From 10c1f1e03a5ff37d7766f6490b0d2ca23bb14fbc Mon Sep 17 00:00:00 2001 From: Shmuel Osovski Date: Sun, 5 Jul 2026 14:35:10 +0300 Subject: [PATCH] added lawgen for use with numerixweave through an mfront style interface --- CLAUDE.md | 2 +- RELEASE_ORDER.md | 139 ++++ laws/plasticity/swift_voce.yaml | 54 ++ packages/algo2code/pyproject.toml | 2 +- packages/mechdsl-core/pyproject.toml | 9 +- .../mechdsl-core/src/mechdsl/lawgen/REUSE.md | 120 +++ .../src/mechdsl/lawgen/__init__.py | 37 + .../src/mechdsl/lawgen/budgets.py | 403 ++++++++++ .../src/mechdsl/lawgen/carrier_emitter.py | 446 +++++++++++ .../mechdsl-core/src/mechdsl/lawgen/cli.py | 588 ++++++++++++++ .../src/mechdsl/lawgen/contracts.py | 277 +++++++ .../src/mechdsl/lawgen/diagnostics.py | 262 +++++++ .../src/mechdsl/lawgen/guard_transforms.py | 234 ++++++ .../src/mechdsl/lawgen/manifest.py | 460 +++++++++++ .../src/mechdsl/lawgen/sympy_to_taichi.py | 737 ++++++++++++++++++ .../src/mechdsl/lawgen/test_emitter.py | 502 ++++++++++++ .../mechdsl-core/tests/lawgen/__init__.py | 0 .../tests/lawgen/fixtures/linear_min.yaml | 10 + .../mechdsl-core/tests/lawgen/test_budgets.py | 311 ++++++++ .../tests/lawgen/test_carrier_emitter.py | 235 ++++++ .../mechdsl-core/tests/lawgen/test_cli.py | 359 +++++++++ .../tests/lawgen/test_contracts.py | 293 +++++++ .../tests/lawgen/test_diagnostics.py | 289 +++++++ .../tests/lawgen/test_guard_injection.py | 402 ++++++++++ .../tests/lawgen/test_lowering_table.py | 405 ++++++++++ .../tests/lawgen/test_manifest.py | 381 +++++++++ .../tests/lawgen/test_sympy_to_taichi.py | 202 +++++ .../tests/lawgen/test_test_emitter.py | 259 ++++++ .../plan_tests/mfront_cyclem0/__init__.py | 0 .../plan_tests/mfront_cyclem0/test_P1-1.py | 84 ++ .../plan_tests/mfront_cyclem0/test_P1-2.py | 97 +++ .../plan_tests/mfront_cyclem0/test_P1-3.py | 51 ++ .../plan_tests/mfront_cyclem0/test_P2-1.py | 77 ++ .../plan_tests/mfront_cyclem0/test_P2-2.py | 141 ++++ .../plan_tests/mfront_cyclem0/test_P2-3.py | 99 +++ .../plan_tests/mfront_cyclem0/test_P2-4.py | 107 +++ .../plan_tests/mfront_cyclem0/test_P3-1.py | 115 +++ .../plan_tests/mfront_cyclem0/test_P3-2.py | 90 +++ .../plan_tests/mfront_cyclem0/test_P3-3.py | 110 +++ .../plan_tests/mfront_cyclem0/test_P4-1.py | 233 ++++++ .../plan_tests/mfront_cyclem0/test_P4-2.py | 36 + .../plan_tests/mfront_cyclem0/test_P4-3.py | 39 + .../recovery_plan_latex_contract/test_p7_5.py | 5 + packages/ti-runtime/pyproject.toml | 2 +- pyproject.toml | 12 +- uv.lock | 453 ++++++++++- 46 files changed, 9145 insertions(+), 24 deletions(-) create mode 100644 RELEASE_ORDER.md create mode 100644 laws/plasticity/swift_voce.yaml create mode 100644 packages/mechdsl-core/src/mechdsl/lawgen/REUSE.md create mode 100644 packages/mechdsl-core/src/mechdsl/lawgen/__init__.py create mode 100644 packages/mechdsl-core/src/mechdsl/lawgen/budgets.py create mode 100644 packages/mechdsl-core/src/mechdsl/lawgen/carrier_emitter.py create mode 100644 packages/mechdsl-core/src/mechdsl/lawgen/cli.py create mode 100644 packages/mechdsl-core/src/mechdsl/lawgen/contracts.py create mode 100644 packages/mechdsl-core/src/mechdsl/lawgen/diagnostics.py create mode 100644 packages/mechdsl-core/src/mechdsl/lawgen/guard_transforms.py create mode 100644 packages/mechdsl-core/src/mechdsl/lawgen/manifest.py create mode 100644 packages/mechdsl-core/src/mechdsl/lawgen/sympy_to_taichi.py create mode 100644 packages/mechdsl-core/src/mechdsl/lawgen/test_emitter.py create mode 100644 packages/mechdsl-core/tests/lawgen/__init__.py create mode 100644 packages/mechdsl-core/tests/lawgen/fixtures/linear_min.yaml create mode 100644 packages/mechdsl-core/tests/lawgen/test_budgets.py create mode 100644 packages/mechdsl-core/tests/lawgen/test_carrier_emitter.py create mode 100644 packages/mechdsl-core/tests/lawgen/test_cli.py create mode 100644 packages/mechdsl-core/tests/lawgen/test_contracts.py create mode 100644 packages/mechdsl-core/tests/lawgen/test_diagnostics.py create mode 100644 packages/mechdsl-core/tests/lawgen/test_guard_injection.py create mode 100644 packages/mechdsl-core/tests/lawgen/test_lowering_table.py create mode 100644 packages/mechdsl-core/tests/lawgen/test_manifest.py create mode 100644 packages/mechdsl-core/tests/lawgen/test_sympy_to_taichi.py create mode 100644 packages/mechdsl-core/tests/lawgen/test_test_emitter.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/__init__.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-1.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-2.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-3.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-1.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-2.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-3.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-4.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-1.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-2.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-3.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-1.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-2.py create mode 100644 packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-3.py diff --git a/CLAUDE.md b/CLAUDE.md index 91e7a2a..591f6e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **MechDSL** (14707 symbols, 30253 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **MechDSL** (15661 symbols, 32507 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/RELEASE_ORDER.md b/RELEASE_ORDER.md new file mode 100644 index 0000000..9fafb68 --- /dev/null +++ b/RELEASE_ORDER.md @@ -0,0 +1,139 @@ +# Release Order — MechDSL law compile → NumerixWeave consumption + +This is the operational runbook for shipping a `mechdsl-lawgen`-emitted +constitutive law from **MechDSL** (this repo) into **NumerixWeave** +(`ticonstit.generated`). It is the P4-3 deliverable of MFront-mimic Cycle M0 +(`dev/plans/mfront_cycleM0.md`, R1). + +## Why this doc exists (R1) + +`NumerixWeave/tools/check_dependency_graph.py` walks the **workspace** import +graph (`libs/`, `apps/`, `bundles/`) to enforce that `ticonstit` never gains a +runtime dependency on MechDSL or SymPy. It cannot see — and is not meant to +see — the **cross-repo build edge**: a MechDSL CLI process reading a YAML law +spec and writing generated Python files into a NumerixWeave checkout is a +build-time / process dependency, not a Python import, so it never appears as +an edge in either repo's dependency graph. + +That invisible edge is real, though: NumerixWeave's `ticonstit.generated` +package depends on MechDSL having produced specific, byte-stable files at +specific paths. The seam that keeps this safe is: + +1. **Committed artifacts** — the generated files are checked into + NumerixWeave, not built at NumerixWeave's install/CI time. NumerixWeave + never invokes MechDSL as part of its own build. +2. **`source_hash`** — `_manifest.json` pins the SHA-256 of the canonical + input formula string for each law, so drift between the committed artifact + and its MechDSL source is detectable without re-running the compiler. +3. **This documented order** — the three steps below, always run in this + sequence, from the correct venv in each repo. + +**MechDSL must never become a NumerixWeave runtime dependency.** The +generated Python under `ticonstit/generated/` imports only Taichi and the +Python standard library — never `mechdsl`, never `sympy`. MechDSL only ever +appears on the NumerixWeave side as an optional, path-pinned **subprocess** +invoked from tests (see `libs/ticonstit/tests/generated/test_swift_voce_equivalence.py` +in NumerixWeave), which is exempt from the runtime-import ban by construction +— it never imports MechDSL/SymPy into the NumerixWeave process. + +## The 3-step release sequence + +### Step 1 — Compile the law in MechDSL (MechDSL venv) + +Run from the **MechDSL** repo root, using MechDSL's own `uv`-managed +environment (R3 — never run this from NumerixWeave's `.venv`): + +```bash +cd /Users/shmuelosovski/Github/Personal/MechDSL +uv run mechdsl-lawgen compile laws/plasticity/swift_voce.yaml \ + --target ticonstit \ + --out /Users/shmuelosovski/Github/Personal/NumerixWeave/libs/ticonstit/src/ticonstit/generated/ +``` + +Notes: + +- `--out` points at the **generated-level** directory + (`libs/ticonstit/src/ticonstit/generated/`) — *not* + `.../generated/plasticity/`. The compiler creates/updates the + `plasticity/` subdirectory itself; pointing `--out` one level too deep + double-nests `plasticity/plasticity/`. +- This writes/updates three artifacts under that `--out` directory: + - `plasticity/swift_voce.py` — the generated Taichi carrier class. + - `_manifest.json` — the law registry entry, including `source_hash`. + - `tests/test_swift_voce.py` — a self-contained generated smoke test. +- Output is byte-stable: running this command twice against an unchanged + `swift_voce.yaml` produces byte-identical `swift_voce.py` and the same + `source_hash` in `_manifest.json`. For the current `swift_voce.yaml`, + `source_hash` is + `7b5af3a8bb79c2e44e0055a7076dd2c9de2ce8c75eb2e262b80bb4e0232d557f` + (SHA-256 of the canonical input formula string, not of the whole file). + +### Step 2 — Commit the generated artifacts into NumerixWeave + +Switch to the **NumerixWeave** checkout and commit the files Step 1 wrote (or +overwrote) under `libs/ticonstit/src/ticonstit/generated/`: + +```bash +cd /Users/shmuelosovski/Github/Personal/NumerixWeave +git add libs/ticonstit/src/ticonstit/generated/plasticity/swift_voce.py \ + libs/ticonstit/src/ticonstit/generated/_manifest.json \ + libs/ticonstit/src/ticonstit/generated/tests/test_swift_voce.py +git commit -m "chore(ticonstit): regenerate SwiftVoce carrier from MechDSL lawgen" +``` + +The generated files are **checked into version control**, not produced by +NumerixWeave's own build or CI. Anyone building or testing NumerixWeave gets +the artifacts from git, not from a live MechDSL invocation — this is what +keeps MechDSL out of NumerixWeave's runtime/build dependency graph. + +Do not hand-edit files under `generated/` (see NumerixWeave's +`libs/ticonstit/src/ticonstit/generated/GENERATED.md`) — re-run Step 1 +instead, so the source of truth stays the MechDSL YAML law spec. + +### Step 3 — NumerixWeave CI verifies and gates + +NumerixWeave CI (and any local `pytest` run) then: + +- Runs the **equivalence gate**, + `libs/ticonstit/tests/generated/test_swift_voce_equivalence.py`, which + re-invokes `mechdsl-lawgen compile` as a **subprocess** (via + `uv run --project mechdsl-lawgen ...`) against a sibling + MechDSL checkout, and byte/value-compares the freshly emitted carrier + against the committed one at `rtol=1e-10`. This test skips cleanly (does + not fail) if no MechDSL checkout is available at + `MECHDSL_ROOT` (default `/Users/shmuelosovski/Github/Personal/MechDSL`). +- Confirms the committed `_manifest.json`'s `source_hash` matches the + pinned/expected value — catching silent drift between the YAML law source + and the committed generated artifact. +- Runs `tools/check_dependency_graph.py`, which enforces (among other things) + that nothing under `libs/` or `apps/` imports `mechdsl` or `sympy` at + runtime. `ticonstit.generated.plasticity.swift_voce` imports only `taichi`. + +## Why the order matters + +The steps must run in this sequence — compile, then commit, then +verify/consume — because NumerixWeave's own tooling (`check_dependency_graph.py`, +its `pyproject.toml` workspace membership, its CI) has no visibility into +MechDSL at all except through: + +- files that already exist in the NumerixWeave git tree (Step 2's commit), + and +- the one deliberately-isolated subprocess call in the equivalence test + (Step 3), which runs MechDSL in MechDSL's own venv and never imports it + into the NumerixWeave process. + +If Step 2 is skipped or done out of order (e.g. NumerixWeave CI tries to +regenerate artifacts itself, or a stale artifact is committed without +re-running Step 1 after a law YAML change), the `source_hash` check in Step 3 +is what catches the drift — it is the only cross-repo consistency signal that +survives the fact that the build edge itself is invisible to static +dependency analysis. + +## See also + +- NumerixWeave: `libs/ticonstit/src/ticonstit/generated/GENERATED.md` — the + consumer-side note on the same seam. +- `dev/plans/mfront_cycleM0.md` (Phase 4, R1) — the plan risk this doc + mitigates. +- `dev/plans/mfront_cycleM0/Phase_4_context_summary.md` — phase-level + context for the compile → commit → consume flow. diff --git a/laws/plasticity/swift_voce.yaml b/laws/plasticity/swift_voce.yaml new file mode 100644 index 0000000..d0d0774 --- /dev/null +++ b/laws/plasticity/swift_voce.yaml @@ -0,0 +1,54 @@ +# Swift-Voce isotropic hardening law — authoritative MechDSL source. +# +# MFront-mimic Cycle M0, Phase 4 (dev/plans/mfront_cycleM0.md lines 114-116). +# +# This YAML is the single source of truth for the SwiftVoce hardening carrier. +# ``mechdsl-lawgen compile laws/plasticity/swift_voce.yaml --target ticonstit +# --out `` lowers the R/H/Q expressions below into a Taichi ``class SwiftVoce`` +# (swift_voce.py), a Cycle 0-shaped _manifest.json, and a self-contained pytest +# file — the artifacts NumerixWeave's ``ticonstit.generated`` package consumes. +# +# Canonical formula + source_hash +# -------------------------------- +# The ``R`` expression string below is spelled VERBATIM to reproduce Cycle 0's +# published ``source_hash`` — the SHA-256 of the canonical generator-input +# formula string "R = sigma0 + Q*(1-exp(-b*p)) + K*((p+p0)**n - p0**n)" +# (= 7b5af3a8bb79c2e44e0055a7076dd2c9de2ce8c75eb2e262b80bb4e0232d557f). The +# compiler prepends "R = " to this raw string and hashes it verbatim (no +# whitespace normalisation), so the spacing here (``1-exp`` and ``**n - p0**n``) +# is load-bearing — do not reformat it. +# +# Q-vs-Q_inf naming (transitional divergence) +# ------------------------------------------- +# The Voce saturation magnitude is spelled ``Q`` in this formula (matching the +# Cycle 0 formula string), while the hand-authored material card names it +# ``Q_inf``. Reconciling the two names is deferred; the manifest emitter runs with +# check_matches_spec disabled during this transition (see manifest.py). +# +# H and Q factors +# --------------- +# H (strain-rate factor) and Q (thermal factor) are the two multiplicative +# factors of the flow stress — NOT dR/dp. This authoritative SwiftVoce law is +# rate-independent and isothermal, so both factors are the neutral ``1`` (their +# derivatives are ``0``), matching the Cycle 0 reference's fallback when the +# optional rate (edot0, m) / thermal (alpha, T_ref) parameters are absent. + +name: SwiftVoce + +# Material parameters, in Cycle 0 order: the Voce base (sigma0, Q, b) followed by +# the Swift power-law term (K, n, p0). Q is the saturation magnitude (see the +# Q-vs-Q_inf note above). +parameters: [sigma0, Q, b, K, n, p0] + +# Free-variable bindings: accumulated plastic strain p (R's primary axis), plastic +# strain rate edot (H's axis), temperature T (Q's axis). +variables: [p, edot, T] + +expressions: + # Isotropic-hardening flow stress: Voce saturation + Swift power-law. + # Spelled verbatim for source_hash fidelity (see the header note). + R: "sigma0 + Q*(1-exp(-b*p)) + K*((p+p0)**n - p0**n)" + # Rate factor (neutral: rate-independent). + H: "1" + # Thermal factor (neutral: isothermal). + Q: "1" diff --git a/packages/algo2code/pyproject.toml b/packages/algo2code/pyproject.toml index d742c0c..acbf801 100644 --- a/packages/algo2code/pyproject.toml +++ b/packages/algo2code/pyproject.toml @@ -8,7 +8,7 @@ version = "0.2.0" description = "Transpile LaTeX algorithm boxes (algpseudocode) to executable Taichi/NumPy/C code" readme = "README.md" license = "MIT" -requires-python = ">=3.12,<3.13" +requires-python = ">=3.11,<3.14" authors = [ { name = "Shmuel Osovski" }, ] diff --git a/packages/mechdsl-core/pyproject.toml b/packages/mechdsl-core/pyproject.toml index 29248a4..26b58a2 100644 --- a/packages/mechdsl-core/pyproject.toml +++ b/packages/mechdsl-core/pyproject.toml @@ -8,7 +8,7 @@ version = "0.2.0" description = "MechDSL core — LaTeX tensor expressions to FEM solver code" readme = "README.md" license = "MIT" -requires-python = ">=3.12,<3.13" +requires-python = ">=3.11,<3.14" authors = [ { name = "Shmuel Osovski" }, ] @@ -23,9 +23,9 @@ classifiers = [ ] dependencies = [ "sympy>=1.12", - "numpy>=1.24", + "numpy>=2.4.2", "opt-einsum>=3.3", - "pyyaml>=6.0", + "pyyaml>=6.0.3", "scipy>=1.17.0", "nrpylatex @ git+https://github.com/SOSOVSKI/nrpylatex", ] @@ -53,6 +53,9 @@ dependencies = [ # rest of the on-device seam path; the base install stays algo2code/Taichi-free. verify = ["torch>=2.0", "taichi>=1.7", "ti-runtime", "algo2code"] +[project.scripts] +mechdsl-lawgen = "mechdsl.lawgen.cli:main" + [tool.uv.sources] ti-runtime = { workspace = true } algo2code = { workspace = true } diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/REUSE.md b/packages/mechdsl-core/src/mechdsl/lawgen/REUSE.md new file mode 100644 index 0000000..ed4dbbe --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/REUSE.md @@ -0,0 +1,120 @@ +# lawgen Reuse Map — MechDSL modules the `ticonstit` target composes + +**Task P1-3** (MFront-mimic Cycle M0, Phase 1). Doc-only, but load-bearing: +Phase 2 (P2-1 lowerer, P2-2 budgets, P2-3 emitter, P2-4 manifest) routes +through the modules named here. AC3: *"No new code duplicates an existing +MechDSL public function."* Where a genuine seam is missing, this doc flags it +as a **gap → P2-\*** rather than inventing a reuse. + +All MechDSL paths below are under `packages/mechdsl-core/src/mechdsl/`. The one +exception is the **scaffold sketch**: it lives in the **NumerixWeave repo** +(`SOSOVSKI/NumerixWeave`), *not* MechDSL — it is a read-only reference (gitignored +there), so every scaffold path in this doc is prefixed `NumerixWeave repo:`. + +--- + +## Headline finding — the expression-lowering seam (P2-1) + +**There is no reusable, clean scalar SymPy-expression → Taichi-string printer to +route lowering through. P2-1 must add one.** + +I read `codegen/taichi_printer.py` (3602 lines) end to end for the seam: + +- The file is entirely **`ArtifactBundle`-oriented FEM emission**. Its public + surface is `emit(bundle) -> str`, the `TaichiCodegenFacade` class, the + `EmissionContext` (an indent-tracking line buffer with `.emit(line)`), and a + family of `emit_*(ctx, bundle)` helpers (`emit_constitutive_update`, + `emit_internal_force_kernel`, `emit_tangent_matvec_kernel`, …). +- The constitutive emitters (`_emit_svk_constitutive`, `_emit_j2_constitutive`, + `_emit_lemaitre_constitutive`) do **not** print a SymPy expression — they emit + **hardcoded literal Taichi lines** via `ctx.emit("S = lam * tr_E * I3 + ...")`. + They are FEM-tensor-field routines (they build `F`, `C`, `E`, run the radial + return), not scalar-law printers. +- There is **no** `sympy` import, `_print_*` method, `StrPrinter`/`CodePrinter` + subclass, `ccode`, or `pycode` anywhere in `taichi_printer.py`. + +The closest thing that already turns a SymPy expression into a Taichi string +lives in a **different** module: `codegen/energy_emitter.py` (and its siblings +`anisotropic_emitter.py`, `spectral_emitter.py`). Their approach is +`_to_taichi_math(pycode(expr))` — SymPy `pycode` followed by a **regex** that +rewrites `math.*` → `ti.*` (`energy_emitter._to_taichi_math`, +`_MATH_TO_TAICHI`). **This is exactly the scaffold's `sympy_to_taichi.py` +anti-pattern** (`sp.pycode` + `_replace_math_calls` regex) — the very pattern +plan risk **R4** says not to ship: no CSE, no numerical guards, no budget +counting, per-component `pycode` calls, brittle regex. + +**Verdict for P2-1:** **gap → P2-1 adds a thin, dedicated scalar-SymPy→Taichi +printer** (whitelist-driven, CSE-first, budget-aware). It should reuse the +*function-name mapping table* concept from `energy_emitter._MATH_TO_TAICHI` / +`_MATH_CALL_RE` (token-boundary regex, fail-loud on unregistered functions — the +one good idea there), but must **not** copy the scaffold's raw `pycode` + broad +regex, and must **not** re-use the FEM `emit_*` functions. Emit through the +existing `EmissionContext` line buffer / `TaichiCodegenFacade` so indentation and +determinism match the rest of MechDSL codegen. + +--- + +## Mapping table + +| lawgen concern | existing MechDSL module + symbol | reuse verdict | +|---|---|---| +| Expression lowering (SymPy → Taichi) | `codegen/taichi_printer.py` — none; closest is `codegen/energy_emitter.py::_to_taichi_math` / `_MATH_TO_TAICHI` (`pycode`+regex, = R4 anti-pattern) | **gap → P2-1** adds a dedicated scalar printer; may reuse the `_MATH_TO_TAICHI` mapping + fail-loud style, not the raw `pycode` path | +| Line buffering / indentation / determinism | `codegen/taichi_printer.py::EmissionContext`, `TaichiCodegenFacade`, `EmissionContext.emit` | **reuse** — emit law `@ti.func`s through `EmissionContext` so output style/determinism match core codegen | +| Deterministic CSE | *none in codebase* (`grep sp.cse` → 0 hits; scaffold uses `sp.cse(order="canonical")`) | **gap → P2-1** wires `sympy.cse` directly (SymPy is a core dep); no MechDSL wrapper exists to reuse | +| Numerical-guard injection (clamp / near-zero / `ti.max`) | `codegen/taichi_printer.py` — guards are **inlined literals** inside FEM emitters (e.g. `ti.max(...)`, near-zero deviatoric guard, NaN-sentinel), no reusable guard util | **gap → P2-\* (P2-1/P2-3)** must add scalar-law guards; the FEM guard idioms are a **pattern to mirror**, not a callable to import | +| Budget / JIT line counting | `codegen/einsum_optimizer.py::estimate_unrolled_lines`, `classify_tier`, `check_kernel_budget`, `check_absolute_budget`, `BudgetExceededError` | **extend** — the counter and `BudgetExceededError` are reusable, but they count **einsum-string + operand-shape** lines, not scalar-expression ops / `cse` temps. **P2-2** adds a scalar-expression budget over the six `TiconstitTarget` knobs; reuse `BudgetExceededError` and the fail-loud style | +| Artifact / manifest writing | `codegen/artifact.py::ArtifactBundle` (`to_json`/`from_json`/`to_dict`/`content_hash`), `ContractionPlan` | **extend** — reuse the JSON-serialisation + content-hash **pattern** for the P2-4 law manifest; the `ArtifactBundle` *shape* is FEM-specific (`problem_ir_dict`, `element_ir_dict`, `contraction_plans`), so P2-4 writes a lawgen manifest rather than an `ArtifactBundle` | +| Emitter dispatch | `codegen/taichi_printer.py::emit` (top-level orchestrator), `TaichiCodegenFacade` (step-wise façade), `_dispatch_family` | **extend** — mirror the `emit()`/façade orchestration shape for the law module; the FEM dispatch (material-model / dynamics-mode / family branches) does not apply to scalar carriers | +| FE localisation passes | `lowering/fe_localise.py::localise`, `localise_and_optimize`, `LocalisationResult`, `EinsumSpec` | **does NOT apply** — these lower a `ProblemIR` to a per-element `ElementIR` (basis functions, quadrature, formulation). A plasticity carrier is a **scalar law** with no element/mesh, so no localisation pass runs on it | +| Einsum extraction / optimisation | `lowering/einsum_extract.py::extract_einsum_specs`, `tangent_matvec_apply_spec`, `build_tangent_matvec_plan` | **does NOT apply** — extracts einsum contraction strings from an `ElementIR`. Scalar R/H/Q expressions have no tensor contractions to extract | +| Boundary lowering | `lowering/boundary.py` | **does NOT apply** — FE boundary-condition lowering, irrelevant to a scalar carrier law | + +--- + +## The four required modules (named explicitly for the audit) + +1. **`taichi_printer`** (`codegen/taichi_printer.py`) — FEM `ArtifactBundle` + emitter. **Reuse** `EmissionContext` / `TaichiCodegenFacade` for the line + buffer and orchestration shape. **Do not reuse** the `emit_*` FEM helpers and + **do not** expect a scalar SymPy→Taichi printer here — it does not exist + (headline gap → P2-1). +2. **`artifact`** (`codegen/artifact.py`) — `ArtifactBundle` / + `ContractionPlan`. **Extend** the JSON + `content_hash` serialisation + *pattern* for the P2-4 law manifest; the bundle *shape* is FEM-specific. +3. **`lowering`** (`lowering/`: `fe_localise.py`, `einsum_extract.py`, + `boundary.py`) — FE-localisation and einsum passes. **None apply** to scalar + law expressions: there is no element, mesh, quadrature, or tensor contraction + in a plasticity carrier. Documented honestly as *out of scope*, not a gap. +4. **Scaffold `sympy_to_taichi`** (`mechdsl_lawgen`) — the read-only scaffold in + the **NumerixWeave repo** (not MechDSL): + `NumerixWeave repo: dev/plans/mfront_add/mfront_mimic/src/mechdsl_lawgen/sympy_to_taichi.py`. + Its `lower_expr` (`sp.pycode` + `_replace_math_calls` regex) and `lower_many` + (`sp.cse` + `check_budget`) are the **R4 anti-pattern not to ship**: broad + regex over `pycode` output, no numerical guards, no whitelist enforcement at + the printer boundary. P2-1 replaces it with a proper printer; the only reusable + *ideas* are the CSE-first structuring and the budget-gate call sequence. + +--- + +## Gaps flagged for Phase 2 + +- **P2-1** — Add a dedicated scalar SymPy→Taichi expression printer + (whitelist/fail-loud like `energy_emitter._MATH_TO_TAICHI`, CSE-first, emitting + through `EmissionContext`). Replaces both the scaffold `sympy_to_taichi.py` and + the `pycode`+regex `energy_emitter` path for law expressions. **This is the + single dependency the rest of Phase 2 stands on.** +- **P2-1** — Wire `sympy.cse(order="canonical")` for deterministic common- + subexpression elimination. No MechDSL CSE wrapper exists to reuse. +- **P2-1 / P2-3** — Add numerical-guard injection for scalar laws (near-zero / + clamp / `ti.max`). The FEM guard idioms in `taichi_printer.py` are a pattern to + mirror, not an importable helper. +- **P2-2** — Add a scalar-expression budget checker over the six + `TiconstitTarget` knobs (`max_expr_ops`, `max_cse_temps_per_func`, + `max_func_lines`, `max_total_generated_lines_per_class`, + `max_piecewise_branches`, `max_pow_with_symbolic_exponent`). **Reuse** + `einsum_optimizer.BudgetExceededError` and its fail-loud style; the existing + `estimate_unrolled_lines` counts einsum lines, not scalar-expression ops, so it + cannot be called directly. +- **P2-4** — Write the law module + manifest by **extending** the + `artifact.py` JSON/`content_hash` serialisation pattern; do not force the + FEM-shaped `ArtifactBundle`. diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/__init__.py b/packages/mechdsl-core/src/mechdsl/lawgen/__init__.py new file mode 100644 index 0000000..48e4f67 --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/__init__.py @@ -0,0 +1,37 @@ +"""MechDSL lawgen — constitutive-law emission for the ticonstit target. + +MFront-mimic Cycle M0. Phase 1 lands the emission *contracts* here; Phase 2 +adds the lowerer that consumes them. The two public contracts, +:class:`TiconstitTarget` (target profile) and :class:`PlasticityCarrierSpec` +(one carrier law), are the sole shared types between the CLI and the lowerer. +""" + +from __future__ import annotations + +from mechdsl.lawgen.contracts import ( + TICONSTIT_CONTRACT_ID, + TICONSTIT_PACKAGE, + PlasticityCarrierSpec, + TiconstitTarget, +) +from mechdsl.lawgen.manifest import ( + GENERATED_BY, + LAWS_ENTRY_FIELDS, + compute_input_formula_hash, + emit_manifest, + formula_matches_spec, + write_manifest, +) + +__all__ = [ + "GENERATED_BY", + "LAWS_ENTRY_FIELDS", + "TICONSTIT_CONTRACT_ID", + "TICONSTIT_PACKAGE", + "PlasticityCarrierSpec", + "TiconstitTarget", + "compute_input_formula_hash", + "emit_manifest", + "formula_matches_spec", + "write_manifest", +] diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/budgets.py b/packages/mechdsl-core/src/mechdsl/lawgen/budgets.py new file mode 100644 index 0000000..bf67677 --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/budgets.py @@ -0,0 +1,403 @@ +"""Pre-emission JIT budget gate for the lawgen lowerer (Task P2-2). + +MFront-mimic Cycle M0, Phase 2 (``dev/plans/mfront_cycleM0.md`` lines 79-82). + +This module is the *gate* that stands between the deterministic lowerer +(:mod:`mechdsl.lawgen.sympy_to_taichi`, P2-1) and any Taichi emission +(Phase 3/4). It counts six quantities over a law's SymPy expressions and its +lowered source lines, and **fails loud** (R2) the moment any of them exceeds the +matching :class:`~mechdsl.lawgen.contracts.TiconstitTarget` budget knob. No +Taichi source is produced here — this is purely a pre-emission check, so an +over-budget law never reaches the printer. + +The six budgets (defaults from ``TiconstitTarget``; the target's knobs override) +------------------------------------------------------------------------------- +============================================ ================================ +Budget knob (``TiconstitTarget`` field) What it counts +============================================ ================================ +``max_expr_ops`` ``sp.count_ops`` of each law + expression (checked per + expression — the *worst* wins). +``max_cse_temps_per_func`` ``len(lowered.temporaries)`` for + each emitted function. +``max_func_lines`` ``len(temporaries) + len(returns)`` + — the emitted line count of one + function. +``max_total_generated_lines_per_class`` the sum of ``max_func_lines`` over + every emitted function of the law. +``max_piecewise_branches`` branch count of the largest + ``sp.Piecewise`` in each + expression. +``max_pow_with_symbolic_exponent`` number of ``sp.Pow`` nodes with a + non-integer exponent, across each + expression. +============================================ ================================ + +Error contract (P3-1 — collect-all) +----------------------------------- +:meth:`BudgetChecker.check_all` runs **all six** budget checks over **all** +expressions/functions and accumulates *every* violation into a +:class:`~mechdsl.lawgen.diagnostics.DiagnosticCollector`, then raises a single +:class:`~mechdsl.lawgen.diagnostics.LawgenError` carrying the whole batch (P3-1 +collect-all: the user sees every over-budget knob in one run, not just the first). +Each budget violation still carries the structured +``" budget exceeded: > "`` text — built by +:meth:`BudgetError.for_budget` — as the diagnostic's ``reason`` (so the measured +value AND the limit are always present), while :class:`BudgetError` itself, which +*is-a* :class:`~mechdsl.codegen.einsum_optimizer.BudgetExceededError`, remains the +per-violation formatter and the public budget-error type. This mirrors how +:class:`mechdsl.lowering.fe_localise.LocalisationError` subclasses the broader +``UnsupportedError``. + +Reusability for downstream phases (P2-3 / P2-4 / P3) +---------------------------------------------------- +The three counters are module-level pure functions +(:func:`count_expr_ops`, :func:`count_piecewise_branches`, +:func:`count_pow_symbolic_exponent`) so they are independently testable and can +be reused outside :class:`BudgetChecker`. :class:`BudgetChecker` binds one +:class:`TiconstitTarget` and exposes :meth:`BudgetChecker.check_all`, which +Phase 3/4 call *before* emission. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING + +import sympy as sp + +# REUSE.md verdict: extend the repo's single message-only budget error rather +# than inventing a parallel hierarchy. ``BudgetError`` IS-A ``BudgetExceededError`` +# so callers that already catch the shared budget error keep working, while +# lawgen code can catch the narrower lawgen-specific type. +from mechdsl.codegen.einsum_optimizer import BudgetExceededError +from mechdsl.lawgen.contracts import TiconstitTarget +from mechdsl.lawgen.diagnostics import DiagnosticCollector, LawgenDiagnostic + +if TYPE_CHECKING: + from collections.abc import Iterable + + from mechdsl.lawgen.sympy_to_taichi import LoweredExpr + +__all__ = [ + "BudgetChecker", + "BudgetError", + "count_expr_ops", + "count_piecewise_branches", + "count_pow_symbolic_exponent", +] + + +class BudgetError(BudgetExceededError): + """Raised when a lawgen expression/emission exceeds a JIT budget knob. + + A lawgen-specific budget error that *is-a* + :class:`~mechdsl.codegen.einsum_optimizer.BudgetExceededError` (the shared, + message-only budget error). Subclassing — rather than raising the base type + directly — lets lawgen callers catch precisely the pre-emission budget + failures while callers that already handle the broader + ``BudgetExceededError`` keep working. This mirrors + :class:`mechdsl.lowering.fe_localise.LocalisationError`, which subclasses the + broader ``UnsupportedError``. + + The message names the violated budget, the measured value, and the limit + (see :meth:`BudgetError.for_budget`). + """ + + @classmethod + def for_budget( + cls, + knob: str, + measured: int, + limit: int, + *, + where: str | None = None, + ) -> BudgetError: + """Build a :class:`BudgetError` with a structured message. + + The message is ``" budget exceeded: > "`` with + an optional ``" ()"`` suffix locating the offending function. + Callers (and tests) can read the knob name, the measured value, and the + limit straight from the text. + """ + message = f"{knob} budget exceeded: {measured} > {limit}" + if where is not None: + message += f" ({where})" + return cls(message) + + +# --------------------------------------------------------------------------- +# Module-level pure counters (independently testable; reused by P2-3/P2-4/P3). +# --------------------------------------------------------------------------- + + +def count_expr_ops(expr: sp.Expr) -> int: + """Return ``sympy.count_ops(expr)`` as a plain ``int``. + + ``sp.count_ops`` is the canonical operation-count metric SymPy exposes; it + counts arithmetic operators and function applications in the expression + tree. ``visual=False`` returns an integer (the default), which we coerce to + a Python ``int`` so the value is JSON/log friendly and version-stable. + + Risk note (P2-2): ``count_ops`` semantics can vary slightly across SymPy + versions, so budget fixtures should use a low overridden limit on a fixed + small expression rather than pinning an exact absolute count. + """ + return int(sp.count_ops(expr, visual=False)) + + +def count_piecewise_branches(expr: sp.Expr) -> int: + """Return the largest branch count of any ``Piecewise`` in ``expr``. + + A ``sympy.Piecewise`` stores one ``(value, condition)`` pair per branch in + its ``.args``, so ``len(piece.args)`` is the branch count. When several + ``Piecewise`` nodes are nested/added, the maximum branch count is what the + ``max_piecewise_branches`` budget guards (the deepest single switch). Returns + ``0`` when the expression contains no ``Piecewise``. + """ + pieces = expr.atoms(sp.Piecewise) + if not pieces: + return 0 + return max(len(piece.args) for piece in pieces) + + +def count_pow_symbolic_exponent(expr: sp.Expr) -> int: + """Count ``Pow`` nodes in ``expr`` that lower to a runtime ``ti.pow``. + + Rule (precise): a ``sympy.Pow`` node counts iff its exponent is **not** a + ``sympy.Integer`` **and** is not an exact half-power ``±sympy.S.Half``. + That is: + + * Integer-literal exponents (``x**2``, ``x**-3``) are exempt — they lower to + plain repeated multiplication and cost the JIT nothing extra. + * Exact ``±1/2`` exponents (``sqrt(x)`` = ``Pow(x, S.Half)`` and + ``1/sqrt(x)`` = ``Pow(x, -S.Half)``) are exempt too. P2-1's printer + (:meth:`mechdsl.lawgen.sympy_to_taichi.TaichiExprPrinter._print_Pow`) + special-cases these to ``ti.sqrt`` / ``1/ti.sqrt`` — **not** ``ti.pow`` — + so they do not incur the runtime-``ti.pow`` cost this budget guards. + The ``±S.Half`` detection mirrors that printer's idiom + (``expr.exp is sp.S.Half`` / ``-expr.exp is sp.S.Half``). + * Every other non-integer exponent counts: a symbolic exponent (``x**n`` + with ``n`` a ``Symbol``), a non-half rational (``x**(3/2)``), and a float + (``x**2.0``) all force a runtime ``ti.pow`` / guarded-``ti.select`` + emission. + """ + return sum( + 1 + for node in expr.atoms(sp.Pow) + if not isinstance(node.exp, sp.Integer) and node.exp != sp.S.Half and -node.exp != sp.S.Half + ) + + +# --------------------------------------------------------------------------- +# BudgetChecker — the pre-emission gate. +# --------------------------------------------------------------------------- + + +def _func_line_count(lowered: LoweredExpr) -> int: + """Emitted line count of one lowered function: temporaries + returns. + + Each CSE temporary is one assignment line and each return expression is one + line, so the emitted line count of a single ``@ti.func`` is the sum of the + two tuple lengths. This is the unit both ``max_func_lines`` (per function) + and ``max_total_generated_lines_per_class`` (summed) are measured in. + """ + return len(lowered.temporaries) + len(lowered.returns) + + +def _budget_diagnostic( + knob: str, + measured: int, + limit: int, + *, + law: str, +) -> LawgenDiagnostic: + """Build the structured :class:`~mechdsl.lawgen.diagnostics.LawgenDiagnostic` for one budget breach. + + The ``reason`` reuses :meth:`BudgetError.for_budget`'s exact message text + (``" budget exceeded: > "``), so it always contains + **both the measured value and the limit** (P3-1 AC3). ``node`` is the budget + knob name; ``fix`` is an actionable hint pointing at the two ways to clear a + budget breach — simplify the law or raise the knob on the + :class:`~mechdsl.lawgen.contracts.TiconstitTarget`. + """ + return LawgenDiagnostic( + law=law, + expression=law, + node=knob, + reason=str(BudgetError.for_budget(knob, measured, limit)), + fix=( + f"reduce the law so its {knob} measure ({measured}) is at most {limit}, " + f"or raise the {knob} knob on the TiconstitTarget if the emission budget allows it." + ), + ) + + +class BudgetChecker: + """Pre-emission JIT-budget gate bound to a :class:`TiconstitTarget`. + + The bound target supplies the six budget knobs, so a caller that constructs + a :class:`TiconstitTarget` with a lowered/raised knob transparently overrides + the module defaults (the defaults *are* the ``TiconstitTarget`` defaults — + there is a single source of truth, P1-1). + + Usage (Phase 3/4) + ----------------- + Construct with the emission target, then call :meth:`check_all` with the + law's SymPy expressions and their lowered results *before* handing anything + to the printer:: + + checker = BudgetChecker(target) + checker.check_all(spec.expressions, lowered_by_role) + + :meth:`check_all` accumulates **every** budget violation (across all six + knobs and all expressions/functions) and raises a single + :class:`~mechdsl.lawgen.diagnostics.LawgenError` carrying them all (P3-1 + collect-all); if nothing is over budget it returns ``None`` and emission may + proceed. + """ + + def __init__(self, target: TiconstitTarget | None = None) -> None: + """Bind the checker to a :class:`TiconstitTarget` (knobs = its fields). + + ``target=None`` uses a default :class:`TiconstitTarget`, i.e. the six + plan-frozen defaults; pass a customised target to override any knob. + """ + self.target: TiconstitTarget = target if target is not None else TiconstitTarget() + + def check_all( + self, + expressions: Mapping[str, sp.Expr] | Iterable[sp.Expr], + lowered: Mapping[str, LoweredExpr] | Iterable[LoweredExpr], + ) -> None: + """Run all six budget checks; collect **all** violations, then raise once. + + Collect-all (P3-1): every over-budget knob — across every expression and + every function — is recorded as a + :class:`~mechdsl.lawgen.diagnostics.LawgenDiagnostic`, and the whole batch + is raised at the end as one + :class:`~mechdsl.lawgen.diagnostics.LawgenError`. A law that trips three + budgets reports all three, not just the first. If nothing is over budget, + :meth:`raise_if_any` is a no-op and this returns ``None``. + + Parameters + ---------- + expressions: + The law's SymPy expressions — either a role→expr mapping (e.g. + ``spec.expressions`` keyed ``"R"``/``"H"``/``"Q"``) or a bare + iterable of expressions. The keys locate the offending expression in + each diagnostic (``law``); the *values* drive the per-expression + checks (``max_expr_ops``, ``max_piecewise_branches``, + ``max_pow_with_symbolic_exponent``). + lowered: + The corresponding lowered results — one :class:`LoweredExpr` per + emitted function, as a mapping (same keys as ``expressions``) or a + bare iterable. Drive the emission-shape checks + (``max_cse_temps_per_func``, ``max_func_lines``, + ``max_total_generated_lines_per_class``). + + Returns + ------- + None + When every budget is satisfied. (Fail-loud only — R2: an over-budget + input never returns cleanly; it raises with every violation.) + + Raises + ------ + LawgenError + Carrying one :class:`~mechdsl.lawgen.diagnostics.LawgenDiagnostic` + per violated budget. Each diagnostic's ``reason`` names the knob, the + measured value, and the limit; its ``fix`` is an actionable hint. + """ + expr_items = _as_named_items(expressions) + lowered_items = _as_named_items(lowered) + collector = DiagnosticCollector() + + # --- Per-expression checks (SymPy side) -------------------------------- + for name, expr in expr_items: + law = f"expression {name!r}" if name is not None else "" + + ops = count_expr_ops(expr) + if ops > self.target.max_expr_ops: + collector.add( + _budget_diagnostic("max_expr_ops", ops, self.target.max_expr_ops, law=law) + ) + + branches = count_piecewise_branches(expr) + if branches > self.target.max_piecewise_branches: + collector.add( + _budget_diagnostic( + "max_piecewise_branches", + branches, + self.target.max_piecewise_branches, + law=law, + ) + ) + + sym_pows = count_pow_symbolic_exponent(expr) + if sym_pows > self.target.max_pow_with_symbolic_exponent: + collector.add( + _budget_diagnostic( + "max_pow_with_symbolic_exponent", + sym_pows, + self.target.max_pow_with_symbolic_exponent, + law=law, + ) + ) + + # --- Per-function + whole-class checks (emission side) ----------------- + total_lines = 0 + for name, low in lowered_items: + law = f"function {name!r}" if name is not None else "" + + temps = len(low.temporaries) + if temps > self.target.max_cse_temps_per_func: + collector.add( + _budget_diagnostic( + "max_cse_temps_per_func", temps, self.target.max_cse_temps_per_func, law=law + ) + ) + + func_lines = _func_line_count(low) + if func_lines > self.target.max_func_lines: + collector.add( + _budget_diagnostic( + "max_func_lines", func_lines, self.target.max_func_lines, law=law + ) + ) + + total_lines += func_lines + + if total_lines > self.target.max_total_generated_lines_per_class: + collector.add( + _budget_diagnostic( + "max_total_generated_lines_per_class", + total_lines, + self.target.max_total_generated_lines_per_class, + law="", + ) + ) + + # Collect-all, fail-loud: one LawgenError with every violation, or no-op. + collector.raise_if_any() + + +def _as_named_items[T]( + items: Mapping[str, T] | Iterable[T], +) -> list[tuple[str | None, T]]: + """Normalise a mapping-or-iterable into ``(name, value)`` pairs. + + Generic in the value type ``_T`` so the checker's per-expression / per- + function loops keep their precise element type (``sp.Expr`` / ``LoweredExpr``) + instead of collapsing to ``object``. + + A ``Mapping`` yields ``(key, value)`` pairs so the error message can name + the offending role (``"R"``/``"H"``/``"Q"``). A bare iterable yields + ``(None, value)`` pairs — the checks still run, only the locating suffix is + omitted. A single ``sp.Expr``/``LoweredExpr`` is *not* iterated element-wise + (a bare ``Expr`` is technically iterable via ``.args``); callers pass a + mapping or an explicit sequence. + """ + if isinstance(items, Mapping): + return [(str(key), value) for key, value in items.items()] + return [(None, value) for value in items] diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/carrier_emitter.py b/packages/mechdsl-core/src/mechdsl/lawgen/carrier_emitter.py new file mode 100644 index 0000000..9d10255 --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/carrier_emitter.py @@ -0,0 +1,446 @@ +"""Spec-driven Taichi carrier-class emitter (Task P4-1). + +MFront-mimic Cycle M0, Phase 4 (``dev/plans/mfront_cycleM0.md`` lines 114-116). + +This is the *class emitter* the Phase-2 handoff (note 4) deferred to Phase 3/4: +the piece that assembles a complete, self-contained Taichi module — a +``class `` with its ``@ti.func`` methods — from a +:class:`~mechdsl.lawgen.contracts.PlasticityCarrierSpec`. The Phase-2 lowerer +(:func:`~mechdsl.lawgen.sympy_to_taichi.lower_expression`) turns a scalar +``sympy.Expr`` into Taichi expression *lines* that reference **bare** symbol +names (``sigma0``, ``p``, ``n``); this module is what maps those bare names onto +the class contract — material parameters → ``self.``, free variables → +method arguments — and wraps the lowered lines in the ``get_R`` / ``get_dR`` / +``get_H`` / ``get_dH`` / ``get_Q`` / ``get_dQ`` / ``eval_components`` methods that +mirror Cycle 0's hand-authored ``swift_voce.py``. + +Contract emitted (matches Cycle 0 ``ticonstit.generated.plasticity.swift_voce``) +-------------------------------------------------------------------------------- +:: + + @ti.data_oriented + class : + def __init__(self, params_dict, ti_type=ti.f64): ... # binds self. + @ti.func get_R(peeq, yield_scale=1.0) + @ti.func get_dR(peeq, yield_scale=1.0) + @ti.func get_H(edot) + @ti.func get_dH(edot) + @ti.func get_Q(T) + @ti.func get_dQ(T) + @ti.func eval_components(peeq, edot, T, yield_scale=1.0) + -> (R, rate, thermal, dR, drate, dthermal) + +Design rules +------------ +* **Per-method lowering** (Phase-2 handoff note 2). Each ``get_*`` method is + lowered *independently* — the R/dR/H/dH/Q/dQ expressions are never lowered as + one batch — so no cross-method CSE reorganises one method's structure into + another's. This keeps each method's shape close to Cycle 0's hand-authored + method (the P4-2 equivalence gate is *numerical*, rtol=1e-10, so the derivative + reorganisation batching would introduce is unnecessary and undesirable). +* **Auto-differentiated derivatives.** ``get_dR`` / ``get_dH`` / ``get_dQ`` are + ``sympy.diff`` of the corresponding factor w.r.t. that factor's *primary* + variable (R,dR → ``peeq``; H,dH → ``edot``; Q,dQ → ``T``), then lowered with + the same guards. The generator never hand-writes a derivative — it differentiates + the authored factor, so a factor and its derivative can never drift. +* **Frozen Phase-2 APIs, reused verbatim.** ``lower_expression(exprs, *, + guards=True, target=...)``, :class:`~mechdsl.lawgen.sympy_to_taichi.LoweredExpr`, + and :meth:`~mechdsl.lawgen.budgets.BudgetChecker.check_all` are called by name, + never re-implemented. The budget gate runs (fail-loud via the P3-1 + :class:`~mechdsl.lawgen.diagnostics.LawgenError`) BEFORE any source is returned. +* **Generated file imports Taichi + stdlib only.** The emitted module never + imports ``mechdsl``, ``sympy``, ``ticonstit``, or NumerixWeave — it is a pure + Taichi runtime carrier (INV-DG-1). This module asserts that invariant on its + own output before returning it. +* **Byte-stable.** Given a fixed ``spec`` (and SymPy/mechdsl version) the emitted + string is deterministic: the lowerer is deterministic, the parameter order is + ``spec.parameters`` order, and no timestamps / absolute paths are embedded. + +Symbol → binding map +-------------------- +The lowered lines carry bare names. :func:`_rebind` rewrites them, using SymPy's +own token boundaries (never a regex over the source string — R4): each material +parameter symbol maps to ``self.`` and each free-variable symbol maps to +the method argument name it is bound to (``p`` → ``peeq``, ``edot`` → ``edot``, +``T`` → ``T``). The rebinding is applied to the SymPy *expression* (via +``expr.subs`` with placeholder symbols) so it is structural, not textual. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import sympy as sp + +from mechdsl.lawgen.budgets import BudgetChecker +from mechdsl.lawgen.contracts import PlasticityCarrierSpec, TiconstitTarget +from mechdsl.lawgen.sympy_to_taichi import LoweredExpr, lower_expression + +if TYPE_CHECKING: + from collections.abc import Mapping + +__all__ = [ + "METHOD_PRIMARY_VARIABLE", + "CarrierEmitResult", + "emit_carrier", + "snake_case_module_name", +] + +# The free-variable name each method differentiates / evaluates against, and the +# Taichi argument name that variable is emitted as. R/dR are functions of the +# accumulated plastic strain (spec variable ``p``, emitted as ``peeq`` to match +# Cycle 0's method signature); H/dH of the plastic strain rate ``edot``; Q/dQ of +# the temperature ``T``. R/H/Q are three INDEPENDENT factors — dR is d(R)/d(peeq), +# NOT H — each auto-differentiated w.r.t. its own primary variable. +METHOD_PRIMARY_VARIABLE: dict[str, str] = {"R": "p", "H": "edot", "Q": "T"} + +# The Taichi method-argument name each primary free variable is emitted as. The +# spec binds ``p`` (accumulated plastic strain); Cycle 0's ``get_R``/``get_dR`` +# name that argument ``peeq``. ``edot``/``T`` keep their spec names. +_VARIABLE_ARGUMENT_NAME: dict[str, str] = {"p": "peeq", "edot": "edot", "T": "T"} + +# Indentation units (4-space, PEP 8). Method bodies live two levels in (class + +# method); ``eval_components`` call lines likewise. +_I1 = " " +_I2 = " " + +# The ``ti.f64`` yield-scale annotation Cycle 0's get_R/get_dR use on the +# ``yield_scale`` argument. +_YIELD_SCALE_SIG = "yield_scale: ti.f64 = 1.0" + + +class CarrierEmitResult: + """The emitted carrier module plus the per-method lowered results. + + ``source`` is the complete, ready-to-write Taichi module string. The + ``lowered_by_method`` map (role → :class:`LoweredExpr`) is exposed so the + caller (the CLI) can feed the *same* lowered results into the budget gate and + reuse the lowered ``R`` for the generated test's JIT smoke kernel — without + lowering twice. + """ + + __slots__ = ("lowered_by_method", "source") + + def __init__(self, source: str, lowered_by_method: dict[str, LoweredExpr]) -> None: + self.source = source + self.lowered_by_method = lowered_by_method + + +def snake_case_module_name(name: str) -> str: + """Return the snake_case module filename stem for a carrier class ``name``. + + The generated *class* is ``spec.name`` (e.g. ``"SwiftVoce"``); Cycle 0's + *module* is snake_case (``swift_voce.py``), decoupled from the class name (the + manifest ``source`` field records the mapping). This converts a CamelCase / + mixed identifier to snake_case: ``"SwiftVoce"`` → ``"swift_voce"``, + ``"J2Plasticity"`` → ``"j2_plasticity"``. An already-snake name is returned + unchanged (lowercased). ``name`` is a validated Python identifier (the CLI's + F4 gate), so the result is always a valid module stem. + """ + chars: list[str] = [] + for index, char in enumerate(name): + # Insert a separator before an uppercase letter that follows a lowercase + # letter or a digit (``SwiftVoce`` → ``Swift_Voce``) or before an + # uppercase letter that starts a new word in an acronym run followed by a + # lowercase (``HTMLParser`` → ``HTML_Parser``). Underscores already + # present are preserved (never doubled). + if char.isupper() and index > 0: + prev = name[index - 1] + nxt = name[index + 1] if index + 1 < len(name) else "" + if prev != "_" and (prev.islower() or prev.isdigit() or (nxt.islower() and nxt != "")): + chars.append("_") + chars.append(char.lower()) + return "".join(chars) + + +def _diff_primary(spec: PlasticityCarrierSpec, role: str) -> sp.Symbol | None: + """Return the SymPy symbol ``role``'s derivative differentiates w.r.t., or ``None``. + + Resolves ``role``'s conventional primary variable + (:data:`METHOD_PRIMARY_VARIABLE`) against the spec's ``variable_bindings``. + Returns the bound symbol when the variable exists, else ``None`` — a factor + whose conventional primary the spec does not bind has no dependence on it, so + its derivative is simply the zero expression (differentiating w.r.t. a fresh + symbol the factor does not contain yields ``0``); the caller then differentiates + against that binding-or-fallback and lowers the result. + """ + primary_name = METHOD_PRIMARY_VARIABLE[role] + return spec.variable_bindings.get(primary_name) + + +def _rebind_map(spec: PlasticityCarrierSpec) -> dict[sp.Symbol, sp.Symbol]: + """Map each spec symbol to the placeholder symbol carrying its emitted name. + + Every material parameter ``P`` maps to a fresh symbol named ``self.P`` and + every free variable ``v`` maps to a fresh symbol named after its method + argument (``p`` → ``peeq``; ``edot``/``T`` unchanged). Substituting these into + the SymPy expression *before* lowering makes the rebinding structural (SymPy + token boundaries, never a regex over the printed source — R4): the lowerer then + prints ``self.sigma0`` / ``peeq`` directly. A ``self.`` name is not a + valid Python identifier, but ``sympy.Symbol`` accepts any string as a name and + the printer emits it verbatim, so ``self.sigma0`` renders exactly. + """ + mapping: dict[sp.Symbol, sp.Symbol] = {} + for param in spec.parameters: + mapping[sp.Symbol(param)] = sp.Symbol(f"self.{param}") + for var_name, var_symbol in spec.variable_bindings.items(): + emitted = _VARIABLE_ARGUMENT_NAME.get(var_name, var_name) + if emitted != var_name: + mapping[var_symbol] = sp.Symbol(emitted) + return mapping + + +def _lower_rebound( + expr: sp.Expr, + rebind: Mapping[sp.Symbol, sp.Symbol], + target: TiconstitTarget, +) -> LoweredExpr: + """Rebind ``expr``'s symbols to their emitted names, then lower it (guarded). + + The substitution is applied to the SymPy expression, so the lowerer sees + ``self.sigma0`` / ``peeq`` as ordinary symbol names and prints them verbatim. + Lowering is per-method (a single ``expr``), guards on (the production path the + P4-2 gate measures), and uses the emission ``target`` so its + ``max_piecewise_branches`` knob gates any ``Piecewise``. + """ + rebound = expr.subs(rebind) + return lower_expression(rebound, guards=True, target=target) + + +def _render_method( + name: str, + signature: str, + lowered: LoweredExpr, + *, + wrap_yield_scale: bool, +) -> str: + """Render one ``@ti.func`` method from a lowered single-expression result. + + ``signature`` is the full argument list after ``self`` (e.g. ``"peeq, + yield_scale: ti.f64 = 1.0"``). The lowered CSE temporaries become local + assignment lines and the single return line becomes the method's ``return``. + When ``wrap_yield_scale`` is set the return is multiplied by ``yield_scale`` + (Cycle 0's ``get_R``/``get_dR`` scale their whole result by it); otherwise the + return is emitted as-is (``get_H``/``get_dH``/``get_Q``/``get_dQ`` take no + yield scale). + """ + lines = [f"{_I1}@ti.func", f"{_I1}def {name}(self, {signature}):"] + for temporary in lowered.temporaries: + lines.append(f"{_I2}{temporary}") + # A single expression lowers to exactly one return line. + (ret,) = lowered.returns + if wrap_yield_scale: + lines.append(f"{_I2}return yield_scale * ({ret})") + else: + lines.append(f"{_I2}return {ret}") + return "\n".join(lines) + + +def _render_init(spec: PlasticityCarrierSpec) -> str: + """Render ``__init__`` binding every material parameter to ``self.``. + + Each parameter is read from ``params_dict`` and stored as ``float`` on the + instance (matching Cycle 0's ``self.sigma0 = float(params_dict["sigma0"])`` + idiom), in ``spec.parameters`` order so the emission is deterministic. The + Taichi scalar type is captured as ``self.float_ti`` (Cycle 0's field name) so + a caller can honour a non-default ``ti_type``. + """ + lines = [ + f"{_I1}def __init__(self, params_dict, ti_type=ti.f64):", + f"{_I2}self.float_ti = ti_type", + ] + for param in spec.parameters: + lines.append(f'{_I2}self.{param} = float(params_dict["{param}"])') + return "\n".join(lines) + + +def _render_eval_components() -> str: + """Render ``eval_components`` delegating to the six factor methods. + + Returns the six-tuple ``(R, rate, thermal, dR, drate, dthermal)`` in Cycle 0's + order by calling ``get_R``/``get_H``/``get_Q`` and their derivatives. This is a + fixed dispatch method (no lowering), so it is a literal template — the only + per-law variation is already captured in the six ``get_*`` methods it calls. + """ + return "\n".join( + [ + f"{_I1}@ti.func", + f"{_I1}def eval_components(self, peeq, edot, T, {_YIELD_SCALE_SIG}):", + f"{_I2}R = self.get_R(peeq, yield_scale)", + f"{_I2}dR = self.get_dR(peeq, yield_scale)", + f"{_I2}rate = self.get_H(edot)", + f"{_I2}drate = self.get_dH(edot)", + f"{_I2}thermal = self.get_Q(T)", + f"{_I2}dthermal = self.get_dQ(T)", + f"{_I2}return R, rate, thermal, dR, drate, dthermal", + ] + ) + + +def _render_header(spec: PlasticityCarrierSpec, *, source_hash: str, generated_by: str) -> str: + """Render the ``# AUTO-GENERATED`` banner + module docstring + ``import taichi``. + + The banner stamps ``generated_by`` and the ``source_hash`` (the input-formula + hash, supplied by the caller) exactly as Cycle 0's header does, so the emitted + file records its provenance. The docstring names the source law and its + contract. Only ``import taichi as ti`` follows — no ``mechdsl`` / ``sympy`` / + ``ticonstit`` import (INV-DG-1). + """ + return ( + f"# AUTO-GENERATED by {generated_by}. source_hash: {source_hash}\n" + f'"""{spec.name} isotropic hardening law (generated plasticity carrier).\n' + "\n" + "DO NOT EDIT BY HAND — regenerate via ``mechdsl-lawgen compile`` from the\n" + "authoritative law YAML. This is a pure Taichi runtime carrier: it imports\n" + "only Taichi (INV-DG-1) — never SymPy, MechDSL, or ticonstit.\n" + "\n" + "Contract (matches the Cycle 0 hand-authored SwiftVoce)::\n" + "\n" + " __init__(params_dict, ti_type=ti.f64)\n" + " @ti.func get_R(peeq, yield_scale=1.0)\n" + " @ti.func get_dR(peeq, yield_scale=1.0)\n" + " @ti.func get_H(edot)\n" + " @ti.func get_dH(edot)\n" + " @ti.func get_Q(T)\n" + " @ti.func get_dQ(T)\n" + " @ti.func eval_components(peeq, edot, T, yield_scale=1.0)\n" + " -> (R, rate, thermal, dR, drate, dthermal)\n" + '"""\n' + "\n" + "import taichi as ti" + ) + + +def emit_carrier( + spec: PlasticityCarrierSpec, + *, + source_hash: str, + generated_by: str, + target: TiconstitTarget | None = None, +) -> CarrierEmitResult: + """Emit the complete Taichi carrier module for ``spec``. + + Pipeline + -------- + 1. Build the symbol → emitted-name rebinding (``self.`` / method args). + 2. Per method, differentiate (for dR/dH/dQ) and lower the rebound expression + **independently** (guards on, per-method — no cross-method CSE). + 3. Run the frozen Phase-2 budget gate over all six lowered methods; a breach + raises a collect-all :class:`~mechdsl.lawgen.diagnostics.LawgenError` + BEFORE any source is returned (fail-loud, R2). + 4. Assemble the header, ``__init__``, the six ``get_*`` methods, and + ``eval_components`` into one module string. + 5. Assert the emitted module imports Taichi only (INV-DG-1) before returning. + + Parameters + ---------- + spec: + The carrier law. ``spec.name`` is the emitted class; ``spec.parameters`` + become ``self.``; ``spec.R``/``spec.H``/``spec.Q`` are the three + factors, each auto-differentiated w.r.t. its own primary variable for the + ``get_d*`` methods. + source_hash: + The input-formula hash to stamp in the banner (from + :func:`~mechdsl.lawgen.manifest.compute_input_formula_hash`). Recorded + verbatim — this function does not recompute it. + generated_by: + The generator + version string for the banner (e.g. + :data:`~mechdsl.lawgen.manifest.GENERATED_BY`). + target: + Emission target whose budget knobs gate the lowering. Defaults to a plain + :class:`~mechdsl.lawgen.contracts.TiconstitTarget`. + + Returns + ------- + CarrierEmitResult + The module ``source`` and the per-method ``lowered_by_method`` map. + + Raises + ------ + LawgenError + If any factor contains an unsupported node or any method exceeds a JIT + budget knob (collect-all, before any source is produced). + """ + active_target = target if target is not None else TiconstitTarget() + rebind = _rebind_map(spec) + + # --- Per-method lowering (independent — no cross-method CSE) -------------- + lowered_by_method: dict[str, LoweredExpr] = {} + for role in ("R", "H", "Q"): + factor = spec.expressions[role] + lowered_by_method[role] = _lower_rebound(factor, rebind, active_target) + + # Derivative w.r.t. this factor's own primary variable. A missing binding + # yields a zero derivative (fresh symbol not present in the factor). + primary = _diff_primary(spec, role) + diff_symbol = primary if primary is not None else sp.Symbol(METHOD_PRIMARY_VARIABLE[role]) + derivative = sp.diff(factor, diff_symbol) + lowered_by_method[f"d{role}"] = _lower_rebound(derivative, rebind, active_target) + + # --- Frozen Phase-2 budget gate (fail-loud BEFORE emission) -------------- + # Keyed by method name so a breach names the offending function. The + # expression map drives the per-expression knobs; the lowered map the + # per-function / whole-class knobs. + expr_by_method: dict[str, sp.Expr] = {} + for role in ("R", "H", "Q"): + rebound_factor = spec.expressions[role].subs(rebind) + expr_by_method[role] = rebound_factor + primary = _diff_primary(spec, role) + diff_symbol = primary if primary is not None else sp.Symbol(METHOD_PRIMARY_VARIABLE[role]) + expr_by_method[f"d{role}"] = sp.diff(spec.expressions[role], diff_symbol).subs(rebind) + BudgetChecker(active_target).check_all(expr_by_method, lowered_by_method) + + # --- Assemble the module -------------------------------------------------- + header = _render_header(spec, source_hash=source_hash, generated_by=generated_by) + init = _render_init(spec) + method_blocks = [ + _render_method( + "get_R", f"peeq, {_YIELD_SCALE_SIG}", lowered_by_method["R"], wrap_yield_scale=True + ), + _render_method( + "get_dR", f"peeq, {_YIELD_SCALE_SIG}", lowered_by_method["dR"], wrap_yield_scale=True + ), + _render_method("get_H", "edot", lowered_by_method["H"], wrap_yield_scale=False), + _render_method("get_dH", "edot", lowered_by_method["dH"], wrap_yield_scale=False), + _render_method("get_Q", "T", lowered_by_method["Q"], wrap_yield_scale=False), + _render_method("get_dQ", "T", lowered_by_method["dQ"], wrap_yield_scale=False), + ] + eval_components = _render_eval_components() + + body_blocks = [init, *method_blocks, eval_components] + class_block = f"@ti.data_oriented\nclass {spec.name}:\n" + "\n\n".join(body_blocks) + + source = f"{header}\n\n\n{class_block}\n" + + _assert_taichi_only(source, spec.name) + return CarrierEmitResult(source=source, lowered_by_method=lowered_by_method) + + +# Modules the generated runtime carrier must never import (INV-DG-1 / R3): the +# offline generator (SymPy, MechDSL) and the consumer (ticonstit / NumerixWeave). +_FORBIDDEN_IMPORT_TOKENS: tuple[str, ...] = ("sympy", "mechdsl", "ticonstit", "numerixweave") + + +def _assert_taichi_only(source: str, name: str) -> None: + """Fail loud if the emitted module imports anything but Taichi + stdlib. + + Scans every ``import`` / ``from ... import`` line for a forbidden top-level + module (:data:`_FORBIDDEN_IMPORT_TOKENS`). The generated carrier is a pure + Taichi runtime artifact (INV-DG-1): a stray ``import sympy`` / ``import + mechdsl`` would break the MechDSL↔NumerixWeave dependency DAG that P4-3's guard + enforces. Runs on the emitter's own output as a self-check before the source + is ever written, so a regression fails here rather than in the cross-repo DAG + gate. + """ + for raw_line in source.splitlines(): + line = raw_line.strip() + if not (line.startswith("import ") or line.startswith("from ")): + continue + # The imported top-level module is the token after ``import`` / ``from``. + module = line.split(None, 2)[1].split(".", 1)[0].lower() + if module in _FORBIDDEN_IMPORT_TOKENS: + raise AssertionError( + f"emitted carrier {name!r} imports forbidden module {module!r} " + f"(line: {line!r}); a generated ticonstit runtime carrier must import " + "only Taichi and the Python standard library (INV-DG-1)." + ) diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/cli.py b/packages/mechdsl-core/src/mechdsl/lawgen/cli.py new file mode 100644 index 0000000..1521be8 --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/cli.py @@ -0,0 +1,588 @@ +"""``mechdsl-lawgen`` command-line entry point (Task P1-2). + +MFront-mimic Cycle M0, Phase 1 (``dev/plans/mfront_cycleM0.md`` lines 59-62). + +This is the *skeleton* CLI: it wires the ``mechdsl-lawgen compile +--target ticonstit --out `` surface, parses a law YAML into a +:class:`~mechdsl.lawgen.contracts.PlasticityCarrierSpec`, and — in ``--dry-run`` +mode — prints the *emission plan* without writing any files. + +There is deliberately **no** lowering / codegen here. Turning the R/H/Q +expressions into Taichi source is Phase 2's job (``taichi_printer`` is not +imported). Likewise there is no ``import ticonstit`` and no NumerixWeave import: +the MechDSL↔NumerixWeave seam is committed artifacts only. The CLI depends only +on :mod:`mechdsl.lawgen`, the standard library, :mod:`yaml`, and :mod:`sympy`. + +Law YAML schema +--------------- +.. code-block:: yaml + + name: swift_voce # str → carrier identifier + parameters: [sigma0, Q, b, K, n] # list[str] → material parameters + variables: [p, edot, T] # list[str] → free-variable bindings + expressions: # map with the three required roles + R: "sigma0 + Q*(1 - exp(-b*p)) + K*p**n" + H: "..." + Q: "..." + +Every parameter and variable becomes a SymPy ``Symbol``; each of ``R``/``H``/``Q`` +is parsed against those symbols (with a **non-eval** parser and a restricted +math allow-list — never ``sympify``/``eval`` on untrusted YAML) and handed to +``PlasticityCarrierSpec``. +""" + +from __future__ import annotations + +import argparse +import keyword +import sys +from pathlib import Path +from tokenize import TokenError + +import sympy as sp +import yaml # type: ignore[import-untyped] # PyYAML ships no stubs +from sympy.core.function import AppliedUndef +from sympy.parsing.sympy_parser import parse_expr, standard_transformations + +from mechdsl.lawgen.carrier_emitter import emit_carrier, snake_case_module_name +from mechdsl.lawgen.contracts import PlasticityCarrierSpec, TiconstitTarget +from mechdsl.lawgen.diagnostics import LawgenError +from mechdsl.lawgen.manifest import ( + GENERATED_BY, + compute_input_formula_hash, + emit_manifest, + write_manifest, +) +from mechdsl.lawgen.test_emitter import emit_tests + +# The only ``--target`` value P1-2 accepts. Kept as a module constant so the +# argparse ``choices`` and the error message agree. +_SUPPORTED_TARGETS: tuple[str, ...] = ("ticonstit",) + +# Required top-level keys in a law YAML document. ``variables`` and +# ``expressions`` drive the spec's bindings and R/H/Q; ``name``/``parameters`` +# name the carrier and its material parameters. +_REQUIRED_KEYS: tuple[str, ...] = ("name", "parameters", "variables", "expressions") + +# Exactly the keys a law YAML may carry at the top level. Anything else is a +# typo or an attempt to smuggle unexpected data — rejected (F6). +_ALLOWED_TOP_KEYS: frozenset[str] = frozenset(_REQUIRED_KEYS) + +# The math functions an R/H/Q expression may call. This is a deliberately +# conservative set for the Phase-1 CLI front-end; P2-4 owns the full Taichi +# allow-list (what the printer can actually lower). +_ALLOWED_FUNCTIONS: dict[str, object] = { + "exp": sp.exp, + "log": sp.log, + "sqrt": sp.sqrt, + "sin": sp.sin, + "cos": sp.cos, + "tan": sp.tan, + "sinh": sp.sinh, + "cosh": sp.cosh, + "tanh": sp.tanh, + "Abs": sp.Abs, + "Max": sp.Max, + "Min": sp.Min, + "sign": sp.sign, +} + +# The *only* global namespace expressions are parsed against (F1). We do NOT +# use parse_expr's default global_dict (``exec('from sympy import *')``, which +# also injects ``__builtins__`` — and thus ``__import__``). Instead: +# * ``"__builtins__": {}`` — set explicitly so ``eval_expr`` cannot re-inject +# the real builtins; this is what makes ``__import__``/``eval``/``open`` +# unreachable, so a hostile expression cannot execute anything. +# * the SymPy numeric/symbol constructors that ``standard_transformations`` +# emits (``Integer``/``Float``/``Rational``/``Symbol``) — required so plain +# numeric literals like ``1`` parse. +# * the math allow-list above. +_PARSE_GLOBALS: dict[str, object] = { + "__builtins__": {}, + "Integer": sp.Integer, + "Float": sp.Float, + "Rational": sp.Rational, + "Symbol": sp.Symbol, + **_ALLOWED_FUNCTIONS, +} + + +class _LawError(Exception): + """A user-facing, message-only error while loading a law YAML. + + Raised for any malformed input (bad YAML, missing key, bad expression) so + the CLI can print a concise ``error: ...`` line to stderr and exit non-zero + instead of leaking a raw traceback. + """ + + +# --------------------------------------------------------------------------- +# YAML → PlasticityCarrierSpec. +# --------------------------------------------------------------------------- + + +def load_carrier_spec(law_path: Path) -> PlasticityCarrierSpec: + """Parse ``law_path`` into a :class:`PlasticityCarrierSpec`. + + Raises :class:`_LawError` (message only, no traceback) for every user-input + failure route: the file is missing, the YAML is malformed, an unknown or + missing key is present, ``name``/``parameters``/``variables`` are not valid + Python identifiers (or collide / duplicate), or an R/H/Q expression fails to + parse / references an undeclared name or unknown function. + """ + spec, _raw = load_carrier_source(law_path) + return spec + + +def load_carrier_source(law_path: Path) -> tuple[PlasticityCarrierSpec, dict[str, str]]: + """Parse ``law_path`` into a spec **and** the verbatim R/H/Q expression strings. + + Same validation and failure routes as :func:`load_carrier_spec` (which is a + thin wrapper over this), but also returns the *raw* expression strings exactly + as the YAML spells them (keyed ``"R"``/``"H"``/``"Q"``). The real-emission path + needs the verbatim ``R`` string to build the canonical ``input_formula`` whose + SHA-256 is the manifest ``source_hash`` — SymPy re-printing ``spec.R`` would + change the spacing/ordering and thus the hash, so the raw string is preserved + here rather than reconstructed from the parsed expression. + """ + try: + raw_text = law_path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise _LawError(f"law YAML not found: {law_path}") from exc + except OSError as exc: # unreadable file, permission error, … + raise _LawError(f"could not read law YAML {law_path}: {exc}") from exc + + try: + doc = yaml.safe_load(raw_text) + except yaml.YAMLError as exc: + raise _LawError(f"malformed YAML in {law_path}: {exc}") from exc + + if not isinstance(doc, dict): + raise _LawError( + f"law YAML {law_path} must be a mapping with keys " + f"{list(_REQUIRED_KEYS)}, got {type(doc).__name__}." + ) + + # F6: reject any unexpected top-level key up front (typos, smuggled data). + unknown_top = sorted(set(doc) - _ALLOWED_TOP_KEYS) + if unknown_top: + raise _LawError(f"law YAML has unknown top-level key(s): {', '.join(unknown_top)}") + + for key in _REQUIRED_KEYS: + if key not in doc: + raise _LawError(f"law YAML missing required key {key!r}") + + name = doc["name"] + if not isinstance(name, str) or not name: + raise _LawError("law YAML key 'name' must be a non-empty string") + # F4: name drives generated file/class names, so it must be a plain Python + # identifier — this rejects path separators/traversal ("../../escape") and + # reserved words ("class"). + _require_identifier(name, kind="name") + + parameters = _as_str_list(doc["parameters"], key="parameters") + variables = _as_str_list(doc["variables"], key="variables") + + # F4: every parameter/variable name must be a valid identifier, with no + # duplicates within a list and no parameter↔variable collision (a name can + # only mean one thing in the symbol table). + for param in parameters: + _require_identifier(param, kind="parameter") + for var in variables: + _require_identifier(var, kind="variable") + _reject_duplicates(parameters, kind="parameter") + _reject_duplicates(variables, kind="variable") + collisions = sorted(set(parameters) & set(variables)) + if collisions: + raise _LawError( + f"name(s) declared as both a parameter and a variable: {', '.join(collisions)}" + ) + + expressions_raw = doc["expressions"] + if not isinstance(expressions_raw, dict): + raise _LawError("law YAML key 'expressions' must be a mapping of R/H/Q strings") + # F6: reject any expressions key outside the required R/H/Q roles. + unknown_roles = sorted(set(expressions_raw) - set(PlasticityCarrierSpec.REQUIRED_EXPRESSIONS)) + if unknown_roles: + raise _LawError( + f"law YAML 'expressions' has unknown role key(s): {', '.join(unknown_roles)}" + ) + + # Build the symbol table every expression is parsed against: one Symbol + # per material parameter and per free variable. Variable symbols double as + # the spec's variable_bindings. + variable_bindings: dict[str, sp.Symbol] = {v: sp.Symbol(v) for v in variables} + locals_table: dict[str, sp.Symbol] = {p: sp.Symbol(p) for p in parameters} + locals_table.update(variable_bindings) + + expressions: dict[str, sp.Expr] = {} + raw_expressions: dict[str, str] = {} + for role in PlasticityCarrierSpec.REQUIRED_EXPRESSIONS: + if role not in expressions_raw: + raise _LawError(f"law YAML 'expressions' missing required role {role!r}") + raw = expressions_raw[role] + expressions[role] = _parse_expr(raw, role=role, locals_table=locals_table) + # Preserve the verbatim source string (``_parse_expr`` has already checked + # it is a str); the manifest source_hash is the SHA-256 of this exact text. + raw_expressions[role] = raw + + try: + spec = PlasticityCarrierSpec( + name=name, + parameters=tuple(parameters), + expressions=expressions, + variable_bindings=variable_bindings, + ) + except (ValueError, TypeError) as exc: + # Surface contract-level validation (empty parameters, etc.) as a clean + # user error rather than a traceback. + raise _LawError(str(exc)) from exc + return spec, raw_expressions + + +def _as_str_list(value: object, *, key: str) -> list[str]: + """Coerce a YAML list of names into ``list[str]``, or raise ``_LawError``.""" + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise _LawError(f"law YAML key {key!r} must be a list of strings") + if not value: + raise _LawError(f"law YAML key {key!r} must be a non-empty list of strings") + return list(value) + + +def _require_identifier(candidate: str, *, kind: str) -> None: + """Reject ``candidate`` unless it is a valid, non-keyword Python identifier (F4). + + Guards against path separators / traversal (``../../escape``) and reserved + words (``class``) leaking into generated file/class names or the symbol + table. + """ + if not candidate.isidentifier() or keyword.iskeyword(candidate): + raise _LawError( + f"{kind} {candidate!r} is not a valid Python identifier " + "(letters, digits, underscores; not starting with a digit; not a reserved word)" + ) + + +def _reject_duplicates(names: list[str], *, kind: str) -> None: + """Raise ``_LawError`` if ``names`` contains a repeat (F4).""" + seen: set[str] = set() + dupes: list[str] = [] + for name in names: + if name in seen and name not in dupes: + dupes.append(name) + seen.add(name) + if dupes: + raise _LawError(f"duplicate {kind} name(s): {', '.join(sorted(dupes))}") + + +def _parse_expr(raw: object, *, role: str, locals_table: dict[str, sp.Symbol]) -> sp.Expr: + """Parse one R/H/Q expression string safely against ``locals_table``. + + Security (F1): parsing goes through :func:`sympy.parsing.sympy_parser.parse_expr` + with a *restricted* namespace — the declared symbols as ``local_dict`` and + :data:`_PARSE_GLOBALS` (``__builtins__`` blanked + SymPy constructors + + math allow-list) as ``global_dict``. ``__import__``, ``eval``, ``open`` etc. + are simply not names in scope, so a hostile expression like + ``__import__('os').system(...)`` cannot execute anything (contrast + ``sympify``, which evals against full builtins). ``transformations`` is the + plain ``standard_transformations`` — no implicit-call magic. + + Validation: + + * F2 — reject any undefined applied function (e.g. a typo ``expp(-b*p)``); + * free-symbol subset — reject any free symbol not in the declared set + (parameters ∪ variables), so a typo'd name (``signa0`` for ``sigma0``) + fails loudly instead of becoming a stray symbol. + """ + if not isinstance(raw, str): + raise _LawError(f"law YAML expression {role!r} must be a string, got {type(raw).__name__}") + try: + expr = parse_expr( + raw, + local_dict=dict(locals_table), + global_dict=dict(_PARSE_GLOBALS), + transformations=standard_transformations, + evaluate=True, + ) + # parse_expr internally ``eval``s transformed code against the restricted + # namespace; a hostile/garbage input (e.g. ``__import__('os').system(...)``) + # can surface as SyntaxError/TokenError/NameError/AttributeError/TypeError/ + # ValueError. None of those *execute* anything — os/__import__/eval are not + # in scope — but we catch broadly so no such failure ever leaks as a raw + # traceback to the user. + except (SyntaxError, TokenError) as exc: + raise _LawError(f"could not parse expression {role!r} ({raw!r}): {exc}") from exc + except Exception as exc: + raise _LawError(f"could not parse expression {role!r} ({raw!r}): {exc}") from exc + + if not isinstance(expr, sp.Expr): + raise _LawError(f"expression {role!r} ({raw!r}) did not parse to a scalar expression") + + # F2: any AppliedUndef is a call to a function that is not in the allow-list + # (a typo like ``expp`` or an intentionally-unknown call). + unknown_funcs = sorted({type(f).__name__ for f in expr.atoms(AppliedUndef)}) + if unknown_funcs: + raise _LawError( + f"expression {role!r} calls unknown function(s): {', '.join(unknown_funcs)}" + ) + + declared = set(locals_table) + undeclared = sorted(sym.name for sym in expr.free_symbols if sym.name not in declared) + if undeclared: + raise _LawError( + f"expression {role!r} references undeclared name(s): {', '.join(undeclared)}" + ) + return expr + + +# --------------------------------------------------------------------------- +# Emission plan (dry-run). +# --------------------------------------------------------------------------- + + +def _planned_paths(out_dir: Path, spec: PlasticityCarrierSpec) -> dict[str, Path]: + """The output paths a *real* compile writes, keyed by role. + + The generated *class* is ``spec.name`` (CamelCase, e.g. ``SwiftVoce``) but the + *module* filename is snake_case (``swift_voce.py``), matching Cycle 0's + file↔class decoupling — the manifest ``source`` field records the mapping. The + dry-run plan and the real emission share this one function, so they never + disagree about where a file lands. + """ + module = snake_case_module_name(spec.name) + carrier = out_dir / "plasticity" / f"{module}.py" + return { + "carrier": carrier, + "manifest": out_dir / "_manifest.json", + "test": out_dir / "tests" / f"test_{module}.py", + } + + +def format_emission_plan( + spec: PlasticityCarrierSpec, target: TiconstitTarget, out_dir: Path +) -> str: + """Render the human-readable emission plan for ``--dry-run``. + + The exact line prefixes below are a stable contract: Phase 4's end-to-end + test asserts against them. Keep them in sync if you change the format. + """ + paths = _planned_paths(out_dir, spec) + lines = [ + "mechdsl-lawgen: emission plan (dry-run, no files written)", + f" carrier: {spec.name}", + " target: ticonstit", + f" contract_id: {target.contract_id}", + f" package: {target.package}", + f" parameters: {', '.join(spec.parameters)}", + f" variables: {', '.join(spec.variable_bindings)}", + " planned output paths:", + f" carrier: {paths['carrier']}", + f" manifest: {paths['manifest']}", + f" test: {paths['test']}", + " expressions to lower:", + ] + for role in PlasticityCarrierSpec.REQUIRED_EXPRESSIONS: + lines.append(f" {role}: {spec.expressions[role]}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Argument parsing + command dispatch. +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + """Construct the ``mechdsl-lawgen`` argument parser.""" + parser = argparse.ArgumentParser( + prog="mechdsl-lawgen", + description="Emit constitutive-law carriers for the ticonstit target.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + compile_parser = subparsers.add_parser( + "compile", + help="Compile a law YAML into a ticonstit carrier.", + description=( + "Parse into a plasticity carrier spec and emit it for the " + "given target. Use --dry-run to print the emission plan without " + "writing any files." + ), + ) + compile_parser.add_argument("law", type=Path, help="Path to the law YAML file.") + compile_parser.add_argument( + "--target", + choices=_SUPPORTED_TARGETS, + default="ticonstit", + help="Emission target (only 'ticonstit' is supported).", + ) + compile_parser.add_argument( + "--out", + type=Path, + default=None, + help="Output directory for generated code (required unless --dry-run).", + ) + compile_parser.add_argument( + "--dry-run", + action="store_true", + help="Print the emission plan and write no files.", + ) + compile_parser.set_defaults(func=_cmd_compile) + return parser + + +def _cmd_compile(args: argparse.Namespace) -> int: + """Handle ``mechdsl-lawgen compile ...``. Returns the process exit code. + + ``--target`` is validated at parse time via argparse ``choices`` (an unknown + target already exited with a usage error), so it needs no re-check here. + """ + # --out is part of the planned paths even in dry-run (nothing is written); + # a real compile requires it. + out_dir = args.out if args.out is not None else Path("") + if not args.dry_run and args.out is None: + print( + "error: --out is required for a real compile " + "(or pass --dry-run to preview the emission plan).", + file=sys.stderr, + ) + return 2 + + try: + spec, raw_expressions = load_carrier_source(args.law) + except _LawError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + target = TiconstitTarget() + + if args.dry_run: + print(format_emission_plan(spec, target, out_dir)) + return 0 + + return _emit(spec, raw_expressions, target, out_dir) + + +def _emit( + spec: PlasticityCarrierSpec, + raw_expressions: dict[str, str], + target: TiconstitTarget, + out_dir: Path, +) -> int: + """Run the real emission pipeline: lower + guard + budget → carrier + manifest + test. + + Writes three artifacts under ``out_dir`` (mirroring Cycle 0's + ``ticonstit.generated`` layout): the Taichi carrier module + (``plasticity/.py``), the ``_manifest.json``, and the self-contained + generated pytest file (``tests/test_.py``). Every step reuses a frozen + Phase-1-3 API — :func:`~mechdsl.lawgen.carrier_emitter.emit_carrier` (which runs + the budget gate), :func:`~mechdsl.lawgen.manifest.emit_manifest` / + :func:`~mechdsl.lawgen.manifest.write_manifest`, and + :func:`~mechdsl.lawgen.test_emitter.emit_tests`. A + :class:`~mechdsl.lawgen.diagnostics.LawgenError` (unsupported node / over-budget) + is reported to stderr and turned into a non-zero exit — no partial files are + written, because emission is fail-loud before any write. + + Byte-stability: the emitted carrier, manifest, and test are deterministic for a + fixed spec (no timestamps / absolute paths embedded), so two runs produce + byte-identical output — the P4-1 determinism acceptance criterion. + """ + paths = _planned_paths(out_dir, spec) + module = snake_case_module_name(spec.name) + + # The canonical generator-input formula whose verbatim SHA-256 is the manifest + # source_hash. Built from the raw YAML ``R`` string with the ``"R = "`` prefix, + # matching Cycle 0's ``"R = sigma0 + Q*(1-exp(-b*p)) + K*((p+p0)**n - p0**n)"``. + input_formula = f"R = {raw_expressions['R']}" + source_hash = compute_input_formula_hash(input_formula) + + # The manifest ``tests`` field records where the generated test lives in the + # ticonstit tree (a stable repo-relative path, not the scratch --out abspath), + # so the manifest stays byte-stable regardless of where the compile emits. + test_manifest_path = f"libs/ticonstit/tests/generated/test_{module}.py" + + try: + # emit_carrier runs the frozen budget gate (fail-loud) before returning. + carrier = emit_carrier( + spec, source_hash=source_hash, generated_by=GENERATED_BY, target=target + ) + entry = emit_manifest( + spec, + input_formula=input_formula, + target_contract=_TICONSTIT_RUNTIME_CONTRACT, + exports=spec.name, + source=f"{module}.py", + tests=[test_manifest_path], + required=_required_parameters(spec), + optional=_optional_parameters(spec), + # Fail loud if the hashed input formula is not symbolically spec.R — a + # stale/mistyped formula would otherwise fingerprint the wrong law. Safe + # to enable here because the emitted law is internally consistent (the + # YAML spells the saturation param `Q` in both the formula and the + # parameter list). The Cycle 0 `Q` (formula) vs `Q_inf` (material card) + # divergence is a separate, manifest-parameter-name matter that this + # formula<->spec check does not touch. + check_matches_spec=True, + ) + except LawgenError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + except (ValueError, TypeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + # Write the three artifacts. Parent dirs are created by each writer. + carrier_path = paths["carrier"] + carrier_path.parent.mkdir(parents=True, exist_ok=True) + carrier_path.write_text(carrier.source, encoding="utf-8") + + write_manifest([entry], paths["manifest"]) + + # The generated test lowers R itself (lowered_r=None): its smoke kernel takes + # peeq as an argument and pins every *bare* material-parameter name to a local + # placeholder (``p0 = 1.0``, ``n = 1.0``, …), so it needs the BARE-symbol lowered + # R, NOT the carrier's rebound ``self.`` form. Feeding the rebound R here + # would emit ``self.p0`` into the kernel with no ``self`` in scope + # (TaichiNameError). The rebound form is for the carrier class only. + emit_tests(spec, lowered_r=None, target_test_path=paths["test"]) + + print( + f"mechdsl-lawgen: emitted {spec.name} carrier\n" + f" carrier: {carrier_path}\n" + f" manifest: {paths['manifest']}\n" + f" test: {paths['test']}\n" + f" source_hash: {source_hash}" + ) + return 0 + + +#: The runtime contract the SwiftVoce carrier implements (Cycle 0 ``_manifest.json`` +#: ``target_contract``). This is the *runtime* contract name, deliberately distinct +#: from ``TiconstitTarget.contract_id`` (the emission-contract id). +_TICONSTIT_RUNTIME_CONTRACT: str = "VoceHardeningModel" + +#: The Voce base parameters that are always required. Any remaining spec parameter +#: is optional (the Swift power-law term / rate / thermal extras). +_REQUIRED_PARAMETER_NAMES: frozenset[str] = frozenset({"sigma0", "Q", "Q_inf", "b"}) + + +def _required_parameters(spec: PlasticityCarrierSpec) -> list[str]: + """The spec parameters that are Voce-base required, in spec order.""" + return [p for p in spec.parameters if p in _REQUIRED_PARAMETER_NAMES] + + +def _optional_parameters(spec: PlasticityCarrierSpec) -> list[str]: + """The spec parameters that are optional (Swift/rate/thermal), in spec order.""" + return [p for p in spec.parameters if p not in _REQUIRED_PARAMETER_NAMES] + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point. Returns a process exit code (0 = success).""" + parser = build_parser() + args = parser.parse_args(argv) + handler = args.func + result: int = handler(args) + return result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/contracts.py b/packages/mechdsl-core/src/mechdsl/lawgen/contracts.py new file mode 100644 index 0000000..9be130c --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/contracts.py @@ -0,0 +1,277 @@ +"""Lawgen emission contracts — the seam between the CLI and the Phase 2 lowerer. + +MFront-mimic Cycle M0, Phase 1 (``dev/plans/mfront_cycleM0.md`` lines 55-58). + +This module defines the two frozen dataclasses that every downstream Phase 2 +task and the Phase 4 end-to-end test consume: + +* :class:`TiconstitTarget` — the ``ticonstit`` emission *target profile*: the + frozen contract id, the generated-code package, the default Taichi scalar + type, and the six JIT budget knobs. The budget knob defaults are transcribed + from the plan (lines 79-82) and MUST stay in lock-step with P2-2's + ``budgets.py`` — P2-2 references these fields by name. +* :class:`PlasticityCarrierSpec` — a single plasticity carrier law: its name, + material parameters, the R/H/Q constitutive expressions, and the free-variable + bindings (``p``, ``edot``, ``T``). + +Per MechDSL IR discipline (``.claude/rules/ir.md``) both dataclasses are +immutable (``frozen=True``) and validate at construction time in +``__post_init__``. There is deliberately **no** NumerixWeave / ``ticonstit`` +import here — the MechDSL↔NumerixWeave seam is committed artifacts only +(plan risk R5). SymPy is a first-class mechdsl-core dependency and is the +natural carrier for the R/H/Q expressions. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import TYPE_CHECKING + +# SymPy is imported at runtime because ``PlasticityCarrierSpec.__post_init__`` +# validates value types with ``isinstance(v, sp.Expr)`` / ``isinstance(v, +# sp.Symbol)`` (F3). SymPy is a first-class mechdsl-core dependency, so the +# runtime import is cheap and expected. +import sympy as sp + +if TYPE_CHECKING: + from collections.abc import Mapping + +# --------------------------------------------------------------------------- +# Frozen contract identity. +# +# The canonical contract id is the *only* accepted value for +# ``TiconstitTarget.contract_id``. It is module-level so downstream code can +# reference the constant instead of hard-coding the string literal. +# --------------------------------------------------------------------------- + +TICONSTIT_CONTRACT_ID: str = "ticonstit.plasticity_carrier.v1" +"""Canonical, frozen ticonstit emission-contract id (plan line 55).""" + +TICONSTIT_PACKAGE: str = "ticonstit.generated" +"""Default Python package for ticonstit-generated code (plan line 55).""" + + +# --------------------------------------------------------------------------- +# TiconstitTarget — emission target profile. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class TiconstitTarget: + """The ``ticonstit`` emission target profile. + + Carries the frozen contract identity, the generated-code package, the + default Taichi scalar type, and the six JIT budget knobs the Phase 2 + lowerer enforces. + + ``ti_type_default`` defaults to ``"ti.f64"``: MechDSL's tension-positive + conventions (``07-CONVENTIONS.md``) and its verification tolerances + (displacement diff < 1e-10) require double precision, so f64 is the + sensible default scalar type for generated carriers. + + The six budget-knob defaults are transcribed verbatim from + ``dev/plans/mfront_cycleM0.md`` (lines 79-82) and are frozen: P2-2's + ``budgets.py`` references these field names, so they must not be renamed or + have their defaults drift. + """ + + contract_id: str = TICONSTIT_CONTRACT_ID + package: str = TICONSTIT_PACKAGE + ti_type_default: str = "ti.f64" + + # --- JIT budget knobs (defaults frozen against P2-2; plan lines 79-82) --- + max_expr_ops: int = 400 + max_cse_temps_per_func: int = 96 + max_func_lines: int = 220 + max_total_generated_lines_per_class: int = 900 + max_piecewise_branches: int = 8 + max_pow_with_symbolic_exponent: int = 12 + + def __post_init__(self) -> None: + # F5: the three identity fields must be non-empty *str* — a list or any + # other truthy non-string is rejected, not silently accepted. + for str_field in ("contract_id", "package", "ti_type_default"): + value = getattr(self, str_field) + if not isinstance(value, str) or not value: + raise TypeError( + f"TiconstitTarget.{str_field} must be a non-empty string, " + f"got {type(value).__name__} {value!r}." + ) + if self.contract_id != TICONSTIT_CONTRACT_ID: + raise ValueError( + f"TiconstitTarget.contract_id={self.contract_id!r} is not the " + f"canonical ticonstit contract id; the only accepted value is " + f"{TICONSTIT_CONTRACT_ID!r}." + ) + # F5: each budget knob must be a genuine positive int. ``type(v) is int`` + # rejects ``bool`` (``type(True) is bool``) and ``float`` (``1.5``) that + # an ``isinstance`` / ``> 0`` check would silently wave through. + for knob in ( + "max_expr_ops", + "max_cse_temps_per_func", + "max_func_lines", + "max_total_generated_lines_per_class", + "max_piecewise_branches", + "max_pow_with_symbolic_exponent", + ): + value = getattr(self, knob) + if type(value) is not int: + raise TypeError( + f"TiconstitTarget.{knob} must be an int, got {type(value).__name__} {value!r}." + ) + if value <= 0: + raise ValueError(f"TiconstitTarget.{knob}={value!r} must be a positive integer.") + + +# --------------------------------------------------------------------------- +# PlasticityCarrierSpec — one plasticity carrier law. +# --------------------------------------------------------------------------- + + +def _freeze_mapping(raw: Mapping[str, sp.Expr] | None) -> Mapping[str, sp.Expr]: + """Wrap ``raw`` as a read-only ``MappingProxyType``. + + Frozen dataclasses block attribute reassignment but not in-place mutation + of a stored dict. Wrapping the R/H/Q expression map and the variable + bindings in a ``MappingProxyType`` (cached via ``object.__setattr__`` in + ``__post_init__``) keeps the spec genuinely immutable, matching the IR + discipline used across ``mechdsl.ir``. + """ + return MappingProxyType(dict(raw or {})) + + +@dataclass(frozen=True) +class PlasticityCarrierSpec: + """A single plasticity carrier law, ready for Phase 2 lowering. + + Fields + ------ + name: + Identifier for the carrier (drives the generated class/function name). + parameters: + Material parameter names (e.g. ``("sigma_y0", "K", "n")``), stored as a + tuple so the ordering is stable for codegen. + expressions: + The three constitutive expressions keyed exactly by ``"R"``, ``"H"``, + and ``"Q"`` as SymPy expressions. Storing them in a map keyed by role + preserves *which is which* through lowering. All three keys are + required. + variable_bindings: + Free-variable name → SymPy symbol map (accumulated plastic strain ``p``, + strain rate ``edot``, temperature ``T``). These are the runtime inputs + the generated carrier is a function of. + monotone_check: + When ``True``, the P3-2 test emitter appends a monotonicity assertion to + the generated test file — checking that the hardening ``R`` is + non-decreasing in the accumulated plastic strain across the sample + points. Defaults to ``False`` (no monotonicity block emitted), so every + existing construction site (the CLI, Phase-1 tests) that omits it keeps + working unchanged. It is a plain flag, not a SymPy object, so it is + validated as a genuine ``bool`` in ``__post_init__``. + + The R/H/Q expressions and variable bindings are stored as SymPy objects (not + strings) because Phase 2 routes them through ``taichi_printer``, which + consumes SymPy expressions directly. + """ + + #: The three role keys every carrier must provide, in canonical order. + REQUIRED_EXPRESSIONS: tuple[str, ...] = field( + default=("R", "H", "Q"), init=False, repr=False, compare=False + ) + + name: str + parameters: tuple[str, ...] + expressions: Mapping[str, sp.Expr] + variable_bindings: Mapping[str, sp.Symbol] + monotone_check: bool = False + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("PlasticityCarrierSpec.name must be a non-empty string.") + if isinstance(self.parameters, (str, bytes)): + # A bare str/bytes is iterable, so tuple() would silently split it + # into per-character garbage names (e.g. "Kn" -> ('K', 'n')). + # Reject it up front; parameters must be a sequence of name strings. + raise ValueError( + f"PlasticityCarrierSpec(name={self.name!r}) parameters must be a " + f"sequence of parameter-name strings, not a single " + f"{type(self.parameters).__name__}; got {self.parameters!r}." + ) + if not self.parameters: + raise ValueError( + f"PlasticityCarrierSpec(name={self.name!r}) requires at least one " + "material parameter." + ) + + missing = [k for k in self.REQUIRED_EXPRESSIONS if k not in self.expressions] + if missing: + raise ValueError( + f"PlasticityCarrierSpec(name={self.name!r}) is missing required " + f"expression(s) {missing}; all of {list(self.REQUIRED_EXPRESSIONS)} " + "must be supplied." + ) + if not self.variable_bindings: + raise ValueError( + f"PlasticityCarrierSpec(name={self.name!r}) requires at least one " + "variable binding (e.g. p, edot, T)." + ) + + # Normalise parameters to a tuple and freeze the mapping fields so the + # spec is genuinely immutable (frozen=True alone does not stop mutation + # of a stored dict). + object.__setattr__(self, "parameters", tuple(self.parameters)) + object.__setattr__(self, "expressions", _freeze_mapping(self.expressions)) + object.__setattr__(self, "variable_bindings", _freeze_mapping(self.variable_bindings)) + + # F3: validate value types after freezing. Keys must be non-empty + # strings; every expression value must be an ``sp.Expr`` and every + # binding an ``sp.Symbol``. Requiring ``sp.Expr`` also closes the + # alias-mutation hole — a mutable ``list``/``dict`` value is not an + # ``sp.Expr``, so it can never be stored on the spec. + for key, expr in self.expressions.items(): + if not isinstance(key, str) or not key: + raise TypeError( + f"PlasticityCarrierSpec(name={self.name!r}) expression keys must be " + f"non-empty strings; got {key!r}." + ) + if not isinstance(expr, sp.Expr): + raise TypeError( + f"PlasticityCarrierSpec(name={self.name!r}) expression {key!r} must be a " + f"sympy.Expr, got {type(expr).__name__} {expr!r}." + ) + for key, sym in self.variable_bindings.items(): + if not isinstance(key, str) or not key: + raise TypeError( + f"PlasticityCarrierSpec(name={self.name!r}) variable-binding keys must be " + f"non-empty strings; got {key!r}." + ) + if not isinstance(sym, sp.Symbol): + raise TypeError( + f"PlasticityCarrierSpec(name={self.name!r}) variable binding {key!r} must be a " + f"sympy.Symbol, got {type(sym).__name__} {sym!r}." + ) + + # F5: ``monotone_check`` is an emit-time flag, not a SymPy object; it must + # be a genuine ``bool``. ``type(v) is bool`` rejects a truthy int/str that + # an ``isinstance`` check would silently wave through. + if type(self.monotone_check) is not bool: + raise TypeError( + f"PlasticityCarrierSpec(name={self.name!r}) monotone_check must be a bool, got " + f"{type(self.monotone_check).__name__} {self.monotone_check!r}." + ) + + @property + def R(self) -> sp.Expr: + """The isotropic-hardening flow-stress expression ``R``.""" + return self.expressions["R"] + + @property + def H(self) -> sp.Expr: + """The hardening-modulus expression ``H``.""" + return self.expressions["H"] + + @property + def Q(self) -> sp.Expr: + """The saturation / rate-term expression ``Q``.""" + return self.expressions["Q"] diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/diagnostics.py b/packages/mechdsl-core/src/mechdsl/lawgen/diagnostics.py new file mode 100644 index 0000000..ac4e09d --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/diagnostics.py @@ -0,0 +1,262 @@ +"""Structured, collect-all diagnostics for the lawgen lowerer (Task P3-1). + +MFront-mimic Cycle M0, Phase 3 (``dev/plans/mfront_cycleM0.md`` lines 98-100). + +This module is the *reporting* layer that stands beside the Phase-2 gates +(:mod:`mechdsl.lawgen.budgets`, P2-2; :mod:`mechdsl.lawgen.sympy_to_taichi`, +P2-4). Where Phase 2 fails on the **first** problem, P3-1 turns those checks into +**collect-all** ones: every unsupported SymPy node and every budget breach +produces a structured :class:`LawgenDiagnostic`, they are accumulated in a +:class:`DiagnosticCollector`, and the whole batch is raised at once as a single +:class:`LawgenError` so the user sees *every* problem in one run. + +Design rules (plan R2, "no silent fallback") +--------------------------------------------- +* **Fail loud, but collect first.** Nothing is swallowed: an accumulated + diagnostic always surfaces via :meth:`DiagnosticCollector.raise_if_any`. The + only "no-op" path is the genuinely clean one (zero diagnostics). +* **Every diagnostic is complete.** All five fields of :class:`LawgenDiagnostic` + (``law`` / ``expression`` / ``node`` / ``reason`` / ``fix``) are required, + non-empty strings, validated at construction. In particular ``fix`` must be an + actionable hint — a diagnostic with no remedy is a bug, so an empty ``fix`` + raises. +* **The aggregate is discoverable.** :class:`LawgenError` carries the full list + of diagnostics on ``.diagnostics`` *and* embeds each one in ``.args`` and in + its message, so a caller (or a test) can read every problem off the exception + without re-running the compile. + +Relationship to the Phase-2 error types +---------------------------------------- +:class:`LawgenError` subclasses :class:`NotImplementedError`. Phase 2's +fail-loud raises for unsupported nodes were ``NotImplementedError`` (the +unmapped-function / non-exhaustive-``Piecewise`` path); making the collect-all +aggregate a ``NotImplementedError`` keeps every ``except NotImplementedError`` / +``pytest.raises(NotImplementedError)`` site working while upgrading the payload +from a bare message to a structured, multi-diagnostic report. The per-budget +:class:`~mechdsl.lawgen.budgets.BudgetError` is still used *inside* the budget +checker to format each budget diagnostic's ``reason`` (measured + limit); the +raised *aggregate* is now the richer :class:`LawgenError`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + from types import TracebackType + +__all__ = [ + "DiagnosticCollector", + "LawgenDiagnostic", + "LawgenError", +] + + +@dataclass(frozen=True) +class LawgenDiagnostic: + """One structured problem found while lowering a constitutive law. + + Immutable (frozen) so a collected diagnostic cannot be mutated between the + point it is recorded and the point it is reported. All five fields are + **required, non-empty strings** — a diagnostic missing any of them is + rejected at construction (see :meth:`__post_init__`), because a report the + user cannot act on is worse than no report. + + Attributes + ---------- + law: + The law / role the problem belongs to, e.g. the expression role + ``"R"``/``"H"``/``"Q"`` or a law name. Answers *which law*. + expression: + A human-readable rendering of the offending expression (typically + ``str(expr)``). Answers *where in the law*. + node: + The specific offending node or budget knob — the unsupported SymPy + function name (``"erf"``), or the budget knob (``"max_expr_ops"``). + Answers *what exactly*. + reason: + Why it fails. For a budget breach this MUST contain both the measured + value and the limit (e.g. ``"measured 4 > limit 2"``) so the user can + see how far over budget the law is. + fix: + An actionable remedy hint — never empty. Tells the user what to change + (register the function, simplify the law, raise the knob, …). + """ + + law: str + expression: str + node: str + reason: str + fix: str + + def __post_init__(self) -> None: + """Reject any empty/blank field — every diagnostic must be complete. + + Each of the five fields must be a non-empty string once stripped of + surrounding whitespace. ``fix`` is included: an actionable remedy is + mandatory (a diagnostic with no fix is a silent dead-end, which R2 + forbids). + """ + for name in ("law", "expression", "node", "reason", "fix"): + value = getattr(self, name) + if not isinstance(value, str) or not value.strip(): + raise ValueError( + f"LawgenDiagnostic.{name} must be a non-empty string, got {value!r}. " + "Every diagnostic field (including an actionable 'fix') is required — " + "an incomplete diagnostic is a bug (no silent fallback, R2)." + ) + + def render(self) -> str: + """Render this diagnostic as one multi-field human-readable block. + + Used both in the :class:`LawgenError` message and anywhere a single + diagnostic needs printing. Every field is labelled so the report reads + the same whether it is one diagnostic or one of many. + """ + return ( + f"[{self.law}] {self.node}\n" + f" expression: {self.expression}\n" + f" reason: {self.reason}\n" + f" fix: {self.fix}" + ) + + +class LawgenError(NotImplementedError): + """Aggregate error carrying *every* diagnostic collected in one lowering run. + + Subclasses :class:`NotImplementedError` so Phase 2's fail-loud contract for + unsupported nodes (which raised ``NotImplementedError``) is preserved: any + existing ``except NotImplementedError`` / ``pytest.raises(NotImplementedError)`` + still catches the aggregate, only now the payload is a structured, + collect-all report instead of a single message. + + The diagnostics are exposed three ways, so a caller never has to re-run the + compile to learn what went wrong: + + * :attr:`diagnostics` — the tuple of :class:`LawgenDiagnostic` records. + * ``args`` — the rendered message *plus* each diagnostic, so + ``LawgenError.args`` contains every problem (the P3-1 acceptance check). + * ``str(err)`` — a header line naming the count, then every diagnostic's + :meth:`LawgenDiagnostic.render` block. + """ + + def __init__(self, diagnostics: Iterable[LawgenDiagnostic]) -> None: + """Build the aggregate from one-or-more collected diagnostics. + + ``diagnostics`` must be non-empty — a ``LawgenError`` with nothing to + report is meaningless (:meth:`DiagnosticCollector.raise_if_any` only + constructs one when there is at least one diagnostic). The rendered + message and the individual diagnostics are both placed in ``args`` so + every problem is discoverable straight off the exception. + """ + collected = tuple(diagnostics) + if not collected: + raise ValueError( + "LawgenError requires at least one diagnostic; raise nothing when " + "the collector is empty (use DiagnosticCollector.raise_if_any)." + ) + self.diagnostics: tuple[LawgenDiagnostic, ...] = collected + message = self._format(collected) + # Put the message AND every diagnostic in ``args`` so a caller reading + # ``err.args`` sees all problems (P3-1 AC: "both appear in .args"). + super().__init__(message, *collected) + + @staticmethod + def _format(diagnostics: tuple[LawgenDiagnostic, ...]) -> str: + """Compose the header + every diagnostic block into one message string.""" + count = len(diagnostics) + noun = "diagnostic" if count == 1 else "diagnostics" + header = f"lawgen emission failed with {count} {noun} (no silent fallback — R2):" + blocks = "\n".join(diag.render() for diag in diagnostics) + return f"{header}\n{blocks}" + + +@dataclass +class DiagnosticCollector: + """Accumulates :class:`LawgenDiagnostic` records, then raises them all at once. + + The collect-all counterpart to Phase 2's fail-first gates: a caller + :meth:`add`\\ s a diagnostic for *every* problem it finds and calls + :meth:`raise_if_any` at the end, so a single run reports every problem rather + than aborting on the first. + + Usage (imperative):: + + collector = DiagnosticCollector() + for expr in exprs: + if problem(expr): + collector.add(LawgenDiagnostic(...)) + collector.raise_if_any() # raises LawgenError with ALL problems, or no-op + + Usage (context manager):: + + with DiagnosticCollector() as collector: + collector.add(...) + # raise_if_any() runs on clean block exit + + The context-manager form calls :meth:`raise_if_any` on a *clean* exit only: + if the ``with`` body itself raises, that exception propagates untouched (we + never swallow it to raise a possibly-empty aggregate). + """ + + _diagnostics: list[LawgenDiagnostic] = field(default_factory=list) + + def add(self, diagnostic: LawgenDiagnostic) -> None: + """Record one diagnostic. Order is preserved (append).""" + self._diagnostics.append(diagnostic) + + def extend(self, diagnostics: Iterable[LawgenDiagnostic]) -> None: + """Record several diagnostics in order (convenience over :meth:`add`).""" + self._diagnostics.extend(diagnostics) + + @property + def diagnostics(self) -> tuple[LawgenDiagnostic, ...]: + """The diagnostics collected so far, in insertion order (immutable view).""" + return tuple(self._diagnostics) + + def __bool__(self) -> bool: + """True iff at least one diagnostic has been collected.""" + return bool(self._diagnostics) + + def __len__(self) -> int: + """Number of diagnostics collected so far.""" + return len(self._diagnostics) + + def __iter__(self) -> Iterator[LawgenDiagnostic]: + """Iterate the collected diagnostics in insertion order.""" + return iter(self._diagnostics) + + def raise_if_any(self) -> None: + """Raise :class:`LawgenError` with ALL collected diagnostics, or no-op. + + If any diagnostics were collected, raise a single :class:`LawgenError` + carrying every one of them (so the user sees all problems at once). If + none were collected, return cleanly — this is the *only* silent path, and + it is silent precisely because there is nothing to report. + """ + if self._diagnostics: + raise LawgenError(self._diagnostics) + + def __enter__(self) -> DiagnosticCollector: + """Enter the context-manager form; returns ``self`` for ``as`` binding.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> Literal[False]: + """On a clean block exit, raise any collected diagnostics. + + If the ``with`` body raised (``exc is not None``), that exception + propagates untouched — we do not swallow a real error to substitute a + (possibly empty) aggregate. On a clean exit, :meth:`raise_if_any` fires, + so ``with DiagnosticCollector() as c: ...`` behaves like the imperative + form. Returns ``False`` so an in-body exception is never suppressed. + """ + if exc is None: + self.raise_if_any() + return False diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/guard_transforms.py b/packages/mechdsl-core/src/mechdsl/lawgen/guard_transforms.py new file mode 100644 index 0000000..6fbb6f0 --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/guard_transforms.py @@ -0,0 +1,234 @@ +"""Numerical-guard injection for the SymPy → Taichi lowerer (Task P2-3). + +MFront-mimic Cycle M0, Phase 2 (``dev/plans/mfront_cycleM0.md`` lines 83-86). +This is the **key correctness task** of the phase (plan risk R2): the guards +emitted here must reproduce the hand-authored guards in Cycle 0's +``swift_voce.py`` so the P4-2 numerical-equivalence gate (``rtol=1e-10``) holds. + +Mechanism — a SymPy-tree rewrite pass with stand-in marker nodes +---------------------------------------------------------------- +The context summary (§Allowed Deviations) prefers a rewrite pass over post-hoc +string surgery, and a rewrite pass is genuinely cleaner *here* because SymPy +models a reciprocal as ``Pow(base, negative-integer)`` and ``a / b`` as +``Mul(a, Pow(b, -1))`` — the denominator is a plain sub-tree that ``_print_Mul`` +formats itself. Rather than re-implement SymPy's ``_print_Mul`` (brittle, and +close to the R4 anti-pattern), we *wrap the denominator sub-tree* in a marker +node so ``_print_Mul`` prints the guard for free: ``a / _signed_floor(b)`` +renders as ``a/ti.select(b >= 0, ti.max(b, 1e-12), ti.min(b, -1e-12))``. + +Two marker nodes carry the guard, both trivial :class:`sympy.Function` +subclasses that :class:`~mechdsl.lawgen.sympy_to_taichi.TaichiGuardedPrinter` +knows how to render: + +* :class:`GuardFloor` — ``ti.max(arg, 1e-12)``. Positive-domain floor for + ``log``, ``sqrt`` and ``pow`` with a non-integer exponent (the base of a + fractional power must be positive, so flooring to ``+1e-12`` is correct). +* :class:`GuardSignedFloor` — ``ti.select(arg >= 0, ti.max(arg, 1e-12), + ti.min(arg, -1e-12))``. **Sign-preserving** near-zero guard for a division + denominator that is not a compile-time constant. A plain ``ti.max(ti.abs(b), + 1e-12)`` would return ``|b|`` and flip the sign of ``a/b`` for ``b < 0`` + (Gate-B Finding 1); the signed floor keeps ``b``'s sign and only floors its + *magnitude* to ``1e-12``. It is a no-op for ``|b| >= 1e-12`` (returns ``b``). + +The floor literal is fixed as the *string* ``"1e-12"`` (see +:data:`GUARD_FLOOR_LITERAL`) rather than a ``sympy.Float`` so the emitted text +is exactly ``1e-12`` — a ``Float`` would print ``1.0e-12`` and drift from the +golden. The printer renders the literal, so it never passes through SymPy +numeric formatting. + +The exact guard idioms (reproduced, not invented — the golden is the authority) +------------------------------------------------------------------------------- +From ``NumerixWeave/libs/ticonstit/.../generated/plasticity/swift_voce.py`` +(Cycle 0, hand-authored), ``get_R``/``get_dR``: + +* Swift ``(peeq + p0)**n`` and ``p0**n`` → **floor the base** with + ``ti.max(base, 1e-12)`` then ``ti.pow`` — *only* when the exponent is + non-integer. An integer power needs no floor (and P2-4 inlines small-int + powers to multiplication anyway). +* ``exp(-b*peeq)`` is **UNGUARDED**, matching the Voce idiom (physical domain + ``peeq >= 0`` ⇒ argument ``<= 0`` ⇒ ``exp in (0, 1]``, no overflow). Guarding + ``exp`` would *diverge* from the golden — this pass MUST NOT wrap it (the + ``exp`` node is simply not a rule here). +* ``log`` / ``sqrt`` → positive-domain floor ``ti.max(arg, 1e-12)``. + ``swift_voce`` has no ``log``/``sqrt``, so these are the generic mechanism + (covered by the generic tests, not the golden). +* Division by a possibly-zero *variable* denominator (a genuine reciprocal — a + negative-**integer** exponent) → sign-preserving guard. A denominator that is + a compile-time constant (a pure number) is left bare — matching + ``swift_voce``'s ``/edot0`` (division by a nonzero parameter is not + runtime-guarded there). + +Negative NON-integer exponents are fractional powers, not division +------------------------------------------------------------------ +``x**(-3/10)`` (a negative *fractional* constant) and ``x**(-alpha)`` (a +negative *symbolic* exponent) are still fractional powers whose base must be +positive — they are base-floored via ``ti.pow(ti.max(base, 1e-12), exp)``, NOT +routed to the division guard (Gate-B Finding 2). Only a negative-**integer** +exponent is a reciprocal (``x**-1 = 1/x``, ``x**-2 = 1/x**2``). +""" + +from __future__ import annotations + +import sympy as sp +from sympy.core.function import Function + +__all__ = [ + "GUARD_FLOOR", + "GUARD_FLOOR_LITERAL", + "GUARD_FLOOR_NEG_LITERAL", + "GuardFloor", + "GuardSignedFloor", + "inject_guards", +] + +# The domain-floor / near-zero epsilon, emitted verbatim as this string so the +# generated text is exactly ``1e-12`` (a ``sympy.Float`` would print +# ``1.0e-12`` and drift from Cycle 0's ``swift_voce.py``). ``GUARD_FLOOR`` keeps +# the numeric value available for callers/tests that reason about the value; +# ``GUARD_FLOOR_NEG_LITERAL`` is the negative-side floor for the sign-preserving +# denominator guard. +GUARD_FLOOR_LITERAL: str = "1e-12" +GUARD_FLOOR_NEG_LITERAL: str = "-1e-12" +GUARD_FLOOR: float = 1e-12 + + +class GuardFloor(Function): # type: ignore[misc] # SymPy Function is untyped + """Marker node rendered as ``ti.max(arg, 1e-12)`` — the positive-domain floor. + + A single-argument stand-in that the guarded printer renders; it never + reaches Taichi as a real function. Used for ``log``/``sqrt`` arguments and + for the base of a ``pow`` with a non-integer exponent — all cases where the + argument is required to be positive, so flooring to ``+1e-12`` is correct. + """ + + nargs = 1 + + +class GuardSignedFloor(Function): # type: ignore[misc] # SymPy Function is untyped + """Sign-preserving near-zero guard for a division denominator. + + Rendered as ``ti.select(arg >= 0, ti.max(arg, 1e-12), ti.min(arg, -1e-12))``: + it keeps ``arg``'s sign and floors only its *magnitude* to ``1e-12``. This is + a no-op for ``|arg| >= 1e-12`` (returns ``arg``); for ``|arg| < 1e-12`` it + returns ``+1e-12`` (``arg >= 0``) or ``-1e-12`` (``arg < 0``). + + Why not ``ti.max(ti.abs(arg), 1e-12)`` (Gate-B Finding 1): an ``abs`` floor + returns ``|arg|``, so ``a / arg`` would evaluate to ``a / |arg|`` and FLIP + SIGN for a runtime-negative ``arg``. Wrong result whenever the denominator + can be negative — silently emitting wrong code (R2). The signed floor is the + correct near-zero clamp for a denominator of unknown sign. + """ + + nargs = 1 + + +# Private aliases: the marker classes are an implementation detail of this pass, +# but the printer needs to dispatch on their names. +_GuardFloor = GuardFloor +_GuardSignedFloor = GuardSignedFloor + + +def inject_guards(expr: sp.Expr) -> sp.Expr: + """Return ``expr`` with numerical guards injected as marker nodes. + + A pure, bottom-up SymPy rewrite (no mutation, no string surgery, no + print-to-Python-source): each node is rebuilt from already-guarded children, + then the guard rules apply to the current node. Running it *before* CSE means the + common-subexpression pass sees a normal SymPy tree (the markers are ordinary + ``Function`` nodes) and factors it deterministically. + + Rules (see the module docstring for the golden anchoring): + + * ``Pow(base, 1/2)`` (``sqrt``) → positive-floor the base. + * ``Pow(base, non-negative integer)`` → **no** guard (P2-4 inlines). + * ``Pow(base, non-integer: fractional or symbolic, any sign)`` → positive + base-floor via ``ti.pow`` (a fractional power's base must be positive). + * ``Pow(base, negative integer)`` (``1/x``, ``1/x**2``) → sign-preserving + denominator guard, unless ``base`` is a pure number (a nonzero constant). + * ``log(x)`` → positive-floor the argument. + * ``exp(x)`` → **untouched** (the Voce idiom; + the #1 failure mode is over-guarding this). + + Already-injected markers are returned as-is so the pass is idempotent. + """ + # Atoms (symbols, numbers) and markers already in place: nothing to rewrite. + if expr.is_Atom or isinstance(expr, (_GuardFloor, _GuardSignedFloor)): + return expr + + guarded_args = [inject_guards(arg) for arg in expr.args] + + if expr.is_Pow: + return _guard_pow(guarded_args[0], guarded_args[1]) + + # ``log`` is the one whitelisted function with a domain guard; ``exp`` (and + # every other function) is rebuilt from guarded children but NOT wrapped. + if expr.func is sp.log: + return sp.log(_floor(guarded_args[0])) + + # Any other node (Add, Mul, exp, trig, Abs, Max/Min, sign, ...): rebuild + # from guarded children, unchanged. ``exp`` flows through here unguarded. + return expr.func(*guarded_args) + + +def _floor(arg: sp.Expr) -> sp.Expr: + """Wrap ``arg`` in a :class:`_GuardFloor`, idempotently. + + An ``arg`` that is *already* a guard marker is returned unchanged so a second + pass never nests ``_GuardFloor(_GuardFloor(x))`` (double-flooring is + harmless numerically but would emit a redundant ``ti.max`` and drift from the + golden). Keeps :func:`inject_guards` a proper idempotent rewrite. + """ + if isinstance(arg, (_GuardFloor, _GuardSignedFloor)): + return arg + return _GuardFloor(arg) + + +def _signed_floor(arg: sp.Expr) -> sp.Expr: + """Wrap a denominator ``arg`` in a :class:`_GuardSignedFloor`, idempotently.""" + if isinstance(arg, (_GuardFloor, _GuardSignedFloor)): + return arg + return _GuardSignedFloor(arg) + + +def _guard_pow(base: sp.Expr, exp: sp.Expr) -> sp.Expr: + """Apply the ``Pow`` guard rules to an already-child-guarded ``base``/``exp``. + + Kept ``evaluate=False`` throughout so the injected marker survives (an + evaluating rebuild could fold ``_GuardFloor(x)**2`` and lose the wrapper). + + Routing (order matters — integer-ness before sign): + + 1. ``exp == 1/2`` (``sqrt``) → positive base-floor. + 2. ``exp`` a non-integer (fractional or symbolic, ANY sign) → positive + base-floor via ``ti.pow``. A fractional power's base must be positive, so + even ``x**(-3/10)`` floors the base — it is NOT a reciprocal/division + (Gate-B Finding 2). + 3. ``exp`` a negative integer (``x**-1``, ``x**-2``) → genuine reciprocal: + sign-preserving denominator guard (unless ``base`` is a nonzero constant). + 4. ``exp`` a non-negative integer → no guard (exact; P2-4 inlines small ints). + """ + # 1. sqrt: SymPy's ``Pow(x, 1/2)``. Floor the radicand; the printer routes + # the half-exponent to ``ti.sqrt`` → ``ti.sqrt(ti.max(x, 1e-12))``. + if exp is sp.S.Half: + return sp.Pow(_floor(base), exp, evaluate=False) + + # 2. Non-integer exponent (fractional constant or symbolic), regardless of + # sign: a fractional power whose base must be positive → positive base-floor. + # ``exp.is_Integer`` is False for a symbolic exponent too, so this must come + # BEFORE the negative-integer reciprocal check. The printer emits + # ``ti.pow(ti.max(base, 1e-12), exp)``. + if not exp.is_Integer: + return sp.Pow(_floor(base), exp, evaluate=False) + + # 3. Negative *integer* exponent: a genuine reciprocal (``x**-1`` = ``1/x``, + # ``x**-2`` = ``1/x**2``). Sign-preserving denominator guard, unless the base + # is a pure number (a nonzero compile-time constant needs no runtime guard — + # matches ``swift_voce``'s bare ``/edot0``). + if exp.is_negative: + if base.is_number: + return sp.Pow(base, exp, evaluate=False) + return sp.Pow(_signed_floor(base), exp, evaluate=False) + + # 4. Non-negative integer power: no floor. Exact, and P2-4 inlines small ones + # to repeated multiplication. + return sp.Pow(base, exp, evaluate=False) diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/manifest.py b/packages/mechdsl-core/src/mechdsl/lawgen/manifest.py new file mode 100644 index 0000000..e95227c --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/manifest.py @@ -0,0 +1,460 @@ +"""Manifest emitter — writes ``_manifest.json`` matching Cycle 0's schema (Task P3-3). + +MFront-mimic Cycle M0, Phase 3 (``dev/plans/mfront_cycleM0.md`` lines 104-106). + +:func:`emit_manifest` produces one *laws entry* — and, via :func:`write_manifest`, +merges it into the ``{"_schema": {...}, "laws": [...]}`` file that +NumerixWeave's auto-register loop reads and that Task P4-2 relies on for +provenance (P4-2's own gate is run-to-run ``source_hash`` determinism, not a +whole-file byte-diff against Cycle 0). The authoritative schema is the real +Cycle 0 ``libs/ticonstit/src/ticonstit/generated/_manifest.json`` in the +NumerixWeave repo. Its ``laws[0]`` entry has **nine** fields, emitted in +**insertion order** (the real file is insertion-ordered, not alphabetical) — this +module reproduces those exact field names, order, and shapes: + +``name``, ``kind``, ``source``, ``source_hash``, ``generated_by``, +``target_contract``, ``exports``, ``parameters`` (an object +``{"required": [...], "optional": [...]}``), and ``tests``. + +Cross-repo discipline (R3) +-------------------------- +Nothing here imports NumerixWeave or ``ticonstit``. The MechDSL↔NumerixWeave +seam is committed artifacts only: the manifest we *emit* is compared against the +real file by reading that file's **bytes** (in the tests), never by importing it. + +``source_hash`` convention — hash the INPUT formula string (P4-2 reconciliation) +-------------------------------------------------------------------------------- +# NOTE (P4-2): Cycle 0's ``_manifest.json`` defines ``source_hash`` as the +# SHA-256 of the **generator INPUT** — the canonical R formula string +# (``"R = sigma0 + Q*(1-exp(-b*p)) + K*((p+p0)**n - p0**n)"`` → ``7b5af3a8…``), +# NOT the emitted-output-lines hash that P2-4's +# :func:`~mechdsl.lawgen.sympy_to_taichi.compute_source_hash` / +# :attr:`~mechdsl.lawgen.sympy_to_taichi.LoweredExpr.source_hash` produces. +# This module therefore hashes the input formula string via +# :func:`compute_input_formula_hash` and deliberately does **not** consume the +# P2-4 emitted-lines hash for the manifest. P2-4's hash remains valid provenance +# of the *output*; the manifest fingerprints the *input* so NumerixWeave can +# verify the law was generated from the formula it claims. P4-1 supplies the +# exact canonical formula string (from the law yaml's ``R`` formula) — the +# canonical-string convention this module locks is: the formula string is hashed +# **verbatim, UTF-8, no normalisation** (no whitespace collapsing, no operator +# reordering). Cycle 0's value is reproduced exactly under this convention (see +# ``test_manifest.py`` / ``test_P3-3.py``), so P4-1 must pass the formula string +# with the same spelling the yaml carries. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from mechdsl import __version__ as _MECHDSL_VERSION + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from mechdsl.lawgen.contracts import PlasticityCarrierSpec + +__all__ = [ + "GENERATED_BY", + "LAWS_ENTRY_FIELDS", + "compute_input_formula_hash", + "emit_manifest", + "formula_matches_spec", + "write_manifest", +] + +#: The nine fields of a Cycle 0 ``laws`` entry, in the order the real +#: ``_manifest.json`` lists them. Used to validate/emit a byte-stable entry and +#: as the schema-fidelity key set P4-2 byte-compares against. +LAWS_ENTRY_FIELDS: tuple[str, ...] = ( + "name", + "kind", + "source", + "source_hash", + "generated_by", + "target_contract", + "exports", + "parameters", + "tests", +) + +#: ``generated_by`` value for M0. Cycle 0 (hand-authored) used +#: ``"mfront_mimic Cycle 0 (hand-authored)"``; the MechDSL lawgen emitter stamps +#: ``"mechdsl-lawgen/"`` with the mechdsl-core package version, so the +#: manifest records *which* generator + version produced the law. +GENERATED_BY: str = f"mechdsl-lawgen/{_MECHDSL_VERSION}" + +#: Cycle 0 only emits the ``"plasticity"`` registry bucket. +_DEFAULT_KIND: str = "plasticity" + + +def compute_input_formula_hash(formula: str) -> str: + """Return the SHA-256 of the canonical generator-input formula string. + + This is the manifest ``source_hash`` convention (see the module note): the + formula string is hashed **verbatim** — UTF-8 encoded, with no whitespace + normalisation and no operator reordering. Under this convention Cycle 0's + ``R = sigma0 + Q*(1-exp(-b*p)) + K*((p+p0)**n - p0**n)`` reproduces the + published hash ``7b5af3a8…`` exactly. + + Parameters + ---------- + formula: + The canonical input formula string (P4-1 supplies it from the law yaml's + ``R`` formula). Must be a non-empty string. + + Returns + ------- + str + 64 lowercase hex characters. + """ + if not isinstance(formula, str) or not formula.strip(): + raise ValueError( + "compute_input_formula_hash requires a non-empty formula string " + f"(the canonical generator input), got {formula!r}." + ) + return hashlib.sha256(formula.encode("utf-8")).hexdigest() + + +def formula_matches_spec(input_formula: str, spec: PlasticityCarrierSpec) -> bool: + """Return ``True`` iff ``input_formula``'s RHS is symbolically equal to ``spec.R``. + + The manifest ``source_hash`` fingerprints the *verbatim* ``input_formula`` + string (so it can reproduce Cycle 0's published hash), while the entry ``name`` + / ``parameters`` come from the ``spec``. Those two inputs are independent, so + nothing structurally guarantees the hashed formula is the same law the spec + describes — a stale or mistyped ``input_formula`` would fingerprint the *wrong* + law without complaint. This helper closes that gap for P4-1: parse the formula + (stripping an optional ``R =`` / ``R:=`` left-hand side), sympify the RHS, and + check it is symbolically equal to ``spec.R``. + + Naming caveat (Cycle 0 transition): Cycle 0's published formula string spells + the saturation term ``Q`` while its material card names the parameter + ``Q_inf`` — so for the *transitional* SwiftVoce law the formula variable names + deliberately differ from ``spec.parameters`` and this check will return + ``False``. That is exactly why :func:`emit_manifest`'s ``check_matches_spec`` + is **opt-in** (default off): turn it on once P4-1 authors a law whose formula + spelling matches the spec's own symbols, so a drifted formula fails loud. + + Returns + ------- + bool + ``True`` when ``sympify(RHS) - spec.R`` simplifies to zero. + + Raises + ------ + ValueError + If ``input_formula`` (its RHS) cannot be parsed as a SymPy expression — an + unparseable formula is itself a defect worth surfacing when a caller asks + for the check. + """ + import re # identifier tokenising for the parse — NOT a codegen path (R4 is about the lowerer) + + import sympy as sp # local import: only the opt-in consistency check needs SymPy + + rhs = input_formula.split(":=", 1)[-1] if ":=" in input_formula else input_formula + rhs = rhs.split("=", 1)[-1] if "=" in rhs else rhs + + # Auto-symbolise every bare identifier in the formula so parsing is independent of + # the spec's names and never resolves a variable to a SymPy global. Without this a + # parameter spelled ``Q`` binds to ``sympy.Q`` (the assumptions object) and the + # parse raises. Names immediately followed by ``(`` are function calls (``exp``, + # ``log``, …) — left out of the symbol map so they resolve to the real SymPy + # functions. + all_names = set(re.findall(r"[A-Za-z_]\w*", rhs)) + func_names = set(re.findall(r"([A-Za-z_]\w*)\s*\(", rhs)) + local_dict = {name: sp.Symbol(name) for name in all_names - func_names} + try: + parsed = sp.sympify(rhs, locals=local_dict) + except (sp.SympifyError, SyntaxError, TypeError, ValueError) as exc: + raise ValueError( + f"formula_matches_spec could not parse input_formula RHS {rhs!r} as a " + f"SymPy expression: {exc}." + ) from exc + difference = sp.simplify(parsed - spec.R) + return bool(difference == 0) + + +def _split_parameters( + parameters: Sequence[str], + *, + required: Sequence[str] | None, + optional: Sequence[str] | None, +) -> dict[str, list[str]]: + """Resolve the ``{"required": [...], "optional": [...]}`` parameters object. + + Two modes, matching the Cycle 0 shape (``parameters`` is an *object*, not a + flat list): + + * **Explicit** — when ``required`` and/or ``optional`` are given, they are + used verbatim (order preserved). This is the path P4-1 takes: the law yaml + states which parameters are required vs optional. Every name in + ``required``/``optional`` must appear in the spec's ``parameters`` (no + inventing parameters the law does not declare), the two lists must be + disjoint, and together they must be a **complete partition** — every + declared parameter is either required or optional. A parameter left out of + both is rejected (not silently dropped), so a YAML typo that forgets to + classify a load-bearing parameter fails loud instead of under-declaring the + law's surface in the manifest. + * **Convention** — when neither is given, the split defaults to *all* the + spec's parameters being ``required`` and ``optional`` empty. Rationale: a + carrier lists exactly the parameters its expressions reference, so absent + an explicit yaml split every listed parameter is load-bearing (required). + This keeps a spec-only call (tests, quick emission) valid while letting + P4-1 override with the real required/optional partition. + """ + all_params = list(parameters) + if required is None and optional is None: + return {"required": all_params, "optional": []} + + req = list(required or []) + opt = list(optional or []) + + overlap = sorted(set(req) & set(opt)) + if overlap: + raise ValueError( + f"manifest parameters: required and optional overlap on {overlap}; " + "a parameter is one or the other, not both." + ) + known = set(all_params) + unknown = [p for p in (*req, *opt) if p not in known] + if unknown: + raise ValueError( + f"manifest parameters: {unknown} not declared in the spec's parameters " + f"{all_params}; required/optional may only partition declared parameters." + ) + covered = set(req) | set(opt) + uncovered = [p for p in all_params if p not in covered] + if uncovered: + raise ValueError( + f"manifest parameters: {uncovered} declared in the spec's parameters " + f"{all_params} but classified as neither required nor optional; an explicit " + "split must be a complete partition so a load-bearing parameter is never " + "silently dropped from the manifest." + ) + return {"required": req, "optional": opt} + + +def emit_manifest( + spec: PlasticityCarrierSpec, + *, + input_formula: str, + target_contract: str, + exports: str, + source: str, + tests: Sequence[str], + kind: str = _DEFAULT_KIND, + required: Sequence[str] | None = None, + optional: Sequence[str] | None = None, + check_matches_spec: bool = False, +) -> dict[str, object]: + """Build one Cycle 0-shaped ``laws`` entry (the nine-field object). + + Parameters + ---------- + spec: + The carrier law. ``spec.name`` becomes the entry ``name``; ``spec.parameters`` + is partitioned into the ``parameters`` object (see :func:`_split_parameters`). + input_formula: + The canonical generator-input formula string. Its SHA-256 (verbatim, UTF-8) + is the entry ``source_hash`` — the INPUT-formula hash Cycle 0 uses, NOT the + P2-4 emitted-lines hash (see the module note). ``input_formula`` and ``spec`` + are independent inputs; pass ``check_matches_spec=True`` to assert the + hashed formula actually is ``spec.R``. + check_matches_spec: + When ``True``, verify via :func:`formula_matches_spec` that ``input_formula`` + is symbolically equal to ``spec.R`` and raise :class:`ValueError` if not — so + a stale/mistyped formula fingerprints the wrong law loudly instead of + silently. Default ``False`` because, during the Cycle 0 transition, the + published formula string deliberately spells parameters differently from the + spec's material card (``Q`` vs ``Q_inf``); P4-1 turns it on once a law's + formula spelling matches its own symbols (see :func:`formula_matches_spec`). + target_contract: + The **runtime** contract the law implements (e.g. ``"VoceHardeningModel"``). + Supplied explicitly by P4-1 from the law yaml — this is deliberately NOT + ``TiconstitTarget.contract_id`` (the emission-contract id), which is a + different thing. + exports: + The Python class name the generated submodule exports (e.g. ``"SwiftVoce"``). + source: + The generated module filename within ``ticonstit.generated.plasticity`` + (e.g. ``"swift_voce.py"``); its stem is the imported submodule. + tests: + Test file paths that pin the generated law (golden / FD-derivative / + guard-audit) — the paths P3-2's ``emit_tests`` writes. + kind: + Registry bucket. Defaults to ``"plasticity"`` (the only Cycle 0 bucket). + required, optional: + Optional explicit required/optional parameter partition. When omitted, all + of ``spec.parameters`` is treated as required (see :func:`_split_parameters`). + + Returns + ------- + dict[str, object] + A dict with exactly the nine :data:`LAWS_ENTRY_FIELDS`, ready to be placed + in a ``{"laws": [...]}`` manifest. Fails loud (``ValueError``) on any + missing/empty required input so a half-populated entry is never emitted. + """ + _require_nonempty_str("target_contract", target_contract) + _require_nonempty_str("exports", exports) + _require_nonempty_str("source", source) + _require_nonempty_str("kind", kind) + tests_list = list(tests) + if not tests_list: + raise ValueError( + "emit_manifest requires at least one test path (a generated law must be " + "pinned by at least one test)." + ) + for test_path in tests_list: + _require_nonempty_str("tests entry", test_path) + + if check_matches_spec and not formula_matches_spec(input_formula, spec): + raise ValueError( + f"emit_manifest: input_formula {input_formula!r} is not symbolically equal " + f"to spec.R ({spec.R}) for law {spec.name!r}; the manifest source_hash would " + "fingerprint a different law than the spec describes. Fix the formula string " + "or the spec, or drop check_matches_spec if the spelling divergence is " + "intentional (see formula_matches_spec)." + ) + + source_hash = compute_input_formula_hash(input_formula) + parameters = _split_parameters(spec.parameters, required=required, optional=optional) + + entry: dict[str, object] = { + "name": spec.name, + "kind": kind, + "source": source, + "source_hash": source_hash, + "generated_by": GENERATED_BY, + "target_contract": target_contract, + "exports": exports, + "parameters": parameters, + "tests": tests_list, + } + # Invariant: the entry key set is exactly the Cycle 0 laws-entry field set. + # Guards against a future field drift that P4-2's byte-compare would catch. + assert set(entry) == set(LAWS_ENTRY_FIELDS), ( + f"emit_manifest entry keys {sorted(entry)} drifted from the Cycle 0 " + f"laws-entry schema {sorted(LAWS_ENTRY_FIELDS)}." + ) + return entry + + +def write_manifest( + entries: Sequence[Mapping[str, object]], + out_path: str | Path, + *, + schema_doc: Mapping[str, object] | None = None, +) -> Path: + """Write a byte-stable ``{"_schema": ..., "laws": [...]}`` manifest file. + + The file is serialised with ``indent=2``, a trailing newline, and keys in + **insertion order** (no ``sort_keys``). Output is **byte-stable** across + re-runs for a given set of entries — every entry is built via the fixed + :data:`LAWS_ENTRY_FIELDS` literal — and that insertion order reproduces + Cycle 0's real ``_manifest.json`` structure (``name, kind, source, …`` and + ``{required, optional}``), which is insertion-ordered, *not* alphabetical. + ``sort_keys=True`` would gratuitously reorder the keys away from Cycle 0, so + a structural diff against the hand-authored artifact would differ only in the + field *values* P4-1 supplies, never in key order. + + Parameters + ---------- + entries: + The ``laws`` entries (each a :func:`emit_manifest` result). + out_path: + Where to write ``_manifest.json``. Parent directories are created. + schema_doc: + Optional ``_schema`` documentation block. When omitted, a self-documenting + ``_schema`` is written whose ``laws_entry_fields`` is an **object** (field + name → description), matching Cycle 0's real ``_schema`` shape (not a flat + list), so the emitted file mirrors Cycle 0's ``{"_schema": ..., "laws": + [...]}`` structure. Pass a custom mapping for byte-exact Cycle 0 text. + + Returns + ------- + pathlib.Path + The path written. + """ + manifest: dict[str, object] = { + "_schema": dict(schema_doc) if schema_doc is not None else _default_schema_doc(), + "laws": [dict(entry) for entry in entries], + } + path = Path(out_path) + path.parent.mkdir(parents=True, exist_ok=True) + # No sort_keys: keys serialise in insertion order, reproducing Cycle 0's + # insertion-ordered laws entry (name, kind, source, …) and {required, optional} + # exactly — Cycle 0's real _manifest.json is NOT alphabetical. Output stays + # byte-stable across re-runs because emit_manifest builds every entry via the + # fixed LAWS_ENTRY_FIELDS literal. ensure_ascii=False keeps any unicode + # readable; the trailing newline is POSIX-clean and diff-friendly. + text = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" + path.write_text(text, encoding="utf-8") + return path + + +#: Per-field descriptions for the ``_schema.laws_entry_fields`` block, keyed by +#: the nine :data:`LAWS_ENTRY_FIELDS` in order. Cycle 0's real ``_manifest.json`` +#: documents the schema as an **object** (field name → description), not a flat +#: list — reproducing that object shape keeps the emitted ``_schema`` structurally +#: identical to the hand-authored artifact. Descriptions are generator-neutral +#: (they describe the field, and note the MechDSL-lawgen conventions where they +#: differ from Cycle 0's hand-authored specifics). +_LAWS_ENTRY_FIELD_DESCRIPTIONS: dict[str, str] = { + "name": ( + "Law name, unique within the plasticity bucket (e.g. 'SwiftVoce'), and the " + "class exported by the submodule." + ), + "kind": "Registry bucket for this law. Cycle 0 only emits 'plasticity'.", + "source": ( + "Generated module filename within ticonstit.generated.plasticity (e.g. " + "'swift_voce.py'). Its stem is the submodule imported by the auto-register " + "loop, decoupling the file name from the class name." + ), + "source_hash": ( + "SHA-256 fingerprint of the generator input (the verbatim R formula string), " + "passed to register_plasticity(source_hash=...)." + ), + "generated_by": ("Tool + version that produced the file (e.g. 'mechdsl-lawgen/')."), + "target_contract": ( + "Runtime contract identifier the law implements (e.g. 'VoceHardeningModel'), " + "passed to register_plasticity(contract=...)." + ), + "exports": "Name of the Python class exported by the submodule that implements the law.", + "parameters": ( + "Object describing required/optional material-parameter names, passed to " + "register_plasticity(required=..., optional=...)." + ), + "tests": ( + "List of test file paths that pin this generated law (golden / FD-derivative " + "/ guard-audit)." + ), +} + + +def _default_schema_doc() -> dict[str, object]: + """A self-documenting ``_schema`` block describing the nine entry fields. + + Mirrors Cycle 0's real ``_manifest.json`` shape: a ``_comment`` string plus a + ``laws_entry_fields`` **object** mapping each field name to a description (not a + flat list of names), so the emitted ``_schema`` is structurally identical to the + hand-authored artifact. Callers wanting byte-exact Cycle 0 text can still pass a + custom ``schema_doc`` to :func:`write_manifest`. + """ + return { + "_comment": ( + "JSON has no comments; this key documents the per-law entry schema. It is " + "ignored by the auto-register loop, which reads only the 'laws' list. Each " + "entry in 'laws' is an object with the fields described below." + ), + "laws_entry_fields": dict(_LAWS_ENTRY_FIELD_DESCRIPTIONS), + } + + +def _require_nonempty_str(label: str, value: object) -> None: + """Raise ``ValueError`` unless ``value`` is a non-empty (non-blank) string.""" + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"emit_manifest: {label} must be a non-empty string, got {value!r}.") diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/sympy_to_taichi.py b/packages/mechdsl-core/src/mechdsl/lawgen/sympy_to_taichi.py new file mode 100644 index 0000000..2efd0dd --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/sympy_to_taichi.py @@ -0,0 +1,737 @@ +"""Deterministic SymPy → Taichi scalar-expression lowerer (Task P2-1). + +MFront-mimic Cycle M0, Phase 2 (``dev/plans/mfront_cycleM0.md`` lines 76-78). + +This module is the foundation the rest of Phase 2 builds on: a *dedicated*, +idiomatic SymPy printer that turns a scalar ``sympy.Expr`` into a Taichi source +string, plus a public :func:`lower_expression` entry point that applies +deterministic common-subexpression elimination (CSE) before printing. + +Why a bespoke printer (the R4 correction) +----------------------------------------- +The P1-3 reuse audit (``lawgen/REUSE.md``, Gate-B-verified) established that +``codegen/taichi_printer.py`` has **no** reusable scalar SymPy→Taichi printer — +its surface is FEM ``ArtifactBundle`` emission with hard-coded literal Taichi. +The only existing SymPy→Taichi code lives in ``codegen/energy_emitter.py`` and +converts the expression to Python source (SymPy's ``py``-``code`` printer) then +rewrites ``math.*`` to ``ti.*`` with a regex substitution over that string. That +is the **R4 anti-pattern** the plan forbids: brittle string surgery, no CSE, no +whitelist enforcement at the printer boundary. + +So P2-1 adds a proper printer instead. It subclasses SymPy's +:class:`~sympy.printing.str.StrPrinter` and overrides the relevant ``_print_*`` +methods, so function/operator lowering happens *inside* SymPy's own dispatch — +no Python-source printing, no regex. Unmapped nodes fail loud (R2): a clear +``NotImplementedError`` naming the node and the phase that adds it, never silent +wrong code. + +Scope of this task +------------------ +Printer + deterministic CSE **only**. Deliberately out of scope (separate +tasks — clean seams are noted inline where each attaches): + +* **P2-2** — scalar-expression budget counting over the six ``TiconstitTarget`` + knobs. +* **P2-3** — numerical-guard injection (``ti.max(x, 1e-12)`` for log/sqrt, + ``ti.select`` for pow with symbolic exponent, guarded division). This printer + emits ``ti.pow``/``ti.log``/``ti.sqrt`` *unguarded*; P2-3 attaches a rewrite + pass upstream of :func:`lower_expression` (or wraps the mapped names). +* **P2-4** — ``Piecewise`` → nested ``ti.select`` lowering, small-integer ``Pow`` + inlining, and a ``source_hash`` provenance field on :class:`LoweredExpr` + (see the extension note on the dataclass). +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import sympy as sp +from sympy.printing.precedence import PRECEDENCE, precedence +from sympy.printing.str import StrPrinter + +from mechdsl.lawgen.budgets import _budget_diagnostic, count_piecewise_branches +from mechdsl.lawgen.contracts import TiconstitTarget +from mechdsl.lawgen.diagnostics import DiagnosticCollector, LawgenDiagnostic +from mechdsl.lawgen.guard_transforms import ( + GUARD_FLOOR_LITERAL, + GUARD_FLOOR_NEG_LITERAL, + GuardFloor, + GuardSignedFloor, + inject_guards, +) + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + +# Small-integer ``Pow`` inlining threshold (P2-4). +# +# ``Pow(x, n)`` with ``n`` a **non-negative** integer and ``n <= +# SMALL_INT_POW_LIMIT`` is inlined to repeated multiplication (``x**2`` → +# ``x*x``); a larger magnitude, a negative exponent, or a symbolic/fractional +# exponent keeps ``ti.pow`` / ``**`` (guarded per P2-3). ``4`` is chosen so the +# worst inlined case unrolls to at most four factors (``x*x*x*x``) — comfortably +# inside the JIT line budget (a ``ti.pow`` call is one line, so inlining trades +# one call for up to three extra ``*`` ops, never a whole line) while covering +# the common material-model powers (square/cube). It is a deliberately +# conservative bound: raising it risks the "large unrolled multiplication" hazard +# flagged in the P2-4 risks. +SMALL_INT_POW_LIMIT: int = 4 + + +def _inlines_to_product(item: sp.Basic) -> bool: + """True if ``item`` is a ``Pow`` this printer inlines to a multi-factor product. + + A ``Pow(base, n)`` with ``n`` an integer and ``2 <= n <= SMALL_INT_POW_LIMIT`` + prints as ``base*base*...`` — a value at ``Mul`` precedence, not ``Pow`` + precedence. :meth:`TaichiExprPrinter.parenthesize` uses this to wrap such a + node when it sits at ``Mul`` level (notably as a division denominator), so + ``a/x**2`` renders as ``a/(x*x)`` and never the mis-grouped ``a/x*x`` (which + would evaluate to ``a`` — the Gate-B silently-wrong-math bug). ``n`` of 0/1 + fold to ``"1"``/``base`` (already atomic), so only ``n >= 2`` needs wrapping. + """ + return ( + isinstance(item, sp.Pow) + and isinstance(item.exp, sp.Integer) + and 2 <= int(item.exp) <= SMALL_INT_POW_LIMIT + ) + + +__all__ = [ + "MATH_TO_TAICHI", + "SMALL_INT_POW_LIMIT", + "LoweredExpr", + "TaichiExprPrinter", + "TaichiGuardedPrinter", + "compute_source_hash", + "lower_expression", +] + +# --------------------------------------------------------------------------- +# The canonical SymPy-function → Taichi-name map. +# +# This is the single source of truth for which scalar functions this printer +# can lower and what they lower to. It is kept in deliberate alignment with: +# * ``codegen/energy_emitter._MATH_TO_TAICHI`` — the existing (regex-based) +# math→ti name table; we reuse the *mapping idea*, not its +# print-to-source-plus-regex mechanism. +# * ``lawgen/cli._ALLOWED_FUNCTIONS`` — P1-2's front-end parser allow-list +# (``exp, log, sqrt, sin, cos, tan, sinh, cosh, tanh, Abs, Max, Min, +# sign``). Every function the CLI accepts into an R/H/Q expression MUST be +# lowerable here, or a legal law would parse but fail to emit. +# +# Keyed by the SymPy function class name (``type(node).__name__`` / +# ``node.func.__name__``); valued by the Taichi call name. ``sqrt`` is handled +# specially in ``_print_Pow`` (SymPy models it as ``Pow(x, 1/2)``, not a +# ``Function``), but is listed here so the allowed-set is one table. +# +# NOTE (P2-4): a later task can converge this map with ``cli._ALLOWED_FUNCTIONS`` +# into one shared constant so the parser and the printer can never disagree. +# Today they are two aligned literals; the alignment is asserted in the tests. +MATH_TO_TAICHI: dict[str, str] = { + "exp": "ti.exp", + "log": "ti.log", + "sqrt": "ti.sqrt", + "sin": "ti.sin", + "cos": "ti.cos", + "tan": "ti.tan", + "sinh": "ti.sinh", + "cosh": "ti.cosh", + "tanh": "ti.tanh", + "Abs": "ti.abs", + "Max": "ti.max", + "Min": "ti.min", + "sign": "ti.sign", + # ``pow`` is emitted via ``_print_Pow`` for a symbolic/general exponent; + # registered here so the name is part of the one allowed-function table. + "pow": "ti.pow", +} + +# SymPy ``Function`` *subclasses* that this lowerer supports via a dedicated +# ``_print_*`` method rather than a :data:`MATH_TO_TAICHI` name mapping. These +# must be excluded from the unsupported-function sweep +# (:func:`_unsupported_function_names`), which walks ``expr.atoms(sp.Function)``: +# ``sp.Piecewise`` is a ``sp.Function`` subclass but is fully supported +# (``_print_Piecewise`` → nested ``ti.select``), so without this exclusion it +# would be mis-reported as an unmapped node. (``sp.Max``/``sp.Min`` are +# ``MinMaxBase``, not ``sp.Function``, so they never appear in that sweep and do +# not need listing here.) +_SUPPORTED_FUNCTION_NODES: frozenset[str] = frozenset({"Piecewise"}) + + +class TaichiExprPrinter(StrPrinter): + """A :class:`~sympy.printing.str.StrPrinter` that emits Taichi source. + + Deterministic and regex-free: lowering happens through SymPy's own + ``_print_*`` dispatch, so the same expression always prints to the same + string (given a fixed SymPy version) and no print-to-Python-source plus + string-substitution step is involved. + + Behaviour + --------- + * Symbols print as their name; ``Integer``/``Float``/``Rational`` print as + numeric literals; ``Add``/``Mul`` and integer/general ``Pow`` print via + the base :class:`StrPrinter` (``x**n`` stays ``x**n`` for P2-1). + * Whitelisted functions (:data:`MATH_TO_TAICHI`) print as their ``ti.*`` + call, e.g. ``exp(-b*p)`` → ``ti.exp(-b*p)``. + * ``sqrt(x)`` — SymPy's ``Pow(x, 1/2)`` — prints as ``ti.sqrt(x)`` rather + than the base printer's literal ``sqrt(x)``. + * **Fail loud (R2):** any function/node this printer does not map raises + :class:`NotImplementedError` naming the node and the phase that adds it. + Nothing is emitted silently. + """ + + printmethod = "_taichi" + + def parenthesize(self, item: sp.Basic, level: int, strict: bool = False) -> str: + """Parenthesise ``item`` at ``level``, treating inlined powers as products. + + Identical to :meth:`StrPrinter.parenthesize` except that a small + positive-integer ``Pow`` — which :meth:`_print_Pow` inlines to a + multi-factor product (``x**2`` → ``x*x``) — is judged at ``Mul`` + precedence instead of ``Pow`` precedence for the wrap decision. + + Why (Gate-B critical): SymPy's :meth:`StrPrinter._print_Mul` places a + reciprocal power in the denominator by calling ``parenthesize`` on the + *positive* ``Pow`` (e.g. ``Pow(x, 2)`` for ``a/x**2``). The stock method + wraps only when ``precedence(item) <= level``; a ``Pow`` node reports + precedence 60 > ``Mul``'s 50, so it is left unwrapped — but our inlined + print is the product ``x*x`` (precedence 50), yielding the mis-grouped + ``a/x*x`` which evaluates to ``a`` (silently-wrong math). Reporting the + inlined power at ``Mul`` precedence makes the denominator wrap correctly + (``a/(x*x)``) while a standalone ``x**2`` still prints as the bare + ``x*x``. This also covers the guarded reciprocal + (``a/x**2`` with guards → ``a/(*)``). + """ + eff = PRECEDENCE["Mul"] if _inlines_to_product(item) else precedence(item) + if (eff < level) or ((not strict) and eff <= level): + return f"({self._print(item)})" + return str(self._print(item)) + + def _print_Function(self, expr: sp.Function) -> str: + """Print a whitelisted SymPy function as its ``ti.*`` call. + + Any function not in :data:`MATH_TO_TAICHI` (an unregistered SymPy + function such as ``erf`` or ``gamma``) fails loud rather than emitting + a call Taichi cannot compile. + """ + name = expr.func.__name__ + taichi = MATH_TO_TAICHI.get(name) + if taichi is None: + raise NotImplementedError( + f"TaichiExprPrinter cannot lower SymPy function {name!r}: it is " + f"not in the Taichi allow-list {sorted(MATH_TO_TAICHI)}. Register " + "it in MATH_TO_TAICHI (lawgen/sympy_to_taichi.py) if Taichi " + "supports it." + ) + args = ", ".join(self._print(arg) for arg in expr.args) + return f"{taichi}({args})" + + def _print_Max(self, expr: sp.Max) -> str: + """Print ``Max(a, b, ...)`` as a ``ti.max`` call. + + SymPy models ``Max``/``Min`` as ``MinMaxBase`` (a lattice op), *not* a + ``Function``, so ``_print_Function`` never sees them and the base + printer would emit a bare ``Max(...)``. Route them through the allowed + table explicitly. Taichi's ``ti.max``/``ti.min`` are variadic, so an + n-ary ``Max`` maps one-to-one. + """ + args = ", ".join(self._print(arg) for arg in expr.args) + return f"{MATH_TO_TAICHI['Max']}({args})" + + def _print_Min(self, expr: sp.Min) -> str: + """Print ``Min(a, b, ...)`` as a ``ti.min`` call (see ``_print_Max``).""" + args = ", ".join(self._print(arg) for arg in expr.args) + return f"{MATH_TO_TAICHI['Min']}({args})" + + def _print_Pow(self, expr: sp.Pow, rational: bool = False) -> str: + """Print a ``Pow`` node: ``sqrt`` → ``ti.sqrt``, small-int → multiplication. + + SymPy canonicalises ``sqrt(x)`` as ``Pow(x, S.Half)``; the base + :class:`StrPrinter` would emit literal ``sqrt(x)`` (not a ``ti.*`` + call). We intercept the ``S.Half`` exponent and emit ``ti.sqrt(base)`` + so the allowed-function table stays the single source of truth. + + Small-integer inlining (P2-4) + ----------------------------- + A ``Pow(base, n)`` with ``n`` a **non-negative** integer and ``n <= + SMALL_INT_POW_LIMIT`` is inlined to repeated multiplication instead of a + ``ti.pow`` call: ``x**2`` → ``x*x``, ``x**3`` → ``x*x*x``. Trivial powers + fold to their identities: ``x**1`` → ``x``, ``x**0`` → ``1``. + + **Negative** integer exponents are deliberately NOT inlined here (Gate-B + critical fix). A ``Pow(base, -k)`` is a reciprocal; SymPy's + :meth:`StrPrinter._print_Mul` owns its placement in the denominator — for + ``a/x**2`` (= ``Mul(a, Pow(x, -2))``) it moves the reciprocal into the + denominator and prints the *positive* counterpart ``Pow(x, 2)`` through + this method. If this method instead returned a self-contained ``1/(x*x)`` + for the negative node, it would collide with that split and emit + ``a/x*x`` — which under left-to-right ``/``/``*`` evaluates to ``a`` + (silently-wrong math, e.g. ``mu/J**2`` → ``mu/J*J``). So the positive + denominator power that ``_print_Mul`` hands back *is* inlined to ``x*x``, + and the collapse is prevented by :meth:`parenthesize` (overridden above), + which reports an inlined power at ``Mul`` precedence so ``_print_Mul`` + wraps the denominator → ``a/(x*x)``. This holds on the guarded path too + (``a/x**2`` → ``a/(*)``). This runs on the + already-guarded tree, so ``base`` carries any P2-3 guard injected. + """ + # Mirror SymPy's own idiom (``-expr.exp is S.Half``): negate then + # identity-check, so an exact ``Rational(-1, 2)`` matches while a + # ``-0.5`` float does not accidentally route to sqrt. + if expr.exp is sp.S.Half: + return f"{MATH_TO_TAICHI['sqrt']}({self._print(expr.base)})" + if -expr.exp is sp.S.Half: + return f"1/{MATH_TO_TAICHI['sqrt']}({self._print(expr.base)})" + if isinstance(expr.exp, sp.Integer): + inlined = self._inline_small_int_pow(expr.base, int(expr.exp)) + if inlined is not None: + return inlined + # ``str(...)``: SymPy is untyped, so the base ``_print_Pow`` is inferred + # as ``Any``; coerce to satisfy the declared ``-> str`` (it already + # returns a str at runtime). + return str(super()._print_Pow(expr, rational=True)) + + def _inline_small_int_pow(self, base: sp.Expr, n: int) -> str | None: + """Inline ``base**n`` to multiplication for a small **non-negative** ``n``. + + Returns the inlined Taichi source, or ``None`` to signal "not inlinable — + let the caller fall back to the base printer" when ``n`` is negative or + exceeds :data:`SMALL_INT_POW_LIMIT`. + + Forms (``b`` is the printed, already-guarded base): + + * ``n == 0`` → ``"1"`` (``x**0``). + * ``n == 1`` → ``b`` (``x**1``). + * ``n >= 2`` → ``b*b*...`` (``n`` factors), e.g. ``x**2`` → ``x*x``. + + Negative ``n`` returns ``None`` on purpose: a ``Pow`` with a negative + exponent is a reciprocal, and SymPy's :meth:`StrPrinter._print_Mul` must + own its placement in the denominator (with correct parenthesisation) — + inlining it here to a standalone ``1/(...)`` collides with that split and + produces mathematically wrong, mis-parenthesised division (Gate-B + critical). The *positive* denominator power that ``_print_Mul`` derives is + what gets inlined instead. + + The base is parenthesised at ``Mul`` precedence, so a compound base + (``(a + b)**2`` → ``(a + b)*(a + b)``) is grouped correctly and a bare + symbol (``x**2`` → ``x*x``) is not over-parenthesised. + """ + if n < 0 or n > SMALL_INT_POW_LIMIT: + return None + if n == 0: + return "1" + printed = self.parenthesize(base, PRECEDENCE["Mul"]) + return "*".join([printed] * n) + + def _print_Piecewise(self, expr: sp.Piecewise) -> str: + """Lower a ``Piecewise`` to a right-nested ``ti.select`` chain (P2-4). + + ``Piecewise((e1, c1), (e2, c2), ..., (en, True))`` → + ``ti.select(c1, e1, ti.select(c2, e2, ... en))``. Each branch value and + each condition is lowered recursively through this printer, so guards + injected inside a branch expression (P2-3) render normally and a nested + ``Piecewise`` becomes a nested ``ti.select``. Relational conditions + (``x > 0``) print via the base :class:`StrPrinter` as Taichi-valid scalar + comparisons. + + The final branch must be the exhaustive default (``cond is True``); its + value becomes the innermost ``ti.select`` else-argument. A non-exhaustive + ``Piecewise`` (no ``True`` tail) fails loud (R2) — Taichi ``ti.select`` + has no "undefined" result, so a missing default would silently emit a + wrong fallthrough. The branch-count *budget* gate is enforced upstream in + :func:`lower_expression` (pre-emission), not here. + """ + branches = expr.args + if branches[-1].cond is not sp.true: + raise NotImplementedError( + "TaichiExprPrinter cannot lower a non-exhaustive Piecewise " + f"{expr!r}: the final branch must be the default ``(value, True)`` " + "so the nested ti.select has a defined else-value (no silent " + "fallthrough — R2)." + ) + # Build the chain from the innermost (default value) outward, so the + # right-nesting matches the branch order left-to-right. ``str(...)``: + # ``self._print`` is ``Any`` (SymPy is untyped), coerce to the declared + # ``-> str`` (it already returns a str at runtime). + result = str(self._print(branches[-1].expr)) + for pair in reversed(branches[:-1]): + cond = self._print(pair.cond) + value = self._print(pair.expr) + result = f"ti.select({cond}, {value}, {result})" + return result + + +class TaichiGuardedPrinter(TaichiExprPrinter): + """A :class:`TaichiExprPrinter` that renders the P2-3 guard marker nodes. + + Pairs with :func:`mechdsl.lawgen.guard_transforms.inject_guards`: that pass + wraps guardable sub-trees in :class:`~mechdsl.lawgen.guard_transforms.\ +GuardFloor` / :class:`~mechdsl.lawgen.guard_transforms.GuardSignedFloor` + markers, and this printer turns those markers into concrete Taichi. The two + are always used together (the base :class:`TaichiExprPrinter` is the raw, + unguarded path). + + Guard rendering + --------------- + * ``GuardFloor(arg)`` → ``ti.max(, 1e-12)`` (positive-domain floor). + * ``GuardSignedFloor(arg)`` → ``ti.select( >= 0, ti.max(, 1e-12), + ti.min(, -1e-12))`` (sign-preserving denominator guard). + * ``Pow`` whose base is a floored marker and whose exponent is not the + ``sqrt`` half → ``ti.pow(ti.max(base, 1e-12), exp)`` (matching Cycle 0's + ``swift_voce.py`` ``get_R``: a floored base is emitted through ``ti.pow``, + not the base printer's ``**``). This holds for a *negative* fractional + exponent too — the rewrite pass only floors a base when the power is + genuinely fractional, never for a negative-integer reciprocal. + + The ``1e-12`` epsilon is emitted verbatim from + :data:`~mechdsl.lawgen.guard_transforms.GUARD_FLOOR_LITERAL`, so it never + passes through SymPy ``Float`` formatting (which would print ``1.0e-12``). + """ + + def _print_GuardFloor(self, expr: GuardFloor) -> str: + """Render the positive-domain floor marker as ``ti.max(arg, 1e-12)``.""" + return f"{MATH_TO_TAICHI['Max']}({self._print(expr.args[0])}, {GUARD_FLOOR_LITERAL})" + + def _print_GuardSignedFloor(self, expr: GuardSignedFloor) -> str: + """Render the sign-preserving denominator guard. + + ``ti.select(arg >= 0, ti.max(arg, 1e-12), ti.min(arg, -1e-12))`` — floors + the magnitude to ``1e-12`` while keeping ``arg``'s sign (a no-op for + ``|arg| >= 1e-12``). ``ti.select``/``ti.max``/``ti.min`` are all valid + Taichi scalar ops. + """ + arg = self._print(expr.args[0]) + pos = f"{MATH_TO_TAICHI['Max']}({arg}, {GUARD_FLOOR_LITERAL})" + neg = f"{MATH_TO_TAICHI['Min']}({arg}, {GUARD_FLOOR_NEG_LITERAL})" + return f"ti.select({arg} >= 0, {pos}, {neg})" + + def _print_Pow(self, expr: sp.Pow, rational: bool = False) -> str: + """Route a floored-base power to ``ti.pow``; otherwise defer to the base. + + When the base is a :class:`~mechdsl.lawgen.guard_transforms.GuardFloor` + marker and the exponent is not the ``sqrt`` half, emit + ``ti.pow(ti.max(base, 1e-12), exp)`` — the exact ``swift_voce.py`` idiom + — for any (fractional / symbolic, positive or negative) exponent. The + ``sqrt`` half-exponent case falls through to the base printer, which + produces ``ti.sqrt(...)`` with the floored marker rendered inside. A + :class:`~mechdsl.lawgen.guard_transforms.GuardSignedFloor` base (a + reciprocal denominator) is likewise handled by the base printer, which + formats the division and renders the marker via ``_print_GuardSignedFloor``. + """ + base = expr.base + if isinstance(base, GuardFloor) and expr.exp is not sp.S.Half: + return f"{MATH_TO_TAICHI['pow']}({self._print(base)}, {self._print(expr.exp)})" + return super()._print_Pow(expr, rational=rational) + + +def _unsupported_function_names(expr: sp.Basic) -> list[str]: + """Return every function name in ``expr`` this lowerer cannot map, sorted. + + Catches BOTH failure modes in one pre-pass over ``expr.atoms(sp.Function)``: + + * an ``AppliedUndef`` — a call to a function outside the allow-list (a typo + like ``foo`` or ``expp``), and + * a *defined-but-unmapped* SymPy function (``erf``, ``gamma``) whose name is + not a key of :data:`MATH_TO_TAICHI`. + + Two families of *supported* nodes are deliberately not swept up here: + + * ``sp.Max``/``sp.Min`` are ``MinMaxBase`` (not ``sp.Function``), so + ``atoms(sp.Function)`` never returns them — they have dedicated + ``_print_Max``/``_print_Min`` methods. + * ``sp.Piecewise`` *is* a ``sp.Function`` subclass, so it would otherwise be + mis-flagged as unmapped; it is excluded via + :data:`_SUPPORTED_FUNCTION_NODES` because it has a dedicated + ``_print_Piecewise`` lowering (its branch-count budget and exhaustiveness + are checked separately in the pre-pass). + + Collecting all unmapped names up front (rather than raising on the first one + deep in the recursive printer) is what lets the caller report *every* + unsupported node in a single + :class:`~mechdsl.lawgen.diagnostics.LawgenError` (P3-1 collect-all). + """ + names = { + type(fn).__name__ + for fn in expr.atoms(sp.Function) + if type(fn).__name__ not in MATH_TO_TAICHI + and type(fn).__name__ not in _SUPPORTED_FUNCTION_NODES + } + return sorted(names) + + +def _collect_unsupported(expr: sp.Basic, *, law: str, collector: DiagnosticCollector) -> None: + """Add a diagnostic for each unsupported function node in ``expr`` (R2, collect-all). + + One diagnostic per distinct unmapped function name, so two unsupported nodes + in the same law surface as two diagnostics in the one + :class:`~mechdsl.lawgen.diagnostics.LawgenError`. Nothing is raised here — the + caller (:func:`lower_expression`) raises the whole batch after every + expression has been scanned (no silent fallback: an unmapped node always + yields a diagnostic). + """ + for name in _unsupported_function_names(expr): + collector.add( + LawgenDiagnostic( + law=law, + expression=str(expr), + node=name, + reason=( + f"function {name!r} is not in the Taichi allow-list " + f"{sorted(MATH_TO_TAICHI)}; it is an unknown/unsupported node " + "(no silent fallback — R2)." + ), + fix=( + f"remove or rewrite {name!r} using only allowed functions, or register " + f"{name!r} in MATH_TO_TAICHI (lawgen/sympy_to_taichi.py) if Taichi supports it." + ), + ) + ) + + +def _collect_non_exhaustive_piecewise( + expr: sp.Basic, *, law: str, collector: DiagnosticCollector +) -> None: + """Add a diagnostic for each non-exhaustive ``Piecewise`` in ``expr`` (R2, collect-all). + + A ``Piecewise`` whose final branch condition is not ``True`` has no defined + else-value; lowering it to nested ``ti.select`` would silently emit a wrong + fallthrough (``ti.select`` has no "undefined" result). Detected in the same + pre-pass so it collects alongside unsupported-function and budget diagnostics + rather than raising mid-recursion. + """ + for piece in expr.atoms(sp.Piecewise): + if piece.args[-1].cond is not sp.true: + collector.add( + LawgenDiagnostic( + law=law, + expression=str(expr), + node="Piecewise", + reason=( + f"non-exhaustive Piecewise {piece!r}: the final branch is not the " + "default ``(value, True)``, so the nested ti.select would have no " + "defined else-value (silent fallthrough — R2)." + ), + fix=( + "add a terminal default branch ``(value, True)`` so the switch is " + "exhaustive and the nested ti.select has a defined else-value." + ), + ) + ) + + +def _collect_piecewise_budget( + expr: sp.Basic, target: TiconstitTarget, *, law: str, collector: DiagnosticCollector +) -> None: + """Add a budget diagnostic if ``expr``'s largest ``Piecewise`` exceeds the branch budget. + + Reuses P2-2's :func:`~mechdsl.lawgen.budgets.count_piecewise_branches` and the + shared budget-diagnostic builder, so the ``reason`` carries the measured + branch count AND the ``max_piecewise_branches`` limit. Runs in the same + pre-pass, so an over-budget switch never reaches the printer and its + diagnostic collects alongside the others. ``count_piecewise_branches`` + returns ``0`` when there is no ``Piecewise``, so this is a no-op for ordinary + expressions. + """ + branches = count_piecewise_branches(expr) + if branches > target.max_piecewise_branches: + collector.add( + _budget_diagnostic( + "max_piecewise_branches", branches, target.max_piecewise_branches, law=law + ) + ) + + +@dataclass(frozen=True) +class LoweredExpr: + """The deterministic result of lowering one or more scalar expressions. + + Immutable so a lowered result can be cached / hashed / compared safely. + + Attributes + ---------- + temporaries: + CSE temporary assignment lines, in emission order, e.g. + ``("x0 = ti.exp(-b*p)",)``. Empty when CSE finds no shared + sub-expression. These MUST be emitted before :attr:`returns` — the + returns reference the ``x0``/``x1`` temporaries by name. + returns: + The reduced return-expression source lines, one per input expression, + in the same order the inputs were supplied, e.g. ``("sigma0 + x0",)``. + + source_hash: + A deterministic SHA-256 provenance digest over the emitted lines + (:func:`compute_source_hash`), computed automatically in + ``__post_init__``. 64 lowercase hex chars. Every ``LoweredExpr`` carries + one — including instances constructed directly (P2-2's budget tests build + ``LoweredExpr(temporaries=…, returns=…)`` without passing a hash). It is + excluded from ``__init__`` (callers never supply it) and from equality + (it is derived from ``temporaries``/``returns``, so two lowered results + are equal iff their lines are — the hash carries no independent identity). + """ + + temporaries: tuple[str, ...] + returns: tuple[str, ...] + source_hash: str = field(default="", init=False, compare=False) + + def __post_init__(self) -> None: + # Frozen dataclass: assign the derived hash via ``object.__setattr__``. + object.__setattr__(self, "source_hash", compute_source_hash(self.temporaries, self.returns)) + + +def compute_source_hash(temporaries: Sequence[str], returns: Sequence[str]) -> str: + """Return the deterministic SHA-256 provenance hash of emitted lines. + + Hash input: the emitted lines joined with ``"\\n"`` in **emission order** — + all :attr:`LoweredExpr.temporaries` first, then all :attr:`LoweredExpr.returns` + (``temporaries + returns``), UTF-8 encoded. Emission order *is* the sort key: + the CSE temporaries are emitted in canonical-CSE order (``order='canonical'`` + in :func:`lower_expression`) and the returns follow in input order, so the + line sequence — and therefore the hash — is fully deterministic for a given + input and SymPy version. Returns 64 lowercase hex chars. + + # NOTE (P4-2): this hashes the *emitted output lines*. Cycle 0's + # ``swift_voce.py`` header + ``_manifest.json`` instead define ``source_hash`` + # as the SHA-256 of the canonical *input formula string* (the generator + # INPUT, e.g. ``"R = sigma0 + Q*(1-exp(-b*p)) + K*((p+p0)**n - p0**n)"`` → + # ``7b5af3a8…``), NOT the emitted output. This P2-4 hash satisfies the task's + # determinism + 64-hex acceptance criteria but will NOT equal Cycle 0's hash. + # This function is kept small and separately named so P4-1/P4-2 can add or + # switch to an input-formula hash for manifest-matching without touching the + # lowerer. Do NOT reconcile the two definitions here — that is P4's call. + """ + payload = "\n".join([*temporaries, *returns]) + return hashlib.sha256(payload.encode()).hexdigest() + + +def lower_expression( + exprs: sp.Expr | Sequence[sp.Expr], + *, + printer: TaichiExprPrinter | None = None, + guards: bool = True, + target: TiconstitTarget | None = None, +) -> LoweredExpr: + """Lower scalar SymPy expression(s) to deterministic Taichi source lines. + + Pipeline + -------- + 1. Normalise ``exprs`` to a list (a single ``Expr`` is wrapped). + 2. Collect-all pre-pass (R2): scan every expression for unsupported nodes + (unmapped/undefined functions), non-exhaustive ``Piecewise``, and each + expression's largest ``Piecewise`` against the ``max_piecewise_branches`` + budget — accumulating a diagnostic per problem and raising them together + as one :class:`~mechdsl.lawgen.diagnostics.LawgenError` *before* any code + is produced (never fail-first, never a silent drop). + 3. **P2-3 guard injection** (when ``guards`` is true): rewrite each + expression with :func:`~mechdsl.lawgen.guard_transforms.inject_guards`, + floring ``log``/``sqrt``/symbolic-``pow`` bases with ``ti.max(·, 1e-12)`` + and guarding division denominators — reproducing Cycle 0's hand-authored + ``swift_voce.py`` guards. ``exp`` is deliberately left **unguarded**. + Injection runs *before* CSE so the marker nodes factor deterministically. + 4. Run ``sympy.cse(exprs, order='canonical')``. The ``order='canonical'`` + keyword is **mandatory**: SymPy's default CSE order is not deterministic + across versions, whereas the canonical order gives a stable sort of the + common sub-expressions — the guarantee the whole determinism story rests + on. + 5. Emit the CSE temporaries first (``"x0 = "`` lines), then the + reduced return expressions — with a :class:`TaichiGuardedPrinter` when + ``guards`` is true (it renders the injected markers), else the raw + :class:`TaichiExprPrinter`. + + Parameters + ---------- + exprs: + A single scalar ``sympy.Expr`` or an ordered sequence of them. Order is + preserved: ``returns[i]`` corresponds to ``exprs[i]``. + printer: + Optional pre-built printer. Defaults to a :class:`TaichiGuardedPrinter` + when ``guards`` is true, else a :class:`TaichiExprPrinter`. An explicit + printer is used verbatim (the caller owns marker rendering then). + guards: + When ``True`` (default), inject the P2-3 numerical guards and render them + with a guarded printer. Set ``False`` for the raw, unguarded lowering + (the P2-1 path — used by tests and by callers that guard elsewhere). + P2-4 / P4 depend on this flag: the guarded path is the one the P4-2 + equivalence gate measures. + target: + The :class:`~mechdsl.lawgen.contracts.TiconstitTarget` whose + ``max_piecewise_branches`` knob gates ``Piecewise`` lowering. Defaults to + a plain :class:`TiconstitTarget` (the plan-frozen default of 8 branches). + A ``Piecewise`` with more branches yields a budget diagnostic in the + collect-all :class:`~mechdsl.lawgen.diagnostics.LawgenError` before any + ``ti.select`` is emitted (pre-emission fail-loud, reusing P2-2's counter + and error). + + Returns + ------- + LoweredExpr + Immutable ``(temporaries, returns, source_hash)``. Deterministic: the + same input yields byte-identical tuples and hash on repeat calls. + + Raises + ------ + LawgenError + Collect-all (P3-1): if any expression contains unsupported nodes, a + non-exhaustive ``Piecewise``, or an over-budget ``Piecewise``, *every* + such problem across *all* expressions is collected and raised together as + one :class:`~mechdsl.lawgen.diagnostics.LawgenError` (a + ``NotImplementedError`` subclass, preserving the Phase-2 fail-loud + contract). Each diagnostic carries the offending node, a reason, and an + actionable fix. No node is ever silently dropped (R2). + TypeError + If an element of ``exprs`` is not a ``sympy.Expr``. + """ + active_target = target if target is not None else TiconstitTarget() + expr_list = _as_expr_list(exprs) + + # Pre-pass (collect-all, R2): scan every expression for unsupported nodes and + # budget breaches BEFORE any code is produced, accumulating a diagnostic for + # each so multiple problems surface in one LawgenError (never fail-first, never + # a silent drop). Emission below only runs on a fully clean law. + collector = DiagnosticCollector() + for index, expr in enumerate(expr_list): + law = f"expression #{index}" + _collect_unsupported(expr, law=law, collector=collector) + _collect_non_exhaustive_piecewise(expr, law=law, collector=collector) + _collect_piecewise_budget(expr, active_target, law=law, collector=collector) + collector.raise_if_any() + + if guards: + expr_list = [inject_guards(expr) for expr in expr_list] + + if printer is not None: + active_printer: TaichiExprPrinter = printer + elif guards: + active_printer = TaichiGuardedPrinter() + else: + active_printer = TaichiExprPrinter() + + # order='canonical' is mandatory — see the docstring. This is the single + # CSE call in lawgen (no MechDSL wrapper existed to reuse; REUSE.md). + replacements, reduced = sp.cse(expr_list, order="canonical") + + temporaries = tuple( + f"{active_printer.doprint(sym)} = {active_printer.doprint(sub)}" + for sym, sub in replacements + ) + returns = tuple(active_printer.doprint(expr) for expr in reduced) + return LoweredExpr(temporaries=temporaries, returns=returns) + + +def _as_expr_list(exprs: sp.Expr | Sequence[sp.Expr]) -> list[sp.Expr]: + """Normalise the input to a list of validated ``sympy.Expr``. + + A single ``Expr`` (which is itself iterable via ``.args``) is wrapped, not + iterated — otherwise ``sigma0 + x0`` would be mistaken for a sequence of its + terms. Every element must be a genuine ``sympy.Expr`` (fail loud, no silent + coercion). + """ + items: Iterable[sp.Expr] + items = [exprs] if isinstance(exprs, sp.Expr) else list(exprs) + for index, item in enumerate(items): + if not isinstance(item, sp.Expr): + raise TypeError( + f"lower_expression expected sympy.Expr, got " + f"{type(item).__name__} {item!r} at index {index}." + ) + return list(items) diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/test_emitter.py b/packages/mechdsl-core/src/mechdsl/lawgen/test_emitter.py new file mode 100644 index 0000000..90a074e --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/test_emitter.py @@ -0,0 +1,502 @@ +"""Generated-tests emitter, one pytest file per scalar plasticity law (Task P3-2). + +MFront-mimic Cycle M0, Phase 3 (``dev/plans/mfront_cycleM0.md`` lines 101-103). + +:func:`emit_tests` writes a **self-contained, valid-Python** pytest file that +exercises one :class:`~mechdsl.lawgen.contracts.PlasticityCarrierSpec`. The file +that :func:`emit_tests` writes contains, in order: + +* ``test_reference_eval`` — reconstructs the R/H/Q expressions, ``lambdify``\\ s + them over the spec's symbol map (parameters ∪ variable bindings) and evaluates + them at ``N = 10`` sample points of the primary free variable (the accumulated + plastic strain ``p``), asserting every result is finite. +* ``test_fd_derivative`` — a ``pytest.mark.parametrize``\\ d test covering **all + three factors** R/H/Q. R, H and Q are three *independent* scalar factors, not a + value and its derivative (matching Cycle 0's ``swift_voce.py`` + ``get_R``/``get_H``/``get_Q``): R is the isotropic-hardening flow stress + (function of the plastic strain ``p``), H the strain-rate factor (function of + ``edot``), Q the thermal factor (function of ``T``). For each factor the test + compares a central finite-difference derivative w.r.t. that factor's **own** + primary variable against the *analytic* derivative (``sympy.diff``, lowered + through the same ``lambdify`` path) at the sample points, to ``rtol = 1e-5`` + (standard FD precision — deliberately **not** the ``1e-10`` P4-2 equivalence + gate). A factor that is constant in its primary (e.g. a rate-independent + ``H = 1``) has analytic derivative ``0`` and FD ``≈ 0``, so the general + FD-vs-analytic check passes without any special-casing. +* ``test_monotonicity`` — emitted **iff** ``spec.monotone_check`` is ``True``: + asserts ``R`` is non-decreasing in the accumulated plastic strain across the + sorted sample points. +* ``test_taichi_smoke`` — **optional and guarded**: ``pytest.importorskip`` skips + it cleanly when Taichi is not installed; otherwise it JIT-compiles the lowered + R into a ``@ti.kernel`` and calls it once. + +Self-containedness (why ``srepr``) +---------------------------------- +The generated file must run under ``pytest`` on its own, so it cannot import the +spec back. Instead each expression is serialised with :func:`sympy.srepr` — a +loss-free, ``eval``-free round-trip string that the generated file rebuilds with +:func:`sympy.sympify`. No SymPy code-printer and no regex string surgery is used +(plan rule R4): the *expression* is carried as data and re-parsed by SymPy, and +the file *body* is assembled from plain f-strings. + +Fixed placeholder parameters +---------------------------- +The generated tests are self-consistency checks that hold for *any* fixed +parameter values (the FD identity ``f'(x) ≈ (f(x+h) − f(x−h))/2h`` and +monotonicity of a well-formed hardening law do not depend on the specific +material constants). So every material parameter and every non-primary variable +binding is pinned to :data:`PLACEHOLDER_PARAM_VALUE` (``1.0``); the generated file +documents this inline. This keeps the emitted file dependency-free and +deterministic without needing a real material card. + +No silent fallback (R2) +----------------------- +If the spec's ``R`` cannot be lowered to Taichi (an unsupported node), +:func:`emit_tests` calls :func:`~mechdsl.lawgen.sympy_to_taichi.lower_expression`, +which raises :class:`~mechdsl.lawgen.diagnostics.LawgenError`. The emitter never +writes a partial or silently-degraded file. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import sympy as sp + +from mechdsl.lawgen.sympy_to_taichi import lower_expression + +if TYPE_CHECKING: + from mechdsl.lawgen.contracts import PlasticityCarrierSpec + +__all__ = [ + "FACTOR_PRIMARY_VARIABLE", + "FD_RTOL", + "FD_STEP", + "N_SAMPLE_POINTS", + "PLACEHOLDER_PARAM_VALUE", + "PRIMARY_VARIABLE_NAME", + "emit_tests", +] + +# --------------------------------------------------------------------------- +# Emission constants (mirrored verbatim into the generated file so a reader of +# the test does not have to import this module to know the numbers). +# --------------------------------------------------------------------------- + +#: Number of sample points of the primary variable used by every generated test. +N_SAMPLE_POINTS: int = 10 + +#: Fixed value pinned to every material parameter and every non-primary variable +#: binding in the generated tests. ``1.0`` — the checks are self-consistency +#: identities that hold for any fixed params (see module docstring). +PLACEHOLDER_PARAM_VALUE: float = 1.0 + +#: Central finite-difference step for the FD-derivative test. ``1e-6`` balances +#: truncation error (``O(h^2)``) against float64 round-off (``O(eps/h)``); the +#: sweet spot for a central difference in double precision is ~``eps**(1/3) ≈ +#: 6e-6``, so ``1e-6`` sits comfortably in that band. +FD_STEP: float = 1e-6 + +#: Relative tolerance for the FD-vs-analytic derivative comparison. ``1e-5`` is +#: standard central-difference precision — NOT the ``1e-10`` P4-2 equivalence +#: gate. The generated test uses this as ``rtol`` (with a small ``atol`` so a +#: near-zero analytic derivative does not force an unreachable relative match). +FD_RTOL: float = 1e-5 + +#: The free-variable name the generated tests treat as the primary sweep axis +#: (the accumulated plastic strain). If a spec has no ``"p"`` binding the emitter +#: falls back to the first variable binding. +PRIMARY_VARIABLE_NAME: str = "p" + +# Per-factor primary-variable convention (the FD-derivative axis for each of the +# three shipped factors). R/H/Q are THREE SEPARATE scalar factors, not a value and +# its derivative — matching Cycle 0's ``swift_voce.py`` ``get_R``/``get_H``/ +# ``get_Q``: +# * R = isotropic HARDENING flow-stress, primarily a function of the accumulated +# plastic strain ``p`` (a.k.a. peeq); +# * H = strain-RATE factor, primarily a function of the rate ``edot``; +# * Q = THERMAL factor, primarily a function of temperature ``T``. +# The generator auto-differentiates each factor w.r.t. its OWN primary variable +# via ``sympy.diff`` — H is NOT ``d(R)/dp``. A factor with no dependence on its +# conventional primary (e.g. a rate-independent ``H = 1``) has analytic derivative +# ``0`` and FD ``≈ 0``: the general FD-vs-analytic check passes without any +# special-casing. Each role's primary is resolved against the spec's +# ``variable_bindings`` (falling back to the global primary if the conventional +# binding is absent — see :func:`_factor_primary_name`). +FACTOR_PRIMARY_VARIABLE: dict[str, str] = {"R": "p", "H": "edot", "Q": "T"} + +# Sample-point window for the primary variable. A strictly positive, increasing +# range so hardening laws with ``sqrt``/``log``/``pow`` of the plastic strain are +# evaluated in-domain and the monotonicity check is meaningful (a hardening law +# is monotone in ``p >= 0``). Kept away from exactly 0 so FD ``x - h`` stays +# positive. +_SAMPLE_START: float = 1e-3 +_SAMPLE_STOP: float = 1.0 + + +def _primary_variable(spec: PlasticityCarrierSpec) -> tuple[str, sp.Symbol]: + """Return the ``(name, symbol)`` of the primary sweep variable. + + Prefers the accumulated plastic strain binding (``PRIMARY_VARIABLE_NAME``, + ``"p"``); falls back to the first variable binding if the spec does not bind + ``"p"``. The spec guarantees at least one binding (``__post_init__``), so this + never returns ``None``. + """ + bindings = dict(spec.variable_bindings) + if PRIMARY_VARIABLE_NAME in bindings: + return PRIMARY_VARIABLE_NAME, bindings[PRIMARY_VARIABLE_NAME] + name, symbol = next(iter(bindings.items())) + return name, symbol + + +def _factor_primary_name(spec: PlasticityCarrierSpec, role: str, global_primary: str) -> str: + """Return the FD-derivative axis name for factor ``role`` (``"R"``/``"H"``/``"Q"``). + + Uses the :data:`FACTOR_PRIMARY_VARIABLE` convention (R→``p``, H→``edot``, + Q→``T``) resolved against the spec's ``variable_bindings``: if the conventional + binding exists it is the factor's primary; otherwise fall back to + ``global_primary`` (the spec's ``p``/first binding). The fallback keeps the FD + check well-defined even for a spec that does not bind ``edot``/``T`` — the + factor simply has no dependence on the fallback axis and its analytic + derivative is ``0`` (FD ``≈ 0``), which the general assertion accepts. + """ + conventional = FACTOR_PRIMARY_VARIABLE.get(role, global_primary) + if conventional in spec.variable_bindings: + return conventional + return global_primary + + +def _ordered_symbol_names(spec: PlasticityCarrierSpec, primary_name: str) -> list[str]: + """Deterministic argument order for the generated ``lambdify`` calls. + + Primary variable first, then the remaining variable bindings, then the + material parameters — each group in the spec's own declaration order and with + duplicates removed. A stable order keeps the generated file byte-deterministic + (P3-3 lists these tests in the manifest) and lets the reference/FD/monotone + tests share one argument tuple. + """ + ordered: list[str] = [primary_name] + for name in spec.variable_bindings: + if name not in ordered: + ordered.append(name) + for name in spec.parameters: + if name not in ordered: + ordered.append(name) + return ordered + + +def emit_tests( + spec: PlasticityCarrierSpec, + lowered_r: str | None = None, + target_test_path: str | Path | None = None, +) -> Path: + """Write a self-contained pytest file for ``spec`` and return its path. + + Parameters + ---------- + spec: + The plasticity carrier law to generate tests for. Its ``expressions`` + (R/H/Q), ``parameters``, ``variable_bindings`` and ``monotone_check`` flag + drive the emitted file. + lowered_r: + Optional pre-lowered Taichi return-source for ``R`` (the Taichi expression + string used by the guarded JIT smoke test). When ``None`` (the usual + call), the emitter lowers ``spec.R`` itself via + :func:`~mechdsl.lawgen.sympy_to_taichi.lower_expression` — which raises + :class:`~mechdsl.lawgen.diagnostics.LawgenError` for an unsupported node + (R2, no silent fallback), so an inexpressible law fails here rather than + emitting a broken test. + target_test_path: + Where to write the file. Required. A ``str`` or :class:`~pathlib.Path`; + parent directories are created if missing. + + Returns + ------- + pathlib.Path + The path the test file was written to (``target_test_path`` as a + :class:`~pathlib.Path`). + """ + if target_test_path is None: + raise ValueError("emit_tests requires target_test_path (where to write the pytest file).") + + # Lowering ``R`` here is the fail-loud gate (R2): an unsupported node raises + # LawgenError before any file is written. The returned Taichi source is what + # the guarded JIT smoke test compiles. Guards on so the smoke test matches the + # production (guarded) emission path. + if lowered_r is None: + lowered = lower_expression(spec.R, guards=True) + lowered_r = lowered.returns[0] + + primary_name, _primary_symbol = _primary_variable(spec) + arg_names = _ordered_symbol_names(spec, primary_name) + + # Per-factor FD-derivative axis: R→p, H→edot, Q→T (resolved against the spec's + # bindings; see FACTOR_PRIMARY_VARIABLE). Each factor is FD-vs-analytic checked + # w.r.t. its OWN primary — R/H/Q are three independent factors, not a value and + # its derivative. + factor_primaries = { + role: _factor_primary_name(spec, role, primary_name) for role in ("R", "H", "Q") + } + + source = _render_test_file( + spec=spec, + primary_name=primary_name, + arg_names=arg_names, + factor_primaries=factor_primaries, + lowered_r=lowered_r, + ) + + path = Path(target_test_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + return path + + +def _render_test_file( + *, + spec: PlasticityCarrierSpec, + primary_name: str, + arg_names: list[str], + factor_primaries: dict[str, str], + lowered_r: str, +) -> str: + """Assemble the full generated pytest-file source as one string. + + Pure string assembly from f-strings over data (``srepr`` expression strings, + the ordered argument names, the numeric constants). No SymPy code-printer and + no regex rewriting is used (R4) — the expressions travel as ``srepr`` data and + are rebuilt by ``sympy.sympify`` inside the generated file. + """ + r_srepr = sp.srepr(spec.R) + h_srepr = sp.srepr(spec.H) + q_srepr = sp.srepr(spec.Q) + + args_literal = ", ".join(repr(name) for name in arg_names) + # Placeholder value for EVERY argument (params and all variable bindings, + # including the global primary). ``_args_at`` overrides just the one variable + # it is sweeping and leaves the rest at the placeholder, so the dict must cover + # every name — otherwise sweeping a non-``p`` factor primary (H->edot, Q->T) + # would leave ``p`` unset. Built as a literal dict for readability. + placeholder_items = ", ".join(f"{name!r}: {PLACEHOLDER_PARAM_VALUE!r}" for name in arg_names) + + # (role, SREPR-const-name, factor-primary-name) triples for the parametrized + # FD-derivative test — one row per shipped factor, each differentiated w.r.t. + # its OWN primary axis. + fd_cases_literal = ", ".join( + f"({role!r}, {srepr_const!r}, {factor_primaries[role]!r})" + for role, srepr_const in (("R", "R_SREPR"), ("H", "H_SREPR"), ("Q", "Q_SREPR")) + ) + + monotone_block = _render_monotone_block(primary_name) if spec.monotone_check else "" + header = _render_header(spec) + + # NB: the generated file deliberately does NOT use ``from __future__ import + # annotations``. PEP 563 stringizes every annotation, and Taichi reads a + # ``@ti.kernel``'s parameter annotation as a *type object* (``ti.f64``) — a + # string ``"ti.f64"`` makes it raise ``Invalid type annotation``. The file + # uses no forward references, so omitting the future-import is harmless. + return f'''{header} +import math + +import pytest +import sympy as sp + +# --- Law data (loss-free ``srepr`` round-trip; rebuilt with ``sympy.sympify``) --- +R_SREPR = {r_srepr!r} +H_SREPR = {h_srepr!r} +Q_SREPR = {q_srepr!r} + +# Ordered lambdify argument names: primary variable first, then the other +# variable bindings, then the material parameters. +ARG_NAMES = ({args_literal},) +PRIMARY_NAME = {primary_name!r} + +# Fixed placeholder value for every argument (``_args_at`` overrides just the one +# variable it sweeps). The reference/FD/monotonicity checks are self-consistency +# identities that hold for any fixed params, so a real material card is +# unnecessary (value = {PLACEHOLDER_PARAM_VALUE!r}). +PLACEHOLDERS = {{{placeholder_items}}} + +N_SAMPLE_POINTS = {N_SAMPLE_POINTS!r} +FD_STEP = {FD_STEP!r} +FD_RTOL = {FD_RTOL!r} +FD_ATOL = 1e-9 +_SAMPLE_START = {_SAMPLE_START!r} +_SAMPLE_STOP = {_SAMPLE_STOP!r} + +# Per-factor FD-derivative cases: (role, srepr, primary-variable-name). R/H/Q are +# three INDEPENDENT factors (hardening / rate / thermal), each auto-differentiated +# by the generator (``sympy.diff``) w.r.t. its OWN primary variable — R w.r.t. the +# plastic strain, H w.r.t. the rate, Q w.r.t. temperature. H is NOT ``d(R)/dp``. +# A factor that is constant in its primary (e.g. rate-independent ``H = 1``) has +# analytic derivative 0 and FD ~ 0, so the general FD-vs-analytic check still +# passes — no special-casing needed. +FD_CASES = ({fd_cases_literal},) + +# Init Taichi at most once per process. ``ti.init`` resets Taichi's global runtime, +# so the guarded smoke test calls it only on first use — avoiding a redundant +# re-init if several generated law-test modules run in one pytest session. +_TAICHI_INITED = False + + +def _sample_points(): + """N evenly spaced, strictly positive, increasing values of the swept variable.""" + step = (_SAMPLE_STOP - _SAMPLE_START) / (N_SAMPLE_POINTS - 1) + return [_SAMPLE_START + i * step for i in range(N_SAMPLE_POINTS)] + + +def _rebuild(srepr_str): + """Rebuild a SymPy expression from its ``srepr`` string (loss-free, no eval).""" + return sp.sympify(srepr_str) + + +def _symbols(): + """Return the ordered tuple of SymPy symbols for the lambdify arguments.""" + return tuple(sp.Symbol(name) for name in ARG_NAMES) + + +def _lambdified(expr): + """``lambdify`` ``expr`` over the ordered argument symbols using math backend.""" + return sp.lambdify(_symbols(), expr, "math") + + +def _args_at(x, primary_name=PRIMARY_NAME): + """Positional argument tuple with ``primary_name`` set to ``x``, rest pinned. + + Every other argument (params and the non-swept bindings) is fixed to its + placeholder value, so this builds the evaluation point for both the reference + sweep (``primary_name`` defaults to the global primary) and the per-factor FD + test (which passes each factor's own primary).""" + values = dict(PLACEHOLDERS) + values[primary_name] = x + return tuple(values[name] for name in ARG_NAMES) + + +def test_reference_eval(): + """R/H/Q evaluate to finite numbers at N sample points (reference eval).""" + fns = [_lambdified(_rebuild(s)) for s in (R_SREPR, H_SREPR, Q_SREPR)] + for x in _sample_points(): + args = _args_at(x) + for fn in fns: + value = fn(*args) + assert math.isfinite(value), f"non-finite reference value {{value!r}} at p={{x!r}}" + + +@pytest.mark.parametrize(("role", "srepr_str", "primary_name"), FD_CASES) +def test_fd_derivative(role, srepr_str, primary_name): + """Central FD derivative of each factor matches its analytic derivative. + + Covers ALL THREE factors R/H/Q, each differentiated w.r.t. its OWN primary + variable (R->plastic strain, H->rate, Q->temperature), FD vs analytic + ``sympy.diff`` at the N sample points, rtol <= 1e-5 (+ atol).""" + expr = _rebuild(srepr_str) + primary = sp.Symbol(primary_name) + fn = _lambdified(expr) + dfn = _lambdified(sp.diff(expr, primary)) + + primary_index = ARG_NAMES.index(primary_name) + for x in _sample_points(): + def _perturbed(delta, _x=x): + args = list(_args_at(_x, primary_name)) + args[primary_index] = _x + delta + return fn(*args) + + fd = (_perturbed(FD_STEP) - _perturbed(-FD_STEP)) / (2.0 * FD_STEP) + analytic = dfn(*_args_at(x, primary_name)) + assert math.isclose(fd, analytic, rel_tol=FD_RTOL, abs_tol=FD_ATOL), ( + f"FD {{fd!r}} != analytic {{analytic!r}} for factor {{role!r}} " + f"at {{primary_name}}={{x!r}} (rtol={{FD_RTOL!r}})" + ) +{monotone_block} + +def _run_lowered_r({primary_name}_value): + """JIT-compile the lowered Taichi R and evaluate it once at ``{primary_name}_value``. + + Defined at module scope (Taichi's source inspection cannot reliably compile a + ``@ti.kernel`` nested inside a pytest test function). ``ti`` is imported here, + not at module import, so the generated file imports cleanly without Taichi. + Material constants are kernel-local, pinned to the placeholder value. + """ + import taichi as ti + + global _TAICHI_INITED + if not _TAICHI_INITED: + ti.init(arch=ti.cpu) + _TAICHI_INITED = True + + @ti.kernel + def _kernel({primary_name}: ti.f64) -> ti.f64: +{_render_taichi_placeholder_assignments(spec, primary_name)} return {lowered_r} + + return _kernel({primary_name}_value) + + +@pytest.mark.slow +def test_taichi_smoke(): + """Guarded Taichi JIT smoke: compile the lowered R once and call it. + + Skipped cleanly when Taichi is not installed (``importorskip``).""" + pytest.importorskip("taichi") + result = _run_lowered_r(0.5) + assert math.isfinite(result), f"non-finite Taichi R output {{result!r}}" +''' + + +def _render_header(spec: PlasticityCarrierSpec) -> str: + """Module docstring for the generated file, naming the source law.""" + monotone = "yes" if spec.monotone_check else "no" + return ( + f'"""Auto-generated tests for plasticity carrier {spec.name!r} ' + f"(MechDSL lawgen P3-2).\n\n" + f"DO NOT EDIT BY HAND — regenerate via mechdsl.lawgen.test_emitter.emit_tests.\n\n" + f"Reference eval + per-factor FD-derivative (rtol={FD_RTOL!r}) self-consistency\n" + f"checks over {N_SAMPLE_POINTS} sample points, with fixed placeholder parameters " + f"(= {PLACEHOLDER_PARAM_VALUE!r}).\n" + f"R/H/Q are three independent factors (hardening/rate/thermal); each is FD-checked\n" + f"against its own analytic derivative w.r.t. its own primary variable.\n" + f"Monotonicity block emitted: {monotone}. Taichi JIT smoke test is guarded\n" + f'(skips when Taichi is unavailable).\n"""' + ) + + +def _render_monotone_block(primary_name: str) -> str: + """The monotonicity test, emitted only when ``spec.monotone_check`` is True.""" + return f''' + +def test_monotonicity(): + """R is non-decreasing in the accumulated plastic strain {primary_name!r}.""" + r_fn = _lambdified(_rebuild(R_SREPR)) + points = _sample_points() # already strictly increasing + values = [r_fn(*_args_at(x)) for x in points] + for (x_lo, v_lo), (x_hi, v_hi) in zip( + list(zip(points, values)), list(zip(points, values))[1:], strict=False + ): + assert v_lo <= v_hi + FD_ATOL, ( + f"R not monotone: R(p={{x_lo!r}})={{v_lo!r}} > R(p={{x_hi!r}})={{v_hi!r}}" + ) +''' + + +def _render_taichi_placeholder_assignments(spec: PlasticityCarrierSpec, primary_name: str) -> str: + """Emit ``name = 1.0`` lines for every symbol the lowered R references but the + kernel does not take as an argument (the material params and non-primary vars). + + The lowered R Taichi source references parameter and binding names directly; + the smoke kernel only takes the primary variable, so every other referenced + name must be a local constant. Pinned to the documented placeholder value. + """ + names: list[str] = [] + for name in spec.parameters: + if name != primary_name and name not in names: + names.append(name) + for name in spec.variable_bindings: + if name != primary_name and name not in names: + names.append(name) + indent = " " * 8 + lines = [ + f"{indent}# Placeholder material constants pinned to " + f"{PLACEHOLDER_PARAM_VALUE!r} (see PLACEHOLDERS).\n" + ] + lines += [f"{indent}{name}: ti.f64 = {PLACEHOLDER_PARAM_VALUE!r}\n" for name in names] + return "".join(lines) diff --git a/packages/mechdsl-core/tests/lawgen/__init__.py b/packages/mechdsl-core/tests/lawgen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/mechdsl-core/tests/lawgen/fixtures/linear_min.yaml b/packages/mechdsl-core/tests/lawgen/fixtures/linear_min.yaml new file mode 100644 index 0000000..21154ca --- /dev/null +++ b/packages/mechdsl-core/tests/lawgen/fixtures/linear_min.yaml @@ -0,0 +1,10 @@ +# Minimal law fixture for the mechdsl-lawgen compile CLI (Task P1-2). +# A trivial power-law isotropic-hardening carrier — non-colliding names so it +# never clashes with the real swift_voce.yaml P4-1 ships. +name: linear_min +parameters: [sigma0, K, n] +variables: [p, edot, T] +expressions: + R: "sigma0 + K*p**n" + H: "1" + Q: "1" diff --git a/packages/mechdsl-core/tests/lawgen/test_budgets.py b/packages/mechdsl-core/tests/lawgen/test_budgets.py new file mode 100644 index 0000000..8696215 --- /dev/null +++ b/packages/mechdsl-core/tests/lawgen/test_budgets.py @@ -0,0 +1,311 @@ +"""Unit tests for the pre-emission JIT budget gate (Task P2-2). + +MFront-mimic Cycle M0, Phase 2 (``dev/plans/mfront_cycleM0.md`` lines 79-82). + +Covers all seven ``test_plan.cases``: + +1-6. Each of the six budget knobs is tripped *in isolation* by a targeted + fixture, and the raised :class:`BudgetError` names the knob, the measured + value, and the limit. +7. A compliant SwiftVoce-like expression set passes :meth:`check_all` cleanly. + +Plus: the three module-level counters are exercised directly (they are the +independently-testable primitives P2-3/P2-4/P3 reuse), and the defaults are +proven to be wired from :class:`TiconstitTarget` (one test trips a *default* +budget, the rest use tiny overridden knobs to keep fixtures small). +""" + +from __future__ import annotations + +import pytest +import sympy as sp + +from mechdsl.codegen.einsum_optimizer import BudgetExceededError +from mechdsl.lawgen.budgets import ( + BudgetChecker, + BudgetError, + count_expr_ops, + count_piecewise_branches, + count_pow_symbolic_exponent, +) +from mechdsl.lawgen.contracts import TiconstitTarget +from mechdsl.lawgen.diagnostics import LawgenDiagnostic, LawgenError +from mechdsl.lawgen.sympy_to_taichi import LoweredExpr + + +def _sole_diagnostic(exc: LawgenError, knob: str) -> LawgenDiagnostic: + """Return the single collected diagnostic and assert it names ``knob``. + + The P2-2 single-knob fixtures each trip exactly one budget, so the collect-all + :class:`LawgenError` (P3-1) carries exactly one diagnostic. This helper pins + that (one diagnostic, the expected knob) and hands it back for the + measured+limit assertions. + """ + assert len(exc.diagnostics) == 1, [d.node for d in exc.diagnostics] + (diag,) = exc.diagnostics + assert diag.node == knob + assert diag.fix.strip() # every diagnostic carries an actionable fix + return diag + + +# --------------------------------------------------------------------------- +# Shared symbols / a compliant SwiftVoce-like expression set. +# --------------------------------------------------------------------------- + +_x, _n, _b, _p, _p0 = sp.symbols("x n b p p0") +_sigma0, _Q, _K = sp.symbols("sigma0 Q K") + + +def _swift_voce_expressions() -> dict[str, sp.Expr]: + """A compliant SwiftVoce-like ``{R, H, Q}`` expression set. + + ``R = sigma0 + Q*(1 - exp(-b*p)) + K*((p + p0)**n - p0**n)``; ``H = 1``; + ``Q = 1``. Small on every axis: a dozen ops, two symbolic-exponent powers, + no ``Piecewise`` — comfortably inside every default budget. + """ + r = _sigma0 + _Q * (1 - sp.exp(-_b * _p)) + _K * ((_p + _p0) ** _n - _p0**_n) + return {"R": r, "H": sp.Integer(1), "Q": sp.Integer(1)} + + +def _lowered(n_temps: int = 0, n_returns: int = 1) -> LoweredExpr: + """Build a ``LoweredExpr`` with the requested temporary/return line counts.""" + return LoweredExpr( + temporaries=tuple(f"x{i} = 0.0" for i in range(n_temps)), + returns=tuple(f"r{i}" for i in range(n_returns)), + ) + + +# --------------------------------------------------------------------------- +# Module-level counters — the reusable primitives (P2-3/P2-4/P3 depend here). +# --------------------------------------------------------------------------- + + +def test_count_expr_ops_matches_sympy() -> None: + """``count_expr_ops`` returns an int equal to ``sp.count_ops``.""" + expr = _x**2 + 2 * _x + 1 + value = count_expr_ops(expr) + assert isinstance(value, int) + assert value == int(sp.count_ops(expr)) + + +def test_count_piecewise_branches_takes_the_max() -> None: + """Branch count is ``len(Piecewise.args)``; 0 when there is no Piecewise.""" + three = sp.Piecewise((_x, _x > 0), (2 * _x, _x < -1), (sp.Integer(0), True)) + assert count_piecewise_branches(three) == 3 + assert count_piecewise_branches(_x**2 + 1) == 0 + + +def test_count_pow_symbolic_exponent_rule() -> None: + """Only non-``Integer`` exponents count (symbolic, rational, float).""" + assert count_pow_symbolic_exponent(_x**_n) == 1 # symbolic + assert count_pow_symbolic_exponent(_x**2) == 0 # integer literal + assert count_pow_symbolic_exponent(_x**-3) == 0 # negative integer + # ±1/2 exponents lower to ti.sqrt / 1/ti.sqrt (P2-1 printer), NOT ti.pow, + # so they are exempt from this runtime-ti.pow budget. + assert count_pow_symbolic_exponent(sp.sqrt(_x)) == 0 # Pow(x, S.Half) + assert count_pow_symbolic_exponent(1 / sp.sqrt(_x)) == 0 # Pow(x, -S.Half) + assert count_pow_symbolic_exponent(_x ** sp.Rational(3, 2)) == 1 # non-half rational + assert count_pow_symbolic_exponent(_x**2.0) == 1 # Float + assert count_pow_symbolic_exponent(_x**_n + _p**_n) == 2 # two distinct symbolic + + +# --------------------------------------------------------------------------- +# Case 1 — max_expr_ops. +# --------------------------------------------------------------------------- + + +def test_max_expr_ops_exceeded_raises_named_error() -> None: + """Exceeding ``max_expr_ops`` raises collect-all ``LawgenError`` naming knob/value/limit.""" + expr = _x**2 + 2 * _x + 1 # count_ops == 4 + target = TiconstitTarget(max_expr_ops=2) + checker = BudgetChecker(target) + + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": expr}, {"R": _lowered()}) + + diag = _sole_diagnostic(exc.value, "max_expr_ops") + assert "4" in diag.reason # measured + assert "2" in diag.reason # limit + assert "budget exceeded" in diag.reason + # The aggregate message surfaces the same knob + numbers. + message = str(exc.value) + assert "max_expr_ops" in message and "4" in message and "2" in message + + +# --------------------------------------------------------------------------- +# Case 2 — max_cse_temps_per_func. +# --------------------------------------------------------------------------- + + +def test_max_cse_temps_per_func_exceeded_raises() -> None: + """Too many CSE temporaries in one function trips ``max_cse_temps_per_func``.""" + target = TiconstitTarget(max_cse_temps_per_func=2) + checker = BudgetChecker(target) + lowered = _lowered(n_temps=3, n_returns=1) # 3 temporaries > 2 + + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": sp.Integer(1)}, {"R": lowered}) + + diag = _sole_diagnostic(exc.value, "max_cse_temps_per_func") + assert "3" in diag.reason # measured + assert "2" in diag.reason # limit + + +# --------------------------------------------------------------------------- +# Case 3 — max_func_lines. +# --------------------------------------------------------------------------- + + +def test_max_func_lines_exceeded_raises() -> None: + """An over-length function (temps + returns) trips ``max_func_lines``.""" + target = TiconstitTarget(max_func_lines=3) + checker = BudgetChecker(target) + lowered = _lowered(n_temps=2, n_returns=2) # 4 lines > 3 + + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": sp.Integer(1)}, {"R": lowered}) + + diag = _sole_diagnostic(exc.value, "max_func_lines") + assert "4" in diag.reason # measured + assert "3" in diag.reason # limit + + +# --------------------------------------------------------------------------- +# Case 4 — max_total_generated_lines_per_class. +# --------------------------------------------------------------------------- + + +def test_max_total_generated_lines_per_class_exceeded_raises() -> None: + """The summed line count across functions trips the per-class budget. + + Each function stays under ``max_func_lines`` (5), but their *sum* (3 + 3 = 6) + exceeds ``max_total_generated_lines_per_class`` (5), so only the class-level + budget fires. + """ + target = TiconstitTarget(max_func_lines=5, max_total_generated_lines_per_class=5) + checker = BudgetChecker(target) + lowered = { + "R": _lowered(n_temps=2, n_returns=1), # 3 lines + "H": _lowered(n_temps=2, n_returns=1), # 3 lines -> total 6 + } + exprs = {"R": sp.Integer(1), "H": sp.Integer(1)} + + with pytest.raises(LawgenError) as exc: + checker.check_all(exprs, lowered) + + diag = _sole_diagnostic(exc.value, "max_total_generated_lines_per_class") + assert "6" in diag.reason # measured total + assert "5" in diag.reason # limit + + +# --------------------------------------------------------------------------- +# Case 5 — max_piecewise_branches. +# --------------------------------------------------------------------------- + + +def test_max_piecewise_branches_exceeded_raises() -> None: + """A ``Piecewise`` with too many branches trips ``max_piecewise_branches``.""" + piece = sp.Piecewise( + (_x, _x > 2), (2 * _x, _x > 1), (3 * _x, _x > 0), (sp.Integer(0), True) + ) # 4 branches + target = TiconstitTarget(max_piecewise_branches=2) + checker = BudgetChecker(target) + + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": piece}, {"R": _lowered()}) + + diag = _sole_diagnostic(exc.value, "max_piecewise_branches") + assert "4" in diag.reason # measured + assert "2" in diag.reason # limit + + +# --------------------------------------------------------------------------- +# Case 6 — max_pow_with_symbolic_exponent. +# --------------------------------------------------------------------------- + + +def test_max_pow_with_symbolic_exponent_exceeded_raises() -> None: + """Too many symbolic-exponent powers trip ``max_pow_with_symbolic_exponent``. + + Uses a *default* ``TiconstitTarget`` (limit 12) to prove the defaults are + wired from P1-1: 13 distinct symbolic-exponent powers > 12. + """ + bases = sp.symbols("a0:13") # 13 distinct symbols + expr = sp.Add(*[base**_n for base in bases]) # 13 symbolic-exponent Pow nodes + checker = BudgetChecker(TiconstitTarget()) # default limit == 12 + + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": expr}, {"R": _lowered()}) + + diag = _sole_diagnostic(exc.value, "max_pow_with_symbolic_exponent") + assert "13" in diag.reason # measured + assert "12" in diag.reason # default limit + + +# --------------------------------------------------------------------------- +# Case 7 — compliant SwiftVoce passes. +# --------------------------------------------------------------------------- + + +def test_compliant_swift_voce_passes_check_all() -> None: + """A compliant SwiftVoce set passes ``check_all`` under the default target.""" + exprs = _swift_voce_expressions() + lowered = {role: _lowered(n_temps=1, n_returns=1) for role in exprs} + checker = BudgetChecker(TiconstitTarget()) + + # No exception == pass; check_all returns None. + assert checker.check_all(exprs, lowered) is None + + +def test_check_all_accepts_bare_iterables() -> None: + """``check_all`` also accepts bare iterables (no role keys), still checking.""" + exprs = list(_swift_voce_expressions().values()) + lowered = [_lowered(n_temps=1, n_returns=1) for _ in exprs] + checker = BudgetChecker(TiconstitTarget()) + assert checker.check_all(exprs, lowered) is None + + +# --------------------------------------------------------------------------- +# Error hierarchy + knob-override wiring. +# --------------------------------------------------------------------------- + + +def test_budget_error_is_a_budget_exceeded_error() -> None: + """``BudgetError`` keeps its REUSE.md hierarchy; ``check_all`` raises the P3-1 aggregate. + + P3-1 changes the raised *aggregate* to :class:`LawgenError` (a + ``NotImplementedError`` subclass carrying every violation). :class:`BudgetError` + is retained as the per-diagnostic ``reason`` formatter and still IS-A the + shared :class:`BudgetExceededError` (REUSE.md), so callers building a + :class:`BudgetError` directly keep the old hierarchy. + """ + assert issubclass(BudgetError, BudgetExceededError) + # A directly-built BudgetError still is-a BudgetExceededError (formatter role). + assert isinstance(BudgetError.for_budget("max_expr_ops", 4, 1), BudgetExceededError) + # check_all now raises the collect-all LawgenError aggregate. + with pytest.raises(LawgenError): + BudgetChecker(TiconstitTarget(max_expr_ops=1)).check_all( + {"R": _x**2 + 2 * _x + 1}, {"R": _lowered()} + ) + + +def test_default_checker_uses_frozen_plan_defaults() -> None: + """A ``BudgetChecker()`` with no target binds the plan-frozen defaults.""" + checker = BudgetChecker() + assert checker.target.max_expr_ops == 400 + assert checker.target.max_cse_temps_per_func == 96 + assert checker.target.max_func_lines == 220 + assert checker.target.max_total_generated_lines_per_class == 900 + assert checker.target.max_piecewise_branches == 8 + assert checker.target.max_pow_with_symbolic_exponent == 12 + + +def test_knob_override_changes_the_verdict() -> None: + """The same input passes under defaults but fails under a lowered knob.""" + expr = _x**2 + 2 * _x + 1 # 4 ops + lowered = {"R": _lowered()} + # Default: 4 <= 400, passes. + assert BudgetChecker(TiconstitTarget()).check_all({"R": expr}, lowered) is None + # Overridden knob: 4 > 3, fails with the collect-all LawgenError. + with pytest.raises(LawgenError): + BudgetChecker(TiconstitTarget(max_expr_ops=3)).check_all({"R": expr}, lowered) diff --git a/packages/mechdsl-core/tests/lawgen/test_carrier_emitter.py b/packages/mechdsl-core/tests/lawgen/test_carrier_emitter.py new file mode 100644 index 0000000..3079e65 --- /dev/null +++ b/packages/mechdsl-core/tests/lawgen/test_carrier_emitter.py @@ -0,0 +1,235 @@ +"""Unit tests for the spec-driven Taichi carrier emitter (Task P4-1). + +Covers :mod:`mechdsl.lawgen.carrier_emitter`: the class emitter that assembles a +complete Taichi module (``class `` + ``@ti.func`` methods) from a +:class:`~mechdsl.lawgen.contracts.PlasticityCarrierSpec`, mapping bare lowered +symbol names onto the Cycle 0 contract (parameters → ``self.``, variables → +method args) and auto-differentiating each factor for its ``get_d*`` method. + +The tests are structural / golden-string and numerical-substitute assertions on +the emitted module string — no Taichi JIT here (that is the guarded smoke test in +the generated file and the P4-2 equivalence gate). They pin: the contract surface, +the symbol → self. rebinding, per-method independence, the neutral H/Q +factors, the import-only-Taichi invariant, and byte-determinism. +""" + +from __future__ import annotations + +import math + +import pytest +import sympy as sp + +from mechdsl.lawgen.carrier_emitter import ( + _assert_taichi_only, + emit_carrier, + snake_case_module_name, +) +from mechdsl.lawgen.contracts import PlasticityCarrierSpec +from mechdsl.lawgen.diagnostics import LawgenError +from mechdsl.lawgen.manifest import GENERATED_BY + +# The canonical SwiftVoce input-formula hash (used only as a banner stamp here — +# the emitter records it verbatim, it does not recompute it). +_HASH = "7b5af3a8bb79c2e44e0055a7076dd2c9de2ce8c75eb2e262b80bb4e0232d557f" + + +def _swift_voce_spec() -> PlasticityCarrierSpec: + """The canonical SwiftVoce carrier spec (neutral H/Q factors).""" + sigma0, Q, b, K, n, p0 = sp.symbols("sigma0 Q b K n p0") + p, edot, T = sp.symbols("p edot T") + R = sigma0 + Q * (1 - sp.exp(-b * p)) + K * ((p + p0) ** n - p0**n) + return PlasticityCarrierSpec( + name="SwiftVoce", + parameters=("sigma0", "Q", "b", "K", "n", "p0"), + expressions={"R": R, "H": sp.Integer(1), "Q": sp.Integer(1)}, + variable_bindings={"p": p, "edot": edot, "T": T}, + ) + + +def _emit_swift_voce() -> str: + return emit_carrier(_swift_voce_spec(), source_hash=_HASH, generated_by=GENERATED_BY).source + + +class TestSnakeCaseModuleName: + def test_camelcase_to_snake(self) -> None: + assert snake_case_module_name("SwiftVoce") == "swift_voce" + + def test_already_snake_is_lowercased_unchanged(self) -> None: + assert snake_case_module_name("linear_min") == "linear_min" + + def test_digit_and_acronym_boundaries(self) -> None: + assert snake_case_module_name("J2Plasticity") == "j2_plasticity" + + +class TestEmittedContract: + def test_banner_stamps_hash_and_generator(self) -> None: + source = _emit_swift_voce() + assert source.splitlines()[0] == ( + f"# AUTO-GENERATED by {GENERATED_BY}. source_hash: {_HASH}" + ) + + def test_class_and_decorator(self) -> None: + source = _emit_swift_voce() + assert "@ti.data_oriented" in source + assert "class SwiftVoce:" in source + + def test_all_contract_methods_present(self) -> None: + source = _emit_swift_voce() + for signature in ( + "def __init__(self, params_dict, ti_type=ti.f64):", + "def get_R(self, peeq, yield_scale: ti.f64 = 1.0):", + "def get_dR(self, peeq, yield_scale: ti.f64 = 1.0):", + "def get_H(self, edot):", + "def get_dH(self, edot):", + "def get_Q(self, T):", + "def get_dQ(self, T):", + "def eval_components(self, peeq, edot, T, yield_scale: ti.f64 = 1.0):", + ): + assert signature in source, signature + + def test_eval_components_returns_six_tuple_in_order(self) -> None: + source = _emit_swift_voce() + assert "return R, rate, thermal, dR, drate, dthermal" in source + + def test_init_binds_every_parameter_to_self(self) -> None: + source = _emit_swift_voce() + for param in ("sigma0", "Q", "b", "K", "n", "p0"): + assert f'self.{param} = float(params_dict["{param}"])' in source + + +class TestSymbolRebinding: + def test_parameters_become_self_attributes(self) -> None: + source = _emit_swift_voce() + # get_R references material params via self., never bare. + assert "self.sigma0" in source + assert "self.Q" in source + assert "self.p0" in source + + def test_plastic_strain_is_emitted_as_peeq(self) -> None: + source = _emit_swift_voce() + # The spec binds ``p``; the emitted get_R argument + body use ``peeq``. + assert "def get_R(self, peeq" in source + # The bare spec symbol ``p`` must not leak into get_R as a standalone name + # (only ``peeq`` / ``self.`` appear). Guard against a stray ``* p``. + get_r_body = source.split("def get_R", 1)[1].split("def get_dR", 1)[0] + assert "peeq" in get_r_body + + def test_yield_scale_wraps_R_and_dR_only(self) -> None: + source = _emit_swift_voce() + get_r = source.split("def get_R", 1)[1].split("def get_dR", 1)[0] + get_dr = source.split("def get_dR", 1)[1].split("def get_H", 1)[0] + get_h = source.split("def get_H", 1)[1].split("def get_dH", 1)[0] + assert "return yield_scale * (" in get_r + assert "return yield_scale * (" in get_dr + # get_H takes no yield scale. + assert "yield_scale" not in get_h + + +class TestNeutralFactors: + def test_H_and_Q_are_neutral_and_derivatives_zero(self) -> None: + source = _emit_swift_voce() + + def _method_body(name: str, until: str) -> str: + return source.split(f"def {name}(", 1)[1].split(f"def {until}(", 1)[0] + + # H, Q are the neutral factors (== 1); their derivatives are 0. Each body's + # single return statement is exactly the neutral literal. + assert "return 1" in _method_body("get_H", "get_dH") + assert "return 0" in _method_body("get_dH", "get_Q") + assert "return 1" in _method_body("get_Q", "get_dQ") + assert "return 0" in _method_body("get_dQ", "eval_components") + # And the neutral bodies carry no material-parameter reference (a stray + # ``self.`` in a neutral factor would signal a rebinding leak). + assert "self." not in _method_body("get_H", "get_dH") + assert "self." not in _method_body("get_Q", "get_dQ") + + +class TestImportInvariant: + def test_only_taichi_imported(self) -> None: + source = _emit_swift_voce() + import_lines = [ + line.strip() + for line in source.splitlines() + if line.strip().startswith(("import ", "from ")) + ] + assert import_lines == ["import taichi as ti"] + + def test_no_forbidden_import_in_emitted_source(self) -> None: + source = _emit_swift_voce() + for forbidden in ("import sympy", "import mechdsl", "import ticonstit"): + assert forbidden not in source + + @pytest.mark.parametrize( + "bad_import", + [ + "import sympy", + "import mechdsl as m", + "from mechdsl.lawgen import cli", + "import ticonstit", + "from ticonstit.generated import x", + "import numerixweave", + ], + ) + def test_assert_taichi_only_raises_on_forbidden_import(self, bad_import: str) -> None: + # Directly exercise the INV-DG-1 self-check raise path (the fail-loud guard + # that runs on the emitter's own output): a module carrying any forbidden + # import — the offline generator (sympy/mechdsl) or the consumer + # (ticonstit/numerixweave) — must raise AssertionError naming the module. + source = f"import taichi as ti\n{bad_import}\n\n\nclass Bad:\n pass\n" + with pytest.raises(AssertionError, match="forbidden module"): + _assert_taichi_only(source, "Bad") + + def test_assert_taichi_only_accepts_taichi_and_stdlib(self) -> None: + # The guard must NOT trip on Taichi or a stdlib import (e.g. math). + source = "import taichi as ti\nimport math\n\n\nclass Ok:\n pass\n" + _assert_taichi_only(source, "Ok") # returns None, does not raise + + +class TestDerivativeCorrectness: + """dR is d(R)/d(peeq): prove the emitted derivative equals the analytic one. + + The emitter auto-differentiates R w.r.t. the plastic strain. Batched CSE would + reorganise the structure, but per-method lowering keeps it clean; either way + the value must match K*n*(p+p0)**(n-1) + Q*b*exp(-b*p) to machine precision. + """ + + def test_emitted_dR_matches_analytic_derivative(self) -> None: + params = {"sigma0": 2.5e8, "Q": 1.2e8, "b": 15.0, "K": 3.0e8, "n": 0.3, "p0": 1e-4} + + def analytic_dr(peeq: float) -> float: + base = max(peeq + params["p0"], 1e-12) + return params["Q"] * params["b"] * math.exp(-params["b"] * peeq) + params["K"] * params[ + "n" + ] * base ** (params["n"] - 1.0) + + # Re-implement the emitted dR structure (CSE temp x0 + guarded division). + def emitted_dr(peeq: float) -> float: + x0 = peeq + params["p0"] + sel = max(x0, 1e-12) if x0 >= 0 else min(x0, -1e-12) + swift = params["K"] * params["n"] * max(x0, 1e-12) ** params["n"] / sel + return swift + params["Q"] * params["b"] * math.exp(-params["b"] * peeq) + + for peeq in (1e-3, 1e-2, 0.1, 0.5, 1.0, 1.5): + assert math.isclose(emitted_dr(peeq), analytic_dr(peeq), rel_tol=1e-10) + + +class TestDeterminism: + def test_emission_is_byte_stable(self) -> None: + assert _emit_swift_voce() == _emit_swift_voce() + + +class TestFailLoud: + def test_unsupported_node_raises_lawgen_error(self) -> None: + # A factor using a function outside the Taichi allow-list (erf) must fail + # loud through the lowerer's collect-all LawgenError — no partial emit. + x = sp.Symbol("p") + a = sp.Symbol("a") + spec = PlasticityCarrierSpec( + name="Bad", + parameters=("a",), + expressions={"R": sp.erf(a * x), "H": sp.Integer(1), "Q": sp.Integer(1)}, + variable_bindings={"p": x, "edot": sp.Symbol("edot"), "T": sp.Symbol("T")}, + ) + with pytest.raises(LawgenError): + emit_carrier(spec, source_hash=_HASH, generated_by=GENERATED_BY) diff --git a/packages/mechdsl-core/tests/lawgen/test_cli.py b/packages/mechdsl-core/tests/lawgen/test_cli.py new file mode 100644 index 0000000..2025a34 --- /dev/null +++ b/packages/mechdsl-core/tests/lawgen/test_cli.py @@ -0,0 +1,359 @@ +"""Integration tests for the ``mechdsl-lawgen compile`` CLI (Task P1-2). + +Covers the three ``test_plan.cases`` from ``dev/plans/mfront_cycleM0/json/P1-2.json``: + +1. dry-run on a minimal fixture YAML prints the expected plan lines and writes + no files (the ``--out`` dir stays empty); +2. a YAML missing a required key exits non-zero with a readable stderr error + (no traceback); +3. ``--help`` / ``compile --help`` advertise the ``compile`` subcommand. + +Tests call ``main([...])`` in-process (fast) and capture stdout/stderr with +``capsys``. Only ``--help`` goes through ``SystemExit`` (argparse convention). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from mechdsl.lawgen.cli import main + +FIXTURES = Path(__file__).parent / "fixtures" +MINIMAL_LAW = FIXTURES / "linear_min.yaml" + + +def _write_law(tmp_path: Path, body: str) -> Path: + law = tmp_path / "law.yaml" + law.write_text(body, encoding="utf-8") + return law + + +# --------------------------------------------------------------------------- +# Case 1 — dry-run prints the emission plan and writes no files. +# --------------------------------------------------------------------------- + + +class TestDryRunEmissionPlan: + @pytest.mark.integration + def test_dryrun_prints_plan_lines(self, capsys: pytest.CaptureFixture[str]) -> None: + rc = main( + ["compile", str(MINIMAL_LAW), "--target", "ticonstit", "--out", "out", "--dry-run"] + ) + assert rc == 0 + out = capsys.readouterr().out + # Header + carrier identity. + assert "emission plan (dry-run" in out + assert "linear_min" in out + # Resolved target identity (contract_id + package). + assert "ticonstit.plasticity_carrier.v1" in out + assert "ticonstit.generated" in out + # Planned output paths under --out (nothing written). + assert "out/plasticity/linear_min.py" in out + assert "_manifest.json" in out + assert "test_linear_min.py" in out + # Every scalar expression to lower is summarised. + assert "R:" in out + assert "H:" in out + assert "Q:" in out + + @pytest.mark.integration + def test_dryrun_writes_no_files( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + out_dir = tmp_path / "out" + out_dir.mkdir() + rc = main( + [ + "compile", + str(MINIMAL_LAW), + "--target", + "ticonstit", + "--out", + str(out_dir), + "--dry-run", + ] + ) + assert rc == 0 + # The --out directory stays empty — dry-run writes nothing. + assert list(out_dir.iterdir()) == [] + + +# --------------------------------------------------------------------------- +# Case 2 — missing required key → non-zero exit, readable stderr, no traceback. +# --------------------------------------------------------------------------- + + +class TestFailureRoutes: + @pytest.mark.integration + def test_missing_required_key_exits_nonzero( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + # Drop the 'expressions' key entirely. + law = _write_law( + tmp_path, + "name: broken\nparameters: [K]\nvariables: [p]\n", + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc == 1 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "expressions" in captured.err + # No traceback leaked to the user. + assert "Traceback" not in captured.err + + @pytest.mark.integration + def test_malformed_yaml_exits_nonzero( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + law = _write_law(tmp_path, "name: broken\n parameters: [K]\n: :\n") + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc == 1 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "Traceback" not in captured.err + + @pytest.mark.integration + def test_missing_file_exits_nonzero( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + rc = main(["compile", str(tmp_path / "nope.yaml"), "--target", "ticonstit", "--dry-run"]) + assert rc == 1 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "not found" in captured.err + assert "Traceback" not in captured.err + + @pytest.mark.integration + def test_bad_expression_exits_nonzero( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + law = _write_law( + tmp_path, + "name: broken\nparameters: [K]\nvariables: [p]\n" + 'expressions:\n R: "K*("\n H: "1"\n Q: "1"\n', + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc == 1 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "Traceback" not in captured.err + + @pytest.mark.integration + def test_unknown_target_exits_nonzero(self, capsys: pytest.CaptureFixture[str]) -> None: + # --target is validated by argparse `choices`, so an unknown value is a + # usage error: SystemExit(2), message on stderr, no Python traceback. + with pytest.raises(SystemExit) as exc_info: + main(["compile", str(MINIMAL_LAW), "--target", "mfront", "--dry-run"]) + assert exc_info.value.code == 2 + captured = capsys.readouterr() + assert "mfront" in captured.err # names the invalid choice + assert "Traceback" not in captured.err + + @pytest.mark.integration + def test_undeclared_symbol_in_expression_exits_nonzero( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + # 'signa0' is a typo for the declared 'sigma0'; a naive parser would + # silently turn it into a stray free symbol. The free-symbol subset + # check must reject it, naming the offending symbol and the role. + law = _write_law( + tmp_path, + "name: typo\nparameters: [sigma0, K, n]\nvariables: [p, edot, T]\n" + 'expressions:\n R: "signa0 + K*p**n"\n H: "1"\n Q: "1"\n', + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc != 0 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "undeclared" in captured.err + assert "signa0" in captured.err # names the offending symbol + assert "'R'" in captured.err # names the role + assert "Traceback" not in captured.err + + # --- F1: no arbitrary code execution via a hostile expression ----------- + + @pytest.mark.integration + def test_code_injection_expression_does_not_execute( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + # CRITICAL (F1): `sympify` would eval this and run os.system, creating + # the sentinel file. The non-eval parser must NOT execute anything — + # assert via a sentinel that would only exist if the payload ran. + sentinel = tmp_path / "PWNED_SENTINEL" + assert not sentinel.exists() + payload = f"__import__('os').system('touch {sentinel}')" + law = _write_law( + tmp_path, + "name: evil\nparameters: [b]\nvariables: [p]\n" + f'expressions:\n R: "{payload}"\n H: "1"\n Q: "1"\n', + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc != 0 + # The payload never executed — no side effect on disk. + assert not sentinel.exists() + captured = capsys.readouterr() + assert "error:" in captured.err + assert "Traceback" not in captured.err + + # --- F2: unknown function calls are rejected ---------------------------- + + @pytest.mark.integration + def test_unknown_function_in_expression_exits_nonzero( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + # 'expp' is a typo for 'exp' (not in the allow-list) → AppliedUndef. + law = _write_law( + tmp_path, + "name: fn\nparameters: [b]\nvariables: [p]\n" + 'expressions:\n R: "expp(b*p)"\n H: "1"\n Q: "1"\n', + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc != 0 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "expp" in captured.err # names the unknown function + assert "Traceback" not in captured.err + + # --- F4: name / identifier validation ----------------------------------- + + @pytest.mark.integration + def test_name_with_path_traversal_rejected( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + law = _write_law( + tmp_path, + 'name: "../../escape"\nparameters: [K]\nvariables: [p]\n' + 'expressions:\n R: "K*p"\n H: "1"\n Q: "1"\n', + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc != 0 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "identifier" in captured.err + assert "Traceback" not in captured.err + + @pytest.mark.integration + def test_name_reserved_word_rejected( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + law = _write_law( + tmp_path, + 'name: "class"\nparameters: [K]\nvariables: [p]\n' + 'expressions:\n R: "K*p"\n H: "1"\n Q: "1"\n', + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc != 0 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "identifier" in captured.err + assert "Traceback" not in captured.err + + @pytest.mark.integration + def test_duplicate_parameter_rejected( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + law = _write_law( + tmp_path, + "name: dup\nparameters: [K, K]\nvariables: [p]\n" + 'expressions:\n R: "K*p"\n H: "1"\n Q: "1"\n', + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc != 0 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "duplicate" in captured.err + assert "Traceback" not in captured.err + + @pytest.mark.integration + def test_parameter_variable_collision_rejected( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + # 'p' declared as both a parameter and a variable. + law = _write_law( + tmp_path, + "name: col\nparameters: [p]\nvariables: [p]\n" + 'expressions:\n R: "p"\n H: "1"\n Q: "1"\n', + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc != 0 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "both a parameter and a variable" in captured.err + assert "Traceback" not in captured.err + + # --- F6: strict keys ---------------------------------------------------- + + @pytest.mark.integration + def test_unknown_top_level_key_rejected( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + law = _write_law( + tmp_path, + "name: t\nparameters: [K]\nvariables: [p]\nbogus: 1\n" + 'expressions:\n R: "K*p"\n H: "1"\n Q: "1"\n', + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc != 0 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "bogus" in captured.err + assert "Traceback" not in captured.err + + @pytest.mark.integration + def test_unknown_expression_role_rejected( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + law = _write_law( + tmp_path, + "name: t\nparameters: [K]\nvariables: [p]\n" + 'expressions:\n R: "K*p"\n H: "1"\n Q: "1"\n QQ: "2"\n', + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + assert rc != 0 + captured = capsys.readouterr() + assert "error:" in captured.err + assert "QQ" in captured.err + assert "Traceback" not in captured.err + + # --- valid expressions with allow-listed functions still parse ---------- + + @pytest.mark.integration + def test_allowed_function_expression_parses( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + # exp() is in the allow-list and a plain integer literal must parse. + law = _write_law( + tmp_path, + "name: voce\nparameters: [sigma0, Q0, b]\nvariables: [p]\n" + 'expressions:\n R: "sigma0 + Q0*(1 - exp(-b*p))"\n H: "1"\n Q: "1"\n', + ) + rc = main(["compile", str(law), "--target", "ticonstit", "--out", "out", "--dry-run"]) + assert rc == 0 + out = capsys.readouterr().out + assert "exp(-b*p)" in out + + +# --------------------------------------------------------------------------- +# Case 3 — --help advertises the compile subcommand. +# --------------------------------------------------------------------------- + + +class TestHelp: + @pytest.mark.integration + def test_top_level_help_lists_compile(self, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + main(["--help"]) + assert exc_info.value.code == 0 + assert "compile" in capsys.readouterr().out + + @pytest.mark.integration + def test_compile_help_lists_options(self, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + main(["compile", "--help"]) + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "--target" in out + assert "--out" in out + assert "--dry-run" in out diff --git a/packages/mechdsl-core/tests/lawgen/test_contracts.py b/packages/mechdsl-core/tests/lawgen/test_contracts.py new file mode 100644 index 0000000..577aaf8 --- /dev/null +++ b/packages/mechdsl-core/tests/lawgen/test_contracts.py @@ -0,0 +1,293 @@ +"""Unit tests for the lawgen emission contracts (Task P1-1). + +Covers the four ``test_plan.cases`` from ``dev/plans/mfront_cycleM0/json/P1-1.json``: + +1. instantiate ``TiconstitTarget`` with all defaults +2. instantiate ``TiconstitTarget`` with overridden budget knobs +3. instantiate ``PlasticityCarrierSpec`` with R/H/Q SymPy expressions +4. contract_id validation rejects a wrong string + +Plus failure-route coverage for immutability and required-field validation. +""" + +from __future__ import annotations + +import dataclasses + +import pytest +import sympy as sp + +from mechdsl.lawgen import ( + TICONSTIT_CONTRACT_ID, + TICONSTIT_PACKAGE, + PlasticityCarrierSpec, + TiconstitTarget, +) + + +def _example_carrier() -> PlasticityCarrierSpec: + """A Voce-style isotropic-hardening carrier used across several tests.""" + p, edot, T = sp.symbols("p edot T") + sigma_y0, K, n = sp.symbols("sigma_y0 K n") + return PlasticityCarrierSpec( + name="voce", + parameters=("sigma_y0", "K", "n"), + expressions={ + "R": sigma_y0 + K * p**n, + "H": K * n * p ** (n - 1), + "Q": sp.Integer(1), + }, + variable_bindings={"p": p, "edot": edot, "T": T}, + ) + + +# --------------------------------------------------------------------------- +# Case 1 — TiconstitTarget defaults. +# --------------------------------------------------------------------------- + + +class TestTiconstitTargetDefaults: + def test_default_identity_fields(self) -> None: + target = TiconstitTarget() + assert target.contract_id == "ticonstit.plasticity_carrier.v1" + assert target.contract_id == TICONSTIT_CONTRACT_ID + assert target.package == "ticonstit.generated" + assert target.package == TICONSTIT_PACKAGE + assert target.ti_type_default == "ti.f64" + + def test_default_budget_knobs_match_p2_2(self) -> None: + target = TiconstitTarget() + assert target.max_expr_ops == 400 + assert target.max_cse_temps_per_func == 96 + assert target.max_func_lines == 220 + assert target.max_total_generated_lines_per_class == 900 + assert target.max_piecewise_branches == 8 + assert target.max_pow_with_symbolic_exponent == 12 + + def test_target_is_immutable(self) -> None: + target = TiconstitTarget() + with pytest.raises(dataclasses.FrozenInstanceError): + target.max_expr_ops = 1 # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Case 2 — TiconstitTarget with overridden budget knobs. +# --------------------------------------------------------------------------- + + +class TestTiconstitTargetOverrides: + def test_overridden_budget_knobs_take_effect(self) -> None: + target = TiconstitTarget( + max_expr_ops=10, + max_cse_temps_per_func=11, + max_func_lines=12, + max_total_generated_lines_per_class=13, + max_piecewise_branches=14, + max_pow_with_symbolic_exponent=15, + ) + assert target.max_expr_ops == 10 + assert target.max_cse_temps_per_func == 11 + assert target.max_func_lines == 12 + assert target.max_total_generated_lines_per_class == 13 + assert target.max_piecewise_branches == 14 + assert target.max_pow_with_symbolic_exponent == 15 + # contract identity is untouched by knob overrides. + assert target.contract_id == TICONSTIT_CONTRACT_ID + + def test_overriding_ti_type_default(self) -> None: + target = TiconstitTarget(ti_type_default="ti.f32") + assert target.ti_type_default == "ti.f32" + + @pytest.mark.parametrize( + "knob", + [ + "max_expr_ops", + "max_cse_temps_per_func", + "max_func_lines", + "max_total_generated_lines_per_class", + "max_piecewise_branches", + "max_pow_with_symbolic_exponent", + ], + ) + def test_nonpositive_budget_knob_rejected(self, knob: str) -> None: + with pytest.raises(ValueError, match=knob): + TiconstitTarget(**{knob: 0}) + + def test_empty_ti_type_default_rejected(self) -> None: + # Empty string now trips the strict non-empty-str check (F5) → TypeError. + with pytest.raises((ValueError, TypeError), match="ti_type_default"): + TiconstitTarget(ti_type_default="") + + +# --------------------------------------------------------------------------- +# Case 3 — PlasticityCarrierSpec with R/H/Q SymPy expressions. +# --------------------------------------------------------------------------- + + +class TestPlasticityCarrierSpec: + def test_holds_name_parameters_expressions_bindings(self) -> None: + spec = _example_carrier() + assert spec.name == "voce" + assert spec.parameters == ("sigma_y0", "K", "n") + assert set(spec.expressions) == {"R", "H", "Q"} + assert set(spec.variable_bindings) == {"p", "edot", "T"} + + def test_rhq_role_accessors_preserve_which_is_which(self) -> None: + spec = _example_carrier() + p = spec.variable_bindings["p"] + sigma_y0, K, n = sp.symbols("sigma_y0 K n") + assert sigma_y0 + K * p**n == spec.R + assert K * n * p ** (n - 1) == spec.H + assert sp.Integer(1) == spec.Q + # accessors and the map agree. + assert spec.R is spec.expressions["R"] + assert spec.H is spec.expressions["H"] + assert spec.Q is spec.expressions["Q"] + + def test_expressions_are_sympy(self) -> None: + spec = _example_carrier() + for expr in spec.expressions.values(): + assert isinstance(expr, sp.Expr) + + def test_parameters_normalised_to_tuple(self) -> None: + p, edot, T = sp.symbols("p edot T") + spec = PlasticityCarrierSpec( + name="lin", + parameters=["sigma_y0", "K"], # list on input + expressions={"R": sp.Symbol("sigma_y0"), "H": sp.Symbol("K"), "Q": sp.Integer(1)}, + variable_bindings={"p": p, "edot": edot, "T": T}, + ) + assert spec.parameters == ("sigma_y0", "K") + assert isinstance(spec.parameters, tuple) + + def test_spec_mappings_are_read_only(self) -> None: + spec = _example_carrier() + with pytest.raises(TypeError): + spec.expressions["R"] = sp.Integer(0) # type: ignore[index] + with pytest.raises(TypeError): + spec.variable_bindings["p"] = sp.Symbol("q") # type: ignore[index] + + def test_spec_is_frozen(self) -> None: + spec = _example_carrier() + with pytest.raises(dataclasses.FrozenInstanceError): + spec.name = "other" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Case 4 — contract_id validation + other required-field failure routes. +# --------------------------------------------------------------------------- + + +class TestValidation: + def test_wrong_contract_id_rejected(self) -> None: + with pytest.raises(ValueError, match="contract_id"): + TiconstitTarget(contract_id="something.else") + + def test_empty_contract_id_rejected(self) -> None: + # Empty string now trips the strict non-empty-str check (F5) → TypeError. + with pytest.raises((ValueError, TypeError), match="contract_id"): + TiconstitTarget(contract_id="") + + def test_empty_package_rejected(self) -> None: + # Empty string now trips the strict non-empty-str check (F5) → TypeError. + with pytest.raises((ValueError, TypeError), match="package"): + TiconstitTarget(package="") + + def test_empty_name_rejected(self) -> None: + p = sp.Symbol("p") + with pytest.raises(ValueError, match="name"): + PlasticityCarrierSpec( + name="", + parameters=("K",), + expressions={"R": sp.Symbol("K"), "H": sp.Integer(0), "Q": sp.Integer(1)}, + variable_bindings={"p": p}, + ) + + def test_empty_parameters_rejected(self) -> None: + p = sp.Symbol("p") + with pytest.raises(ValueError, match="parameter"): + PlasticityCarrierSpec( + name="lin", + parameters=(), + expressions={"R": sp.Integer(0), "H": sp.Integer(0), "Q": sp.Integer(1)}, + variable_bindings={"p": p}, + ) + + def test_bare_string_parameters_rejected(self) -> None: + # A bare str would silently split into per-character names ('K', 'n'). + p = sp.Symbol("p") + with pytest.raises(ValueError, match="not a single str"): + PlasticityCarrierSpec( + name="lin", + parameters="Kn", # type: ignore[arg-type] + expressions={"R": sp.Symbol("K"), "H": sp.Integer(0), "Q": sp.Integer(1)}, + variable_bindings={"p": p}, + ) + + def test_missing_expression_rejected(self) -> None: + p = sp.Symbol("p") + with pytest.raises(ValueError, match="expression"): + PlasticityCarrierSpec( + name="lin", + parameters=("K",), + expressions={"R": sp.Symbol("K"), "H": sp.Integer(0)}, # no Q + variable_bindings={"p": p}, + ) + + def test_empty_variable_bindings_rejected(self) -> None: + with pytest.raises(ValueError, match="variable binding"): + PlasticityCarrierSpec( + name="lin", + parameters=("K",), + expressions={"R": sp.Symbol("K"), "H": sp.Integer(0), "Q": sp.Integer(1)}, + variable_bindings={}, + ) + + +# --------------------------------------------------------------------------- +# F3 — value-type validation on the spec's mappings (strict, closes the +# alias-mutation hole: a mutable non-Expr value cannot be stored). +# --------------------------------------------------------------------------- + + +class TestSpecValueTypeValidation: + def test_non_expr_expression_value_rejected(self) -> None: + # A list value is not an sp.Expr → rejected (also blocks alias mutation). + p = sp.Symbol("p") + with pytest.raises((TypeError, ValueError)): + PlasticityCarrierSpec( + name="lin", + parameters=("K",), + expressions={"R": [], "H": sp.Integer(1), "Q": sp.Integer(1)}, # type: ignore[dict-item] + variable_bindings={"p": p}, + ) + + def test_non_symbol_binding_value_rejected(self) -> None: + with pytest.raises((TypeError, ValueError)): + PlasticityCarrierSpec( + name="lin", + parameters=("K",), + expressions={"R": sp.Symbol("K"), "H": sp.Integer(1), "Q": sp.Integer(1)}, + variable_bindings={"p": "notasym"}, # type: ignore[dict-item] + ) + + +# --------------------------------------------------------------------------- +# F5 — strict scalar types on TiconstitTarget (reject bool/float knobs and +# non-string identity fields). +# --------------------------------------------------------------------------- + + +class TestTargetScalarTypeValidation: + def test_float_budget_knob_rejected(self) -> None: + with pytest.raises((TypeError, ValueError), match="max_expr_ops"): + TiconstitTarget(max_expr_ops=1.5) # type: ignore[arg-type] + + def test_bool_budget_knob_rejected(self) -> None: + # type(True) is bool, not int — must be rejected even though True == 1. + with pytest.raises((TypeError, ValueError), match="max_expr_ops"): + TiconstitTarget(max_expr_ops=True) # type: ignore[arg-type] + + def test_non_string_package_rejected(self) -> None: + with pytest.raises((TypeError, ValueError), match="package"): + TiconstitTarget(package=["x"]) # type: ignore[arg-type] diff --git a/packages/mechdsl-core/tests/lawgen/test_diagnostics.py b/packages/mechdsl-core/tests/lawgen/test_diagnostics.py new file mode 100644 index 0000000..f4c84ec --- /dev/null +++ b/packages/mechdsl-core/tests/lawgen/test_diagnostics.py @@ -0,0 +1,289 @@ +"""Unit tests for the collect-all lawgen diagnostics layer (Task P3-1). + +MFront-mimic Cycle M0, Phase 3 (``dev/plans/mfront_cycleM0.md`` lines 98-100). + +Covers the four ``test_plan.cases``: + +1. Two distinct unsupported nodes → both surface in ONE ``LawgenError`` (no + silent drop) — checked via lowering AND via a raw collector. +2. A budget breach → the diagnostic's ``reason`` names the measured value AND the + limit. +3. No diagnostics → ``DiagnosticCollector.raise_if_any()`` is a no-op. +4. The ``fix`` field is a non-empty, actionable string for every diagnostic type + the lawgen pipeline can emit (unsupported node, non-exhaustive Piecewise, + each of the six budget knobs). + +Plus the surrounding contract: the five required non-empty fields, the aggregate +message/args discoverability, the ``NotImplementedError`` hierarchy, and the +context-manager form. +""" + +from __future__ import annotations + +import pytest +import sympy as sp + +from mechdsl.lawgen.budgets import BudgetChecker +from mechdsl.lawgen.contracts import TiconstitTarget +from mechdsl.lawgen.diagnostics import ( + DiagnosticCollector, + LawgenDiagnostic, + LawgenError, +) +from mechdsl.lawgen.sympy_to_taichi import LoweredExpr, lower_expression + + +def _diag(**overrides: str) -> LawgenDiagnostic: + """Build a complete :class:`LawgenDiagnostic`, overriding any field.""" + fields = { + "law": "R", + "expression": "foo(x)", + "node": "foo", + "reason": "unsupported function 'foo'", + "fix": "register foo in MATH_TO_TAICHI", + } + fields.update(overrides) + return LawgenDiagnostic(**fields) + + +def _lowered(n_temps: int = 0, n_returns: int = 1) -> LoweredExpr: + """A ``LoweredExpr`` with the requested temporary/return line counts.""" + return LoweredExpr( + temporaries=tuple(f"x{i} = 0.0" for i in range(n_temps)), + returns=tuple(f"r{i}" for i in range(n_returns)), + ) + + +# --------------------------------------------------------------------------- +# LawgenDiagnostic — five required, non-empty fields. +# --------------------------------------------------------------------------- + + +def test_diagnostic_has_all_five_fields() -> None: + """A ``LawgenDiagnostic`` exposes law/expression/node/reason/fix (AC1).""" + diag = _diag() + assert diag.law == "R" + assert diag.expression == "foo(x)" + assert diag.node == "foo" + assert diag.reason == "unsupported function 'foo'" + assert diag.fix == "register foo in MATH_TO_TAICHI" + + +@pytest.mark.parametrize("field", ["law", "expression", "node", "reason", "fix"]) +def test_diagnostic_rejects_empty_field(field: str) -> None: + """Every field must be a non-empty string — a blank one is rejected at construction.""" + with pytest.raises(ValueError, match=field): + _diag(**{field: ""}) + with pytest.raises(ValueError, match=field): + _diag(**{field: " "}) # whitespace-only is also empty + + +# --------------------------------------------------------------------------- +# Case 1 — two unsupported nodes → both appear in one LawgenError. +# --------------------------------------------------------------------------- + + +def test_two_unsupported_nodes_both_reported_via_lowering() -> None: + """Two distinct undefined functions → both in one ``LawgenError`` (AC2, no silent drop).""" + x = sp.Symbol("x") + foo = sp.Function("foo") + bar = sp.Function("bar") + + with pytest.raises(LawgenError) as exc: + lower_expression(foo(x) + bar(x)) + + nodes = sorted(d.node for d in exc.value.diagnostics) + assert nodes == ["bar", "foo"] + # Both appear in the message AND in .args (the P3-1 acceptance surface). + message = str(exc.value) + assert "foo" in message and "bar" in message + arg_text = " ".join(str(a) for a in exc.value.args) + assert "foo" in arg_text and "bar" in arg_text + + +def test_defined_and_undefined_unsupported_both_reported() -> None: + """A defined-but-unmapped func (``erf``) AND an undefined one (``foo``) both surface.""" + x = sp.Symbol("x") + foo = sp.Function("foo") + + with pytest.raises(LawgenError) as exc: + lower_expression(sp.erf(x) + foo(x)) + + assert sorted(d.node for d in exc.value.diagnostics) == ["erf", "foo"] + + +def test_collector_accumulates_two_diagnostics() -> None: + """A raw ``DiagnosticCollector`` surfaces every added diagnostic in one error.""" + collector = DiagnosticCollector() + collector.add(_diag(node="foo")) + collector.add(_diag(node="bar", expression="bar(x)", reason="unsupported 'bar'")) + + with pytest.raises(LawgenError) as exc: + collector.raise_if_any() + + assert sorted(d.node for d in exc.value.diagnostics) == ["bar", "foo"] + assert len(exc.value.diagnostics) == 2 + + +# --------------------------------------------------------------------------- +# Case 2 — budget breach reason contains measured value AND limit. +# --------------------------------------------------------------------------- + + +def test_budget_breach_reason_has_measured_and_limit() -> None: + """A budget-breach diagnostic's ``reason`` names both the measured value and the limit (AC3).""" + x = sp.Symbol("x") + checker = BudgetChecker(TiconstitTarget(max_expr_ops=2)) + + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": x**2 + 2 * x + 1}, {"R": _lowered()}) # 4 ops > 2 + + (diag,) = exc.value.diagnostics + assert diag.node == "max_expr_ops" + assert "4" in diag.reason # measured + assert "2" in diag.reason # limit + + +def test_multiple_budget_breaches_collect_all() -> None: + """Two knobs over budget → two diagnostics in one ``LawgenError`` (collect-all, not fail-first).""" + x, n = sp.symbols("x n") + # Trips max_expr_ops (many ops) AND max_pow_with_symbolic_exponent (2 sym-pows > 1). + checker = BudgetChecker(TiconstitTarget(max_expr_ops=2, max_pow_with_symbolic_exponent=1)) + + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": x**n + (x + 1) ** n + 2 * x + 1}, {"R": _lowered()}) + + knobs = sorted(d.node for d in exc.value.diagnostics) + assert "max_expr_ops" in knobs + assert "max_pow_with_symbolic_exponent" in knobs + + +# --------------------------------------------------------------------------- +# Case 3 — no diagnostics → raise_if_any() is a no-op. +# --------------------------------------------------------------------------- + + +def test_empty_collector_raise_if_any_is_noop() -> None: + """An empty collector's ``raise_if_any()`` returns cleanly (AC: no-op) — the only silent path.""" + collector = DiagnosticCollector() + assert collector.raise_if_any() is None + assert not collector # __bool__ is False when empty + assert len(collector) == 0 + + +def test_compliant_law_lowers_without_error() -> None: + """A clean expression lowers with no ``LawgenError`` (no false-positive diagnostics).""" + sigma0, Q, b, p = sp.symbols("sigma0 Q b p") + result = lower_expression(sigma0 + Q * (1 - sp.exp(-b * p))) + assert result.returns # emitted cleanly + + +def test_lawgen_error_requires_at_least_one_diagnostic() -> None: + """Constructing a ``LawgenError`` with no diagnostics is itself an error (never raise nothing).""" + with pytest.raises(ValueError, match="at least one diagnostic"): + LawgenError([]) + + +# --------------------------------------------------------------------------- +# Case 4 — fix is non-empty for every diagnostic type the pipeline emits. +# --------------------------------------------------------------------------- + + +def test_fix_non_empty_for_unsupported_node() -> None: + """The unsupported-node diagnostic carries an actionable, non-empty ``fix`` (AC1/AC4).""" + x = sp.Symbol("x") + with pytest.raises(LawgenError) as exc: + lower_expression(sp.Function("foo")(x)) + (diag,) = exc.value.diagnostics + assert diag.fix.strip() + assert "foo" in diag.fix # actionable: names the offending node + + +def test_fix_non_empty_for_non_exhaustive_piecewise() -> None: + """The non-exhaustive-Piecewise diagnostic carries a non-empty ``fix``.""" + x = sp.Symbol("x") + with pytest.raises(LawgenError) as exc: + lower_expression(sp.Piecewise((x, x > 0))) # no True default branch + piecewise_diags = [d for d in exc.value.diagnostics if d.node == "Piecewise"] + assert piecewise_diags + for diag in piecewise_diags: + assert diag.fix.strip() + assert "True" in diag.fix # actionable: add a (value, True) default + + +def test_fix_non_empty_for_every_budget_knob() -> None: + """Every one of the six budget knobs yields a diagnostic with a non-empty ``fix`` (AC4).""" + x, y, n = sp.symbols("x y n") + # A single target with every knob lowered so ALL six trip at once, proving the + # collect-all path emits a fix for each knob type. + target = TiconstitTarget( + max_expr_ops=1, + max_cse_temps_per_func=1, + max_func_lines=1, + max_total_generated_lines_per_class=1, + max_piecewise_branches=1, + max_pow_with_symbolic_exponent=1, + ) + checker = BudgetChecker(target) + # Expression trips: expr_ops, piecewise_branches, pow_symbolic_exponent. + piece = sp.Piecewise((x**n, x > 0), (y**n, x < 0), (sp.Integer(0), True)) + # Lowered trips: cse_temps, func_lines, total_generated_lines. + lowered = _lowered(n_temps=3, n_returns=2) + + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": piece}, {"R": lowered}) + + knobs = {d.node for d in exc.value.diagnostics} + assert knobs == { + "max_expr_ops", + "max_cse_temps_per_func", + "max_func_lines", + "max_total_generated_lines_per_class", + "max_piecewise_branches", + "max_pow_with_symbolic_exponent", + } + for diag in exc.value.diagnostics: + assert diag.fix.strip(), f"empty fix for {diag.node}" + assert diag.node in diag.reason # reason names the knob + # Reason carries measured + limit (both integers appear). + assert any(ch.isdigit() for ch in diag.reason) + + +# --------------------------------------------------------------------------- +# LawgenError hierarchy + DiagnosticCollector context-manager form. +# --------------------------------------------------------------------------- + + +def test_lawgen_error_is_a_not_implemented_error() -> None: + """``LawgenError`` IS-A ``NotImplementedError`` — Phase-2 fail-loud catchers still work.""" + assert issubclass(LawgenError, NotImplementedError) + x = sp.Symbol("x") + with pytest.raises(NotImplementedError): # the Phase-2 contract + lower_expression(sp.Function("foo")(x)) + + +def test_collector_context_manager_raises_on_clean_exit() -> None: + """The context-manager form raises collected diagnostics on a clean block exit.""" + with pytest.raises(LawgenError) as exc, DiagnosticCollector() as collector: + collector.add(_diag(node="foo")) + assert exc.value.diagnostics[0].node == "foo" + + +def test_collector_context_manager_noop_when_empty() -> None: + """The context-manager form is a no-op when nothing was collected.""" + with DiagnosticCollector() as collector: + assert not collector # nothing added + + +def test_collector_context_manager_does_not_swallow_body_exception() -> None: + """A real exception in the ``with`` body propagates untouched (no swallow to raise an aggregate).""" + with pytest.raises(RuntimeError, match="boom"), DiagnosticCollector() as collector: + collector.add(_diag()) # even with a pending diagnostic ... + raise RuntimeError("boom") # ... the real error wins + + +def test_extend_collects_multiple() -> None: + """``extend`` records several diagnostics in order.""" + collector = DiagnosticCollector() + collector.extend([_diag(node="foo"), _diag(node="bar")]) + assert [d.node for d in collector] == ["foo", "bar"] diff --git a/packages/mechdsl-core/tests/lawgen/test_guard_injection.py b/packages/mechdsl-core/tests/lawgen/test_guard_injection.py new file mode 100644 index 0000000..b77dcf1 --- /dev/null +++ b/packages/mechdsl-core/tests/lawgen/test_guard_injection.py @@ -0,0 +1,402 @@ +"""Unit tests for numerical-guard injection (Task P2-3). + +MFront-mimic Cycle M0, Phase 2 (``dev/plans/mfront_cycleM0.md`` lines 83-86). +This is the phase's key correctness task (plan risk R2): the guards emitted by +:func:`mechdsl.lawgen.sympy_to_taichi.lower_expression` must reproduce the +hand-authored guards in Cycle 0's ``swift_voce.py`` so the P4-2 numerical +equivalence gate (``rtol=1e-10``) holds. + +Covers the five ``test_plan.cases``: + +1. ``pow(x, alpha)`` (symbolic alpha) → base floored ``ti.max(x, 1e-12)``. +2. ``log(x)`` → ``ti.log(ti.max(x, 1e-12))``. +3. ``sqrt(x)`` → ``ti.sqrt(ti.max(x, 1e-12))``. +4. ``1/x`` → denominator guarded (sign-preserving; Gate-B Finding 1). +5. GOLDEN: the SwiftVoce ``R`` expression reproduces ``swift_voce.py``'s + ``get_R`` guard structure — floored Swift ``pow`` bases via ``ti.pow`` AND an + **unguarded** ``exp``. + +Plus the #1-risk regression test: ``exp`` is NOT guarded. + +The golden patterns are encoded as **string literals** here, transcribed from +``NumerixWeave/libs/ticonstit/src/ticonstit/generated/plasticity/swift_voce.py`` +(Cycle 0, hand-authored) ``get_R``. The NumerixWeave file is deliberately NOT +read at test time — it lives in a separate repo and MechDSL CI must be +self-contained (plan risk R3). The reference idioms, from that file's +``get_R``:: + + base = ti.max(peeq + self.p0, self._POW_FLOOR) # _POW_FLOOR = 1e-12 + p0_base = ti.max(self.p0, self._POW_FLOOR) + return ... self.Qsat * (1.0 - ti.exp(-self.b * peeq)) # exp UNGUARDED + + self.K * (ti.pow(base, self.n) - ti.pow(p0_base, self.n)) +""" + +from __future__ import annotations + +import sympy as sp + +from mechdsl.lawgen.guard_transforms import GUARD_FLOOR, GUARD_FLOOR_LITERAL +from mechdsl.lawgen.sympy_to_taichi import ( + TaichiExprPrinter, + TaichiGuardedPrinter, + lower_expression, +) + + +def _lower_one(expr: sp.Expr) -> str: + """Lower a single expression (guards on) and return its one return line.""" + result = lower_expression(expr) + assert len(result.returns) == 1 + return result.returns[0] + + +# --------------------------------------------------------------------------- +# Case 1 — pow with a symbolic exponent floors the base (the "safe pattern"). +# --------------------------------------------------------------------------- + + +def test_pow_symbolic_exponent_floors_base() -> None: + """``x**alpha`` (symbolic exp) → ``ti.pow(ti.max(x, 1e-12), alpha)`` (AC1). + + The base is floored with ``ti.max(·, 1e-12)`` — the equivalent safe pattern + ``swift_voce.py`` ``get_R``/``get_dR`` use (a ``ti.max`` base-floor, not a + ``ti.select`` gate), and the floored base is emitted through ``ti.pow``. + """ + x, alpha = sp.symbols("x alpha") + emitted = _lower_one(x**alpha) + + assert emitted == "ti.pow(ti.max(x, 1e-12), alpha)" + # The safe pattern: the base is floored inside the ti.pow. + assert "ti.max(x, 1e-12)" in emitted + assert emitted.startswith("ti.pow(") + + +def test_pow_fractional_constant_exponent_floors_base() -> None: + """A non-integer *constant* exponent (``x**(1/3)``) also floors the base. + + ``1/3`` is not an integer, so the base-floor rule applies exactly as for a + symbolic exponent (only integer powers are exempt). + """ + x = sp.Symbol("x") + emitted = _lower_one(x ** sp.Rational(1, 3)) + + assert "ti.pow(ti.max(x, 1e-12)," in emitted + + +def test_pow_integer_exponent_is_not_floored() -> None: + """A positive integer power is left un-floored (AC1 boundary). + + Integer powers are exact, so the guard pass adds no ``ti.max`` floor. + Since P2-4, a *small* integer power is additionally inlined to repeated + multiplication (``x**3`` → ``x*x*x``); the invariant this test guards is the + absence of any domain floor, not the ``**`` spelling. + """ + x = sp.Symbol("x") + emitted = _lower_one(x**3) + + assert emitted == "x*x*x" + assert "ti.max" not in emitted + assert "ti.pow" not in emitted + + +# --------------------------------------------------------------------------- +# Case 2 — log argument is domain-floored. +# --------------------------------------------------------------------------- + + +def test_log_argument_wrapped_with_ti_max() -> None: + """``log(x)`` → ``ti.log(ti.max(x, 1e-12))`` (AC2).""" + x = sp.Symbol("x") + emitted = _lower_one(sp.log(x)) + + assert emitted == "ti.log(ti.max(x, 1e-12))" + assert "ti.max(x, 1e-12)" in emitted + + +# --------------------------------------------------------------------------- +# Case 3 — sqrt argument is domain-floored. +# --------------------------------------------------------------------------- + + +def test_sqrt_argument_wrapped_with_ti_max() -> None: + """``sqrt(x)`` → ``ti.sqrt(ti.max(x, 1e-12))`` (AC2).""" + x = sp.Symbol("x") + emitted = _lower_one(sp.sqrt(x)) + + assert emitted == "ti.sqrt(ti.max(x, 1e-12))" + assert "ti.max(x, 1e-12)" in emitted + + +# --------------------------------------------------------------------------- +# Case 4 — division denominators are guarded (SIGN-PRESERVING; Gate-B Finding 1). +# --------------------------------------------------------------------------- +# +# The denominator guard must PRESERVE the denominator's sign. A plain +# ``ti.max(ti.abs(b), 1e-12)`` returns ``|b|`` and flips the sign of ``a/b`` for +# a runtime-negative ``b`` (``a/|b|`` != ``a/b``) — silently wrong (R2). The +# sign-preserving form keeps ``b``'s sign and floors only its magnitude to +# 1e-12; it is a no-op for ``|b| >= 1e-12`` (returns ``b``), and returns +# ``+1e-12`` / ``-1e-12`` for a near-zero positive / negative ``b``. +_SIGNED_FLOOR_X = "ti.select(x >= 0, ti.max(x, 1e-12), ti.min(x, -1e-12))" +_SIGNED_FLOOR_B = "ti.select(b >= 0, ti.max(b, 1e-12), ti.min(b, -1e-12))" + + +def test_reciprocal_denominator_sign_preserving_guard() -> None: + """``1/x`` guards the denominator with the sign-preserving floor (AC3). + + ``1/ti.select(x >= 0, ti.max(x, 1e-12), ti.min(x, -1e-12))`` — a no-op for + ``|x| >= 1e-12``, so ``1/x`` is unchanged in the normal range; only a + denominator within 1e-12 of zero is clamped, keeping its sign. + """ + x = sp.Symbol("x") + emitted = _lower_one(1 / x) + + assert emitted == f"1/{_SIGNED_FLOOR_X}" + # The naive abs-floor (sign-losing) form must NOT be emitted. + assert "ti.abs(x)" not in emitted + + +def test_division_denominator_sign_preserving_guard() -> None: + """``a/b`` guards the denominator (sign-preserving), not the numerator (AC3).""" + a, b = sp.symbols("a b") + emitted = _lower_one(a / b) + + assert emitted == f"a/{_SIGNED_FLOOR_B}" + # The numerator ``a`` is not wrapped; only the denominator is guarded. + assert "ti.select(a" not in emitted + # No sign-losing abs floor anywhere. + assert "ti.abs(b)" not in emitted + + +def test_division_by_constant_is_not_guarded() -> None: + """Division by a compile-time constant is left bare (matches ``/edot0``). + + ``swift_voce.py``'s ``get_dH`` divides by the nonzero *parameter* ``edot0`` + with no runtime guard; only a possibly-zero variable denominator is guarded. + A pure numeric denominator (here ``2``) needs no floor. + """ + x = sp.Symbol("x") + emitted = _lower_one(x / 2) + + assert "ti.max" not in emitted + assert "ti.select" not in emitted + assert "ti.abs" not in emitted + + +def test_negative_integer_exponent_is_reciprocal_guard() -> None: + """``x**-2`` is a genuine reciprocal → sign-preserving denominator guard. + + ``x**-2 = 1/x**2``: a negative *integer* exponent is division, so its base is + wrapped in the sign-preserving floor (then raised to the positive power). + Distinct from a negative *fractional* exponent (see the base-floor test). + """ + x = sp.Symbol("x") + emitted = _lower_one(x**-2) + + assert _SIGNED_FLOOR_X in emitted + # It is a power of the guarded base, not a ti.pow base-floor. + assert "ti.pow(ti.max(x" not in emitted + + +def test_negative_fractional_exponent_floors_base_not_denominator() -> None: + """``x**(-3/10)`` is a fractional power → base-floor via ``ti.pow`` (Finding 2). + + A negative *non-integer* exponent is still a fractional power whose base must + be positive; it must NOT be treated as a division (no sign-preserving + denominator guard). It base-floors like any other non-integer power: + ``ti.pow(ti.max(x, 1e-12), -3/10)``. + """ + x = sp.Symbol("x") + emitted = _lower_one(x ** sp.Rational(-3, 10)) + + assert emitted == "ti.pow(ti.max(x, 1e-12), -3/10)" + # Not routed to the reciprocal / sign-preserving denominator guard. + assert "ti.select" not in emitted + + +def test_negative_symbolic_exponent_floors_base() -> None: + """``x**(-alpha)`` (negative symbolic exp) also base-floors via ``ti.pow``. + + A symbolic exponent is non-integer as far as the rewrite can tell, so it + floors the base regardless of any leading minus sign — never a division. + """ + x, alpha = sp.symbols("x alpha") + emitted = _lower_one(x ** (-alpha)) + + assert emitted == "ti.pow(ti.max(x, 1e-12), -alpha)" + assert "ti.select" not in emitted + + +# --------------------------------------------------------------------------- +# The #1 risk — exp must NOT be guarded (regression test). +# --------------------------------------------------------------------------- + + +def test_exp_is_not_guarded() -> None: + """``exp(-b*peeq)`` emits a bare ``ti.exp`` — NO ``ti.max`` around its arg. + + This is the phase's #1 failure mode: over-guarding ``exp`` would diverge + from ``swift_voce.py`` (whose ``get_R`` uses a bare ``ti.exp(-self.b*peeq)``, + matching the Voce idiom on the physical domain ``peeq >= 0``) and break the + P4-2 equivalence gate. + """ + b, peeq = sp.symbols("b peeq") + emitted = _lower_one(sp.exp(-b * peeq)) + + assert emitted == "ti.exp(-b*peeq)" + # No domain floor anywhere around the exp argument. + assert "ti.max" not in emitted + + +def test_exp_inside_larger_expression_stays_unguarded() -> None: + """``exp`` stays unguarded even when floored ``pow`` terms sit beside it. + + Guarding one construct must not accidentally wrap a neighbouring ``exp``. + """ + b, peeq, K, p0, n = sp.symbols("b peeq K p0 n") + emitted = _lower_one(sp.exp(-b * peeq) + K * (peeq + p0) ** n) + + assert "ti.exp(-b*peeq)" in emitted + # The exp argument is not floored ... + assert "ti.max(-b*peeq" not in emitted + assert "ti.max(peeq" not in emitted # exp's arg is -b*peeq, not peeq + # ... but the Swift pow base beside it IS floored. + assert "ti.pow(ti.max(p0 + peeq, 1e-12), n)" in emitted + + +# --------------------------------------------------------------------------- +# Case 5 — GOLDEN: SwiftVoce R reproduces swift_voce.py get_R guard structure. +# --------------------------------------------------------------------------- + + +def test_golden_swift_voce_R_guard_structure() -> None: + """Lowering SwiftVoce ``R`` reproduces ``swift_voce.py`` ``get_R`` guards (AC4). + + ``R = sigma0 + Qsat*(1 - exp(-b*peeq)) + K*((peeq+p0)**n - p0**n)``. + + The expected guard structure is transcribed as string literals from Cycle 0 + ``swift_voce.py`` ``get_R`` (see the module docstring) — the NumerixWeave + file is NOT read here (R3). Asserted structure: + + * Swift base ``(peeq+p0)**n`` → ``ti.pow(ti.max(p0 + peeq, 1e-12), n)``. + * Swift base ``p0**n`` → ``ti.pow(ti.max(p0, 1e-12), n)``. + * ``exp(-b*peeq)`` → bare ``ti.exp(-b*peeq)`` (UNGUARDED). + """ + sigma0, Qsat, b, peeq, K, p0, n = sp.symbols("sigma0 Qsat b peeq K p0 n") + R = sigma0 + Qsat * (1 - sp.exp(-b * peeq)) + K * ((peeq + p0) ** n - p0**n) + + result = lower_expression(R) + assert len(result.returns) == 1 + emitted = result.returns[0] + + # --- Swift pow bases: floored with ti.max(·, 1e-12) then ti.pow ---------- + # Golden get_R: base = ti.max(peeq + self.p0, 1e-12); ti.pow(base, self.n). + assert "ti.pow(ti.max(p0 + peeq, 1e-12), n)" in emitted + # Golden get_R: p0_base = ti.max(self.p0, 1e-12); ti.pow(p0_base, self.n). + assert "ti.pow(ti.max(p0, 1e-12), n)" in emitted + + # --- exp is UNGUARDED (the #1 risk) -------------------------------------- + # Golden get_R: self.Qsat * (1.0 - ti.exp(-self.b * peeq)) — bare ti.exp. + assert "ti.exp(-b*peeq)" in emitted + # There must be NO ti.max wrapping the exp argument anywhere. + assert "ti.max(-b*peeq, 1e-12)" not in emitted + + # --- No un-guarded pow/sqrt/log leaked into the output ------------------- + # Every ``**`` with a symbolic exponent must have gone through ti.pow; the + # only ``**`` that could remain would be an integer power (none here). + assert "peeq)**n" not in emitted + assert "p0**n" not in emitted + + +def test_golden_swift_voce_dR_guard_structure() -> None: + """SwiftVoce ``dR`` reproduces ``get_dR``: floored ``(peeq+p0)**(n-1)``, bare exp. + + ``dR = Qsat*b*exp(-b*peeq) + K*n*(peeq+p0)**(n-1)``. + Golden get_dR: ``base = ti.max(peeq + self.p0, 1e-12)``; + ``K * self.n * ti.pow(base, self.n - 1.0)`` with the exp term unguarded. + """ + Qsat, b, peeq, K, p0, n = sp.symbols("Qsat b peeq K p0 n") + dR = Qsat * b * sp.exp(-b * peeq) + K * n * (peeq + p0) ** (n - 1) + + emitted = lower_expression(dR).returns[0] + + assert "ti.pow(ti.max(p0 + peeq, 1e-12), n - 1)" in emitted + assert "ti.exp(-b*peeq)" in emitted + assert "peeq)**(n - 1)" not in emitted + + +# --------------------------------------------------------------------------- +# Flag / determinism / raw-path contract. +# --------------------------------------------------------------------------- + + +def test_guards_off_leaves_expression_raw() -> None: + """``guards=False`` bypasses injection — the raw P2-1 output is unchanged. + + The raw path emits ``**`` for a symbolic power and never introduces a + ``ti.max`` floor; this is the escape hatch P2-1 tests and downstream + guard-elsewhere callers rely on. + """ + x, alpha = sp.symbols("x alpha") + raw = lower_expression(x**alpha, guards=False).returns[0] + + assert raw == "x**alpha" + assert "ti.max" not in raw + + +def test_guards_default_is_on() -> None: + """The default is guards-on: a symbolic power is floored without the flag.""" + x, alpha = sp.symbols("x alpha") + + assert lower_expression(x**alpha).returns[0] == "ti.pow(ti.max(x, 1e-12), alpha)" + + +def test_guarded_lowering_is_deterministic() -> None: + """Guard injection preserves byte-for-byte determinism across repeat calls.""" + sigma0, Qsat, b, peeq, K, p0, n = sp.symbols("sigma0 Qsat b peeq K p0 n") + R = sigma0 + Qsat * (1 - sp.exp(-b * peeq)) + K * ((peeq + p0) ** n - p0**n) + + first = lower_expression(R) + second = lower_expression(R) + + assert first == second + + +def test_guard_floor_literal_and_value_agree() -> None: + """The emitted literal string and the numeric floor constant match ``1e-12``. + + ``GUARD_FLOOR_LITERAL`` is emitted verbatim (so the text is exactly + ``1e-12``, not the ``1.0e-12`` a ``sympy.Float`` would print); ``GUARD_FLOOR`` + is the numeric value for callers/tests reasoning about magnitude. + """ + assert GUARD_FLOOR_LITERAL == "1e-12" + assert GUARD_FLOOR == 1e-12 + assert float(GUARD_FLOOR_LITERAL) == GUARD_FLOOR + + +def test_guarded_printer_renders_markers_directly() -> None: + """The guarded printer renders the marker nodes; the raw printer rejects them. + + Confirms the two printers are a matched pair: guarded expressions must be + printed with :class:`TaichiGuardedPrinter`, and the raw + :class:`TaichiExprPrinter` fails loud (R2) rather than silently mis-emitting + an injected marker. + """ + from mechdsl.lawgen.guard_transforms import GuardFloor, GuardSignedFloor + + x = sp.Symbol("x") + guarded = TaichiGuardedPrinter() + + assert guarded.doprint(GuardFloor(x)) == "ti.max(x, 1e-12)" + assert ( + guarded.doprint(GuardSignedFloor(x)) + == "ti.select(x >= 0, ti.max(x, 1e-12), ti.min(x, -1e-12))" + ) + + # The raw printer has no marker rendering and must fail loud on either. + import pytest + + with pytest.raises(NotImplementedError, match="GuardFloor"): + TaichiExprPrinter().doprint(GuardFloor(x)) + with pytest.raises(NotImplementedError, match="GuardSignedFloor"): + TaichiExprPrinter().doprint(GuardSignedFloor(x)) diff --git a/packages/mechdsl-core/tests/lawgen/test_lowering_table.py b/packages/mechdsl-core/tests/lawgen/test_lowering_table.py new file mode 100644 index 0000000..09e6e24 --- /dev/null +++ b/packages/mechdsl-core/tests/lawgen/test_lowering_table.py @@ -0,0 +1,405 @@ +"""Unit tests for the Taichi-safe lowering table + source hash (Task P2-4). + +MFront-mimic Cycle M0, Phase 2 (``dev/plans/mfront_cycleM0.md`` lines 87-90). + +Covers the six ``test_plan.cases`` of P2-4: + +1. ``sp.exp(x)`` → ``ti.exp(x)`` in the lowered output (no bare ``exp(``). +2. A 3-branch ``Piecewise`` → a right-nested ``ti.select`` chain. +3. A 9-branch ``Piecewise`` → :class:`BudgetError` (default limit 8). +4. ``Pow(x, 2)`` → inlined ``x*x`` (never ``ti.pow``). +5. Lowering the same input twice → identical ``source_hash``. +6. ``source_hash`` is a 64-char lowercase hex string. + +Plus the surrounding contract: the small-int-Pow threshold boundary, the +Piecewise budget boundary/override, guards landing inside branch expressions, +and the documented hash input (``"\\n".join(temporaries + returns)``). +""" + +from __future__ import annotations + +import re + +import pytest +import sympy as sp + +from mechdsl.lawgen.contracts import TiconstitTarget +from mechdsl.lawgen.diagnostics import LawgenError +from mechdsl.lawgen.sympy_to_taichi import ( + SMALL_INT_POW_LIMIT, + LoweredExpr, + compute_source_hash, + lower_expression, +) + +_HEX64 = re.compile(r"^[0-9a-f]{64}$") + + +def _lower_one(expr: sp.Expr, **kwargs: object) -> str: + """Lower a single expression and return its one return line.""" + result = lower_expression(expr, **kwargs) # type: ignore[arg-type] + assert len(result.returns) == 1 + return result.returns[0] + + +# --------------------------------------------------------------------------- +# Case 1 — exp → ti.exp. +# --------------------------------------------------------------------------- + + +def test_exp_lowers_to_ti_exp() -> None: + """``sp.exp(x)`` lowers to ``ti.exp(x)`` — no bare ``exp(`` leaks (AC1).""" + x = sp.Symbol("x") + emitted = _lower_one(sp.exp(x)) + + assert emitted == "ti.exp(x)" + assert "ti.exp(x)" in emitted + # The only ``exp(`` occurrence is the ``ti.exp`` call — no bare ``exp(`` leaks. + assert emitted.replace("ti.exp(", "") == "x)" + + +# --------------------------------------------------------------------------- +# Case 2 — Piecewise (within budget) → nested ti.select. +# --------------------------------------------------------------------------- + + +def test_piecewise_three_branches_nested_select() -> None: + """A 3-branch ``Piecewise`` → a right-nested ``ti.select`` chain (AC2). + + ``Piecewise((x, x>0), (y, x<0), (0, True))`` → + ``ti.select(x > 0, x, ti.select(x < 0, y, 0))``: two nested selects, the + default (``0``) as the innermost else-value, conditions printed as scalar + comparisons. + """ + x, y = sp.symbols("x y") + piece = sp.Piecewise((x, x > 0), (y, x < 0), (0, True)) + emitted = _lower_one(piece) + + assert emitted == "ti.select(x > 0, x, ti.select(x < 0, y, 0))" + # Right-nesting structure: exactly one nested select for three branches. + assert emitted.count("ti.select") == 2 + # The default branch value is the innermost argument. + assert emitted.endswith(", 0))") + + +def test_piecewise_two_branches_single_select() -> None: + """A 2-branch ``Piecewise`` collapses to a single ``ti.select`` (AC2).""" + x = sp.Symbol("x") + piece = sp.Piecewise((x, x > 0), (0, True)) + emitted = _lower_one(piece) + + assert emitted == "ti.select(x > 0, x, 0)" + assert emitted.count("ti.select") == 1 + + +def test_piecewise_branch_guards_are_injected() -> None: + """Guards (P2-3) land *inside* branch expressions of a lowered Piecewise. + + ``log(x)`` in a branch must be domain-floored exactly as it would be outside + a ``Piecewise`` — the guard pass recurses into branch values. + """ + x = sp.Symbol("x") + piece = sp.Piecewise((sp.log(x), x > 0), (0, True)) + emitted = _lower_one(piece) + + assert emitted == "ti.select(x > 0, ti.log(ti.max(x, 1e-12)), 0)" + + +def test_non_exhaustive_piecewise_fails_loud() -> None: + """A ``Piecewise`` with no ``True`` default branch fails loud (R2). + + ``ti.select`` has no undefined result, so a missing default would silently + emit a wrong fallthrough; the printer rejects it instead. + """ + x = sp.Symbol("x") + piece = sp.Piecewise((x, x > 0)) + with pytest.raises(NotImplementedError, match="non-exhaustive Piecewise"): + lower_expression(piece) + + +# --------------------------------------------------------------------------- +# Case 3 — Piecewise over budget → BudgetError. +# --------------------------------------------------------------------------- + + +def _piecewise_with_branches(n: int) -> sp.Piecewise: + """Build a ``Piecewise`` with exactly ``n`` branches (last one the default).""" + a = sp.Symbol("a") + pairs = [(sp.Integer(i), a > i) for i in range(n - 1)] + pairs.append((sp.Integer(99), sp.true)) + return sp.Piecewise(*pairs) + + +def test_piecewise_nine_branches_raises_budget_error() -> None: + """A 9-branch ``Piecewise`` exceeds the default budget (8) → ``LawgenError`` (AC3). + + P3-1 collect-all: the over-budget switch now surfaces as a budget diagnostic + inside a :class:`LawgenError` (a ``NotImplementedError`` subclass), whose + ``reason`` names the budget knob, the measured branch count (9), and the + limit (8), and is raised *before* any ``ti.select`` is emitted. + """ + piece = _piecewise_with_branches(9) + assert len(piece.args) == 9 + + with pytest.raises(LawgenError) as exc: + lower_expression(piece) + + (diag,) = exc.value.diagnostics + assert diag.node == "max_piecewise_branches" + assert "max_piecewise_branches budget exceeded: 9 > 8" in diag.reason + assert "9" in diag.reason and "8" in diag.reason # measured + limit + assert diag.fix.strip() + + +def test_piecewise_at_budget_limit_lowers() -> None: + """An 8-branch ``Piecewise`` is exactly at the default budget → lowers (AC2/AC3).""" + piece = _piecewise_with_branches(8) + assert len(piece.args) == 8 + + emitted = _lower_one(piece) + # 8 branches → 7 nested selects. + assert emitted.count("ti.select") == 7 + + +def test_piecewise_budget_override_via_target() -> None: + """A custom ``TiconstitTarget`` lowers the branch budget (wiring check). + + A 4-branch ``Piecewise`` passes the default (8) but fails a target with + ``max_piecewise_branches=3`` — proving ``lower_expression`` threads the + target down to the gate rather than hard-coding the limit. + """ + piece = _piecewise_with_branches(4) + strict = TiconstitTarget(max_piecewise_branches=3) + + # Default target: fine. + assert lower_expression(piece).returns + # Strict target: over budget → collect-all LawgenError with the budget diagnostic. + with pytest.raises(LawgenError) as exc: + lower_expression(piece, target=strict) + (diag,) = exc.value.diagnostics + assert diag.node == "max_piecewise_branches" + assert "max_piecewise_branches budget exceeded: 4 > 3" in diag.reason + + +# --------------------------------------------------------------------------- +# Case 4 — small-int Pow → multiplication (not ti.pow). +# --------------------------------------------------------------------------- + + +def test_pow_two_inlines_to_multiplication() -> None: + """``Pow(x, 2)`` → ``x*x`` in the emitted output, never ``ti.pow`` (AC4).""" + x = sp.Symbol("x") + emitted = _lower_one(x**2) + + assert emitted == "x*x" + assert "ti.pow" not in emitted + assert "**" not in emitted + + +def test_pow_three_inlines_to_triple_product() -> None: + """``Pow(x, 3)`` → ``x*x*x`` (repeated multiplication).""" + x = sp.Symbol("x") + assert _lower_one(x**3) == "x*x*x" + + +def test_pow_one_and_zero_fold_to_identities() -> None: + """``x**1`` → ``x`` and ``x**0`` → ``1`` (trivial-power folding).""" + x = sp.Symbol("x") + assert _lower_one(sp.Pow(x, 1, evaluate=False)) == "x" + assert _lower_one(sp.Pow(x, 0, evaluate=False)) == "1" + + +def test_pow_compound_base_is_parenthesised() -> None: + """``(x + y)**2`` inlines to ``(x + y)*(x + y)`` — the base is grouped.""" + x, y = sp.symbols("x y") + assert _lower_one((x + y) ** 2, guards=False) == "(x + y)*(x + y)" + + +def test_pow_at_threshold_inlines_above_threshold_keeps_pow_spelling() -> None: + """The small-int threshold boundary: ``<= 4`` inlines, ``> 4`` does not. + + ``x**4`` (at the limit) inlines to four factors; ``x**5`` (over the limit) + is left as an integer power (``x**5`` — an exact power, no ``ti.pow`` guard + since the exponent is a plain integer). + """ + x = sp.Symbol("x") + assert SMALL_INT_POW_LIMIT == 4 + + assert _lower_one(x**4, guards=False) == "x*x*x*x" + assert _lower_one(x**5, guards=False) == "x**5" + + +def test_standalone_negative_pow_is_not_inlined() -> None: + """A standalone ``x**-2`` (no numerator) is NOT inlined — stays ``x**(-2)``. + + Negative integer exponents are reciprocals; the printer must not intercept + them (Gate-B critical fix). With no numerator there is no division to + mis-group, so SymPy's canonical ``x**(-2)`` is emitted verbatim. The old + behaviour (inlining to a self-contained ``1/(x*x)``) is exactly what caused + the Mul-context collapse and is deliberately gone. + """ + x = sp.Symbol("x") + assert _lower_one(x**-2, guards=False) == "x**(-2)" + + # Guarded standalone reciprocal: the base is the sign-preserving floor, and + # the power stays ``**(-2)`` (still no collapse — no numerator). + guarded = _lower_one(x**-2) + signed_floor = "ti.select(x >= 0, ti.max(x, 1e-12), ti.min(x, -1e-12))" + assert guarded == f"{signed_floor}**(-2)" + + +# --------------------------------------------------------------------------- +# Case 4 (Gate-B regression) — division-by-power must NOT collapse to ``a/x*x``. +# --------------------------------------------------------------------------- +# +# ``a/x**2`` = ``Mul(a, Pow(x, -2))``. SymPy's ``_print_Mul`` splits the +# reciprocal into the denominator and prints the positive counterpart ``Pow(x, 2)`` +# through ``_print_Pow`` — which inlines it to ``x*x``. Without the ``parenthesize`` +# override, ``_print_Mul`` would leave that unwrapped (a ``Pow`` node reports +# precedence 60 > ``Mul``'s 50), emitting ``a/x*x``, which under left-to-right +# ``/`` and ``*`` evaluates to ``a`` — silently-wrong math on realistic forms like +# ``mu/J**2``. These tests pin the correct, parenthesised division on BOTH the raw +# and guarded paths. + + +def test_division_by_square_is_parenthesised_not_collapsed() -> None: + """``a/x**2`` → ``a/(x*x)`` (raw and guarded) — never the ``a/x*x`` collapse.""" + a, x = sp.symbols("a x") + + raw = _lower_one(a / x**2, guards=False) + assert raw == "a/(x*x)" + # The Gate-B collapse: unparenthesised ``/x*x`` must NOT appear ... + assert "/x*x" not in raw + # ... and it must not have degenerated to the bare numerator ``a``. + assert raw != "a" + assert raw != "a/x*x" + + guarded = _lower_one(a / x**2) + signed_floor = "ti.select(x >= 0, ti.max(x, 1e-12), ti.min(x, -1e-12))" + assert guarded == f"a/({signed_floor}*{signed_floor})" + assert "/x*x" not in guarded + + +def test_division_by_square_realistic_material_form() -> None: + """``k/J**2`` (a realistic ``mu/J**2``-style form) → ``k/(J*J)``, not ``k/J*J``.""" + k, J = sp.symbols("k J") + + raw = _lower_one(k / J**2, guards=False) + assert raw == "k/(J*J)" + assert "/J*J" not in raw + assert raw != "k" + + +def test_division_by_cube_is_parenthesised() -> None: + """``a/x**3`` → ``a/(x*x*x)`` — the three-factor denominator is grouped.""" + a, x = sp.symbols("a x") + + raw = _lower_one(a / x**3, guards=False) + assert raw == "a/(x*x*x)" + assert "/x*x" not in raw + + +def test_division_by_power_above_threshold_keeps_pow() -> None: + """``a/x**5`` (over the inline threshold) stays ``a/x**5`` — correct and safe. + + An above-threshold power is not inlined, so it prints as ``x**5``; ``**`` + binds tighter than ``/`` so no extra parentheses are needed (``a/x**5`` is + unambiguous). + """ + a, x = sp.symbols("a x") + assert _lower_one(a / x**5, guards=False) == "a/x**5" + + +def test_multi_factor_denominator_stays_correct() -> None: + """``a/(x**2 * J)`` keeps a correct, mathematically-sound grouping. + + A two-factor denominator is wrapped by SymPy; the inlined square inside must + not break that grouping. Whatever the exact spelling, it must not collapse a + factor out of the denominator (no bare ``/x*x`` fragment). + """ + a, x, J = sp.symbols("a x J") + raw = _lower_one(a / (x**2 * J), guards=False) + + assert "/x*x" not in raw + # A correct grouping: the whole denominator is parenthesised. + assert raw in {"a/(J*(x*x))", "a/(J*x*x)"} + + +# --------------------------------------------------------------------------- +# Cases 5 & 6 — deterministic source_hash. +# --------------------------------------------------------------------------- + + +def test_same_input_yields_same_source_hash() -> None: + """Lowering the same input twice → identical ``source_hash`` (AC5).""" + sigma0, Q, b, p, K, n, p0 = sp.symbols("sigma0 Q b p K n p0") + expr = sigma0 + Q * (1 - sp.exp(-b * p)) + K * ((p + p0) ** n - p0**n) + + first = lower_expression(expr) + second = lower_expression(expr) + + assert first.source_hash == second.source_hash + # And the whole lowered result is equal too (lines are byte-identical). + assert first == second + + +def test_source_hash_is_64_hex_chars() -> None: + """``source_hash`` matches ``^[0-9a-f]{64}$`` (AC6).""" + x = sp.Symbol("x") + result = lower_expression(sp.exp(x) + x**2) + + assert _HEX64.match(result.source_hash) + assert len(result.source_hash) == 64 + + +def test_source_hash_input_is_emitted_lines_in_order() -> None: + """The hash is SHA-256 of ``"\\n".join(temporaries + returns)`` (documented input). + + Recomputing the digest from the emitted lines must reproduce the stored + ``source_hash`` — pinning the exact hash input (emission order: temporaries + first, then returns) that P4-1/P4-2 read. + """ + b, p, sigma0, Q = sp.symbols("b p sigma0 Q") + shared = sp.exp(-b * p) + result = lower_expression([sigma0 + shared, Q * shared]) + + # CSE lifts the shared exp → one temporary + two returns, in emission order. + assert result.temporaries == ("x0 = ti.exp(-b*p)",) + assert result.returns == ("sigma0 + x0", "Q*x0") + + expected = compute_source_hash(result.temporaries, result.returns) + assert result.source_hash == expected + + +def test_direct_lowered_expr_construction_carries_hash() -> None: + """A directly-constructed ``LoweredExpr`` (P2-2's pattern) still gets a hash. + + P2-2's budget tests build ``LoweredExpr(temporaries=…, returns=…)`` without + passing a hash; ``__post_init__`` computes it, so every instance carries a + consistent 64-hex digest and the ``source_hash`` field is not part of + ``__init__``. + """ + lowered = LoweredExpr(temporaries=("x0 = ti.exp(-b*p)",), returns=("sigma0 + x0",)) + + assert _HEX64.match(lowered.source_hash) + assert lowered.source_hash == compute_source_hash(lowered.temporaries, lowered.returns) + + +def test_source_hash_changes_with_different_lines() -> None: + """Different emitted lines → a different ``source_hash`` (the digest is content-bound).""" + x, y = sp.symbols("x y") + + assert lower_expression(sp.exp(x)).source_hash != lower_expression(sp.exp(y)).source_hash + + +def test_source_hash_excluded_from_equality() -> None: + """Equality is driven by the lines, and the derived hash follows them. + + Two lowered results with identical lines are equal AND share a hash; the + ``source_hash`` field carries no independent identity (``compare=False``). + """ + a = LoweredExpr(temporaries=(), returns=("x*x",)) + b = LoweredExpr(temporaries=(), returns=("x*x",)) + + assert a == b + assert a.source_hash == b.source_hash diff --git a/packages/mechdsl-core/tests/lawgen/test_manifest.py b/packages/mechdsl-core/tests/lawgen/test_manifest.py new file mode 100644 index 0000000..d0607f8 --- /dev/null +++ b/packages/mechdsl-core/tests/lawgen/test_manifest.py @@ -0,0 +1,381 @@ +"""Unit tests for the manifest emitter (Task P3-3). + +Covers the four ``test_plan.cases`` from ``dev/plans/mfront_cycleM0/json/P3-3.json``: + +1. the manifest entry for a SwiftVoce spec has all the Cycle 0 required fields; +2. ``source_hash`` matches the compile's source hash — reconciled to Cycle 0's + convention (the INPUT-formula hash, *not* P2-4's emitted-lines hash); +3. ``generated_by`` contains ``"mechdsl-lawgen"``; +4. the written ``_manifest.json`` is valid, loadable JSON. + +Plus the domain-quality bonuses that make the manifest safe for P4-2 to +byte-compare against Cycle 0's real ``_manifest.json``: + +* the canonical SwiftVoce ``R`` formula reproduces Cycle 0's published + ``source_hash`` (``7b5af3a8…``) exactly under the verbatim-UTF-8 convention; +* ``write_manifest`` is byte-stable (same entries → identical bytes twice); +* the entry key set is exactly the nine Cycle 0 ``laws`` fields; +* the ``parameters`` object partitions into ``{"required", "optional"}`` and + ``emit_manifest`` fails loud on empty/invalid inputs (no half-populated entry). + +Cross-repo discipline (R3): nothing here imports NumerixWeave or ``ticonstit``. +The reconciliation is proven by reproducing Cycle 0's *published hash value* +locally — the real file is only ever read as bytes by P4-2, never imported. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest +import sympy as sp + +from mechdsl.lawgen.contracts import PlasticityCarrierSpec +from mechdsl.lawgen.manifest import ( + GENERATED_BY, + LAWS_ENTRY_FIELDS, + compute_input_formula_hash, + emit_manifest, + formula_matches_spec, + write_manifest, +) + +if TYPE_CHECKING: + from pathlib import Path + +# --------------------------------------------------------------------------- +# Cycle 0 reconciliation constants. +# +# The canonical SwiftVoce ``R`` formula string and the published ``source_hash`` +# it must reproduce. These pin the manifest ``source_hash`` convention to Cycle +# 0's: hash the INPUT formula string verbatim (UTF-8, no normalisation). P4-1 +# supplies this exact string from the law yaml; the value below is transcribed +# from Cycle 0's ``_manifest.json`` (read as data, not imported — R3). +# --------------------------------------------------------------------------- + +CYCLE0_R_FORMULA = "R = sigma0 + Q*(1-exp(-b*p)) + K*((p+p0)**n - p0**n)" +CYCLE0_R_SOURCE_HASH = "7b5af3a8bb79c2e44e0055a7076dd2c9de2ce8c75eb2e262b80bb4e0232d557f" + +# The REAL Cycle 0 SwiftVoce ``parameters`` block (transcribed verbatim from +# NumerixWeave's ``_manifest.json`` — read as data, never imported: R3). Note the +# material-card names differ from the formula-string spelling: the card names the +# saturation parameter ``Q_inf`` while the hashed R formula spells it ``Q``. The +# realistic fixture below uses these exact names so the emitted ``parameters`` +# object is byte-identical to the hand-authored artifact P4-2 compares against. +CYCLE0_REQUIRED = ["sigma0", "Q_inf", "b"] +CYCLE0_OPTIONAL = ["K", "n", "p0", "edot0", "m", "alpha", "T_ref"] + + +def _swift_voce_spec() -> PlasticityCarrierSpec: + """A SwiftVoce carrier whose ``R`` matches Cycle 0's *formula string* spelling. + + ``R = sigma0 + Q*(1 - exp(-b*p)) + K*((p + p0)**n - p0**n)`` — the spec's + symbols match the hashed formula (``Q``, not ``Q_inf``), so this fixture drives + the ``source_hash`` reconciliation and the opt-in ``check_matches_spec`` path + (where the formula and the spec must be symbolically equal). For the realistic + material-card ``parameters`` block (``Q_inf`` + 10 names) see + :func:`_cycle0_realistic_entry`. + """ + p, edot, T = sp.symbols("p edot T") + sigma0, Q, b, K, p0, n = sp.symbols("sigma0 Q b K p0 n") + R = sigma0 + Q * (1 - sp.exp(-b * p)) + K * ((p + p0) ** n - p0**n) + return PlasticityCarrierSpec( + name="swift_voce", + parameters=("sigma0", "Q", "b", "K", "p0", "n"), + expressions={"R": R, "H": sp.Integer(1), "Q": sp.Integer(1)}, + variable_bindings={"p": p, "edot": edot, "T": T}, + ) + + +def _cycle0_realistic_spec() -> PlasticityCarrierSpec: + """A SwiftVoce carrier carrying the REAL Cycle 0 material card (10 parameters). + + ``spec.parameters`` is the full ``required ∪ optional`` name set in declaration + order, so an explicit split reproduces Cycle 0's ``parameters`` block exactly. + The R expression is a placeholder that references the card names (the manifest + fingerprints the separately-supplied ``input_formula``, not ``spec.R``, so R's + exact shape does not affect ``source_hash``). + """ + p, edot, T = sp.symbols("p edot T") + syms = sp.symbols(" ".join(CYCLE0_REQUIRED + CYCLE0_OPTIONAL)) + sigma0, Q_inf, b = syms[0], syms[1], syms[2] + R = sigma0 + Q_inf * (1 - sp.exp(-b * p)) + return PlasticityCarrierSpec( + name="SwiftVoce", + parameters=tuple(CYCLE0_REQUIRED + CYCLE0_OPTIONAL), + expressions={"R": R, "H": sp.Integer(1), "Q": sp.Integer(1)}, + variable_bindings={"p": p, "edot": edot, "T": T}, + ) + + +def _cycle0_realistic_entry() -> dict[str, object]: + """``emit_manifest`` for the realistic card with Cycle 0's explicit split.""" + return emit_manifest( + _cycle0_realistic_spec(), + input_formula=CYCLE0_R_FORMULA, + target_contract="VoceHardeningModel", + exports="SwiftVoce", + source="swift_voce.py", + tests=["libs/ticonstit/tests/plan_tests/mfront_cycle0/test_P2-2.py"], + required=CYCLE0_REQUIRED, + optional=CYCLE0_OPTIONAL, + ) + + +def _swift_voce_entry(**overrides: object) -> dict[str, object]: + """``emit_manifest`` for the SwiftVoce spec with sensible P4-1-shaped args.""" + kwargs: dict[str, object] = { + "input_formula": CYCLE0_R_FORMULA, + "target_contract": "SwiftVoce", + "exports": "SwiftVoce", + "source": "swift_voce.py", + "tests": ["tests/generated/test_swift_voce.py"], + } + kwargs.update(overrides) + return emit_manifest(_swift_voce_spec(), **kwargs) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Case 1 — all Cycle 0 required fields present. +# --------------------------------------------------------------------------- + + +class TestManifestFields: + def test_entry_has_all_cycle0_required_fields(self) -> None: + """AC2: the six named fields (and the full nine-field Cycle 0 shape).""" + entry = _swift_voce_entry() + required_six = { + "source_hash", + "generated_by", + "target_contract", + "exports", + "parameters", + "tests", + } + assert required_six <= set(entry) + # The full Cycle 0 laws entry is exactly nine fields, no more, no less. + assert set(entry) == set(LAWS_ENTRY_FIELDS) + + def test_entry_key_order_is_cycle0_order(self) -> None: + """The entry lists fields in Cycle 0's declared order (byte-stability aid).""" + entry = _swift_voce_entry() + assert tuple(entry) == LAWS_ENTRY_FIELDS + + def test_parameters_is_required_optional_object(self) -> None: + """AC4: ``parameters`` is the ``{"required", "optional"}`` object, not a flat list.""" + params = _swift_voce_entry()["parameters"] + assert isinstance(params, dict) + assert set(params) == {"required", "optional"} + # Convention path: no explicit split → all spec parameters are required. + assert params["required"] == list(_swift_voce_spec().parameters) + assert params["optional"] == [] + + def test_explicit_required_optional_partition(self) -> None: + """An explicit required/optional split (the P4-1 path) is preserved verbatim.""" + params = _swift_voce_entry( + required=["sigma0", "Q", "b", "K", "n"], + optional=["p0"], + )["parameters"] + assert params == {"required": ["sigma0", "Q", "b", "K", "n"], "optional": ["p0"]} + + +# --------------------------------------------------------------------------- +# Case 2 — source_hash matches the compile source hash (Cycle 0 reconciliation). +# --------------------------------------------------------------------------- + + +class TestSourceHash: + def test_source_hash_matches_input_formula_hash(self) -> None: + """AC3 (reconciled): ``source_hash`` == hash of the INPUT formula string.""" + entry = _swift_voce_entry() + assert entry["source_hash"] == compute_input_formula_hash(CYCLE0_R_FORMULA) + + def test_source_hash_reproduces_cycle0_published_value(self) -> None: + """The canonical SwiftVoce R formula reproduces Cycle 0's ``7b5af3a8…``. + + This is the P4-2 reconciliation guarantee: MechDSL's emitted manifest + source_hash equals the value already published in Cycle 0's + ``_manifest.json`` — so provenance verification passes. + """ + assert _swift_voce_entry()["source_hash"] == CYCLE0_R_SOURCE_HASH + + def test_input_formula_hash_is_verbatim_utf8(self) -> None: + """The convention is verbatim UTF-8 — whitespace/reordering changes the hash.""" + base = compute_input_formula_hash(CYCLE0_R_FORMULA) + # Collapsing a space is a different input → a different hash (no normalisation). + assert compute_input_formula_hash(CYCLE0_R_FORMULA.replace(" + ", "+")) != base + + def test_empty_formula_rejected(self) -> None: + for bad in ("", " ", None): + with pytest.raises(ValueError, match="non-empty formula string"): + compute_input_formula_hash(bad) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Case 3 — generated_by names the generator. +# --------------------------------------------------------------------------- + + +class TestGeneratedBy: + def test_generated_by_contains_mechdsl_lawgen(self) -> None: + entry = _swift_voce_entry() + assert "mechdsl-lawgen" in str(entry["generated_by"]) + + def test_generated_by_carries_a_version(self) -> None: + """``mechdsl-lawgen/`` — the '/' + a version tail is present.""" + assert GENERATED_BY.startswith("mechdsl-lawgen/") + assert GENERATED_BY.split("/", 1)[1] != "" + + +# --------------------------------------------------------------------------- +# Case 4 — the written _manifest.json is valid, loadable JSON. +# --------------------------------------------------------------------------- + + +class TestWriteManifest: + def test_written_manifest_is_valid_json(self, tmp_path: Path) -> None: + """AC1: ``json.load`` of the emitted file succeeds and round-trips the entry.""" + out = write_manifest([_swift_voce_entry()], tmp_path / "_manifest.json") + loaded = json.loads(out.read_text(encoding="utf-8")) + assert "laws" in loaded + assert loaded["laws"][0]["source_hash"] == CYCLE0_R_SOURCE_HASH + assert loaded["laws"][0]["generated_by"] == GENERATED_BY + + def test_written_manifest_has_schema_and_laws_top_level(self, tmp_path: Path) -> None: + """Top-level shape mirrors Cycle 0: ``{"_schema": ..., "laws": [...]}``.""" + out = write_manifest([_swift_voce_entry()], tmp_path / "_manifest.json") + loaded = json.loads(out.read_text(encoding="utf-8")) + assert set(loaded) == {"_schema", "laws"} + assert isinstance(loaded["laws"], list) + + def test_write_is_byte_stable(self, tmp_path: Path) -> None: + """Same entries → byte-identical file across re-runs (deterministic output).""" + first = write_manifest([_swift_voce_entry()], tmp_path / "a.json").read_bytes() + second = write_manifest([_swift_voce_entry()], tmp_path / "b.json").read_bytes() + assert first == second + assert first.endswith(b"\n") + + def test_written_entry_key_order_matches_cycle0(self, tmp_path: Path) -> None: + """The serialised laws entry keeps Cycle 0's insertion order (no sort_keys). + + Cycle 0's real _manifest.json lists the entry keys as name, kind, source, … + (insertion order, NOT alphabetical) and parameters as {required, optional}. + json.loads preserves file key order (py3.7+), so this pins the on-disk order. + """ + out = write_manifest([_swift_voce_entry()], tmp_path / "_manifest.json") + loaded = json.loads(out.read_text(encoding="utf-8")) + assert tuple(loaded["laws"][0]) == LAWS_ENTRY_FIELDS + assert tuple(loaded["laws"][0]["parameters"]) == ("required", "optional") + + +# --------------------------------------------------------------------------- +# Fail-loud — a half-populated entry is never emitted. +# --------------------------------------------------------------------------- + + +class TestFailLoud: + @pytest.mark.parametrize("field", ["target_contract", "exports", "source"]) + def test_empty_string_field_rejected(self, field: str) -> None: + with pytest.raises(ValueError, match="non-empty string"): + _swift_voce_entry(**{field: " "}) + + def test_no_tests_rejected(self) -> None: + with pytest.raises(ValueError, match="at least one test"): + _swift_voce_entry(tests=[]) + + def test_required_optional_overlap_rejected(self) -> None: + with pytest.raises(ValueError, match="overlap"): + _swift_voce_entry(required=["sigma0"], optional=["sigma0"]) + + def test_unknown_parameter_rejected(self) -> None: + with pytest.raises(ValueError, match="not declared"): + _swift_voce_entry(required=["not_a_param"]) + + def test_incomplete_partition_rejected(self) -> None: + """An explicit split that omits a declared parameter fails loud (no silent drop).""" + # Only sigma0 classified; Q, b, K, p0, n covered by neither list. + with pytest.raises(ValueError, match="neither required nor optional"): + _swift_voce_entry(required=["sigma0"], optional=[]) + + +# --------------------------------------------------------------------------- +# Cycle 0 parameters parity — the realistic material card reproduces the real +# {required, optional} block byte-for-byte (not just the 6-name mechanism spec). +# --------------------------------------------------------------------------- + + +class TestCycle0ParametersParity: + def test_parameters_block_matches_real_cycle0(self) -> None: + """The emitted ``parameters`` object equals Cycle 0's real required/optional lists.""" + entry = _cycle0_realistic_entry() + assert entry["parameters"] == {"required": CYCLE0_REQUIRED, "optional": CYCLE0_OPTIONAL} + + def test_realistic_entry_still_reproduces_source_hash(self) -> None: + """Realistic card + the verbatim formula still fingerprints Cycle 0's hash.""" + assert _cycle0_realistic_entry()["source_hash"] == CYCLE0_R_SOURCE_HASH + + def test_realistic_entry_written_key_order(self, tmp_path: Path) -> None: + """Realistic entry serialises with Cycle 0's insertion order end-to-end.""" + out = write_manifest([_cycle0_realistic_entry()], tmp_path / "_manifest.json") + loaded = json.loads(out.read_text(encoding="utf-8")) + assert tuple(loaded["laws"][0]) == LAWS_ENTRY_FIELDS + assert loaded["laws"][0]["parameters"]["required"] == CYCLE0_REQUIRED + + +# --------------------------------------------------------------------------- +# formula ↔ spec consistency — the opt-in guard that closes the "hashed formula +# is a different law than the spec" gap for P4-1. +# --------------------------------------------------------------------------- + + +class TestFormulaMatchesSpec: + def test_matching_formula_is_true(self) -> None: + """The Cycle 0 formula (Q-spelled) is symbolically equal to the Q-spelled spec.R.""" + assert formula_matches_spec(CYCLE0_R_FORMULA, _swift_voce_spec()) is True + + def test_formula_without_lhs_prefix_also_matches(self) -> None: + """The 'R =' prefix is optional — a bare RHS matches too.""" + rhs = CYCLE0_R_FORMULA.split("=", 1)[1].strip() + assert formula_matches_spec(rhs, _swift_voce_spec()) is True + + def test_mismatched_formula_is_false(self) -> None: + """A formula that is not spec.R is reported as a mismatch (no false positive).""" + assert formula_matches_spec("R = sigma0 + b*p", _swift_voce_spec()) is False + + def test_unparseable_formula_raises(self) -> None: + with pytest.raises(ValueError, match="could not parse"): + formula_matches_spec("R = sigma0 +* b", _swift_voce_spec()) + + def test_emit_manifest_check_matches_spec_passes_when_consistent(self) -> None: + """check_matches_spec=True is a no-op when the formula equals spec.R.""" + entry = _swift_voce_entry(check_matches_spec=True) + assert entry["source_hash"] == CYCLE0_R_SOURCE_HASH + + def test_emit_manifest_check_matches_spec_raises_on_drift(self) -> None: + """A drifted formula fingerprints the wrong law → fails loud under the check.""" + with pytest.raises(ValueError, match="not symbolically equal"): + _swift_voce_entry(input_formula="R = sigma0 + b*p", check_matches_spec=True) + + def test_check_defaults_off_for_cycle0_naming_divergence(self) -> None: + """Default (off) tolerates the Cycle 0 Q vs Q_inf spelling divergence.""" + # The realistic card spells the parameter Q_inf while the formula spells Q, + # so the formula is NOT spec.R — but the default path still emits (hash intact). + assert formula_matches_spec(CYCLE0_R_FORMULA, _cycle0_realistic_spec()) is False + assert _cycle0_realistic_entry()["source_hash"] == CYCLE0_R_SOURCE_HASH + + +# --------------------------------------------------------------------------- +# _schema shape — laws_entry_fields is an object (name → description), matching +# Cycle 0's real _schema, not a flat list of names. +# --------------------------------------------------------------------------- + + +class TestSchemaShape: + def test_laws_entry_fields_is_name_to_description_object(self, tmp_path: Path) -> None: + out = write_manifest([_swift_voce_entry()], tmp_path / "_manifest.json") + schema = json.loads(out.read_text(encoding="utf-8"))["_schema"] + fields = schema["laws_entry_fields"] + assert isinstance(fields, dict) + assert tuple(fields) == LAWS_ENTRY_FIELDS + assert all(isinstance(v, str) and v.strip() for v in fields.values()) diff --git a/packages/mechdsl-core/tests/lawgen/test_sympy_to_taichi.py b/packages/mechdsl-core/tests/lawgen/test_sympy_to_taichi.py new file mode 100644 index 0000000..258af47 --- /dev/null +++ b/packages/mechdsl-core/tests/lawgen/test_sympy_to_taichi.py @@ -0,0 +1,202 @@ +"""Unit tests for the deterministic SymPy → Taichi lowerer (Task P2-1). + +MFront-mimic Cycle M0, Phase 2 (``dev/plans/mfront_cycleM0.md`` lines 76-78). + +Covers the three ``test_plan.cases`` plus the fail-loud route: + +1. A simple quadratic lowers to the expected (golden) Taichi string. +2. A repeated sub-expression is factored into a CSE temporary emitted before + the return line. +3. Lowering the same expression twice is byte-identical (determinism). +4. An unsupported node (``Piecewise`` / an undefined function) raises + ``NotImplementedError`` rather than silently emitting wrong code (R2). + +Plus the R4 guard: the lowerer module must contain no ``pycode`` / ``re.sub``. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import sympy as sp + +from mechdsl.lawgen import sympy_to_taichi as _lowerer_module +from mechdsl.lawgen.cli import _ALLOWED_FUNCTIONS +from mechdsl.lawgen.sympy_to_taichi import ( + MATH_TO_TAICHI, + LoweredExpr, + TaichiExprPrinter, + lower_expression, +) + +# Resolve the module source from the imported module (not a CWD-relative path) +# so the R4 guard test is robust to where pytest is invoked from. +_MODULE_SOURCE = Path(_lowerer_module.__file__) + + +# --------------------------------------------------------------------------- +# Case 1 — simple quadratic → golden Taichi string. +# --------------------------------------------------------------------------- + + +def test_lower_simple_quadratic_golden() -> None: + """``x**2 + 2*x + 1`` lowers to the expected Taichi string (AC3). + + No shared sub-expression, so CSE introduces no temporary; the single return + line is the deterministic golden string the downstream tasks may rely on. + Since P2-4, the small-integer ``x**2`` is inlined to ``x*x`` (never + ``ti.pow``). + """ + x = sp.Symbol("x") + result = lower_expression(x**2 + 2 * x + 1) + + assert isinstance(result, LoweredExpr) + assert result.temporaries == () + assert result.returns == ("x*x + 2*x + 1",) + + +def test_lower_function_maps_to_taichi() -> None: + """A whitelisted function lowers to its ``ti.*`` call, not a bare name.""" + x, b, p = sp.symbols("x b p") + printer = TaichiExprPrinter() + + assert printer.doprint(sp.exp(-b * p)) == "ti.exp(-b*p)" + assert printer.doprint(sp.log(x)) == "ti.log(x)" + assert printer.doprint(sp.sqrt(x)) == "ti.sqrt(x)" + assert printer.doprint(sp.Abs(x)) == "ti.abs(x)" + assert printer.doprint(sp.Max(x, 1)) == "ti.max(1, x)" + assert printer.doprint(sp.Min(x, 1)) == "ti.min(1, x)" + assert printer.doprint(sp.sign(x)) == "ti.sign(x)" + assert printer.doprint(sp.tanh(x)) == "ti.tanh(x)" + + +# --------------------------------------------------------------------------- +# Case 2 — repeated sub-expression introduces a CSE temporary. +# --------------------------------------------------------------------------- + + +def test_repeated_subexpression_introduces_cse_temp() -> None: + """A shared sub-term is factored into a temporary emitted before the return. + + ``exp(-b*p)`` appears twice; canonical CSE lifts it to ``x0`` and the + reduced return references ``x0``. The temporary line must precede the + return line (AC4) and be a real assignment. + """ + b, p = sp.symbols("b p") + shared = sp.exp(-b * p) + result = lower_expression(shared * (1 + shared)) + + assert result.temporaries == ("x0 = ti.exp(-b*p)",) + assert result.returns == ("x0*(x0 + 1)",) + # The temporary is an assignment to the symbol the return references. + assert result.temporaries[0].startswith("x0 = ") + assert "x0" in result.returns[0] + + +def test_repeated_subexpression_across_multiple_returns() -> None: + """CSE factors a sub-term shared across a *sequence* of expressions. + + The two returns keep input order and both reference the lifted temporary. + """ + b, p, sigma0, Q = sp.symbols("b p sigma0 Q") + shared = sp.exp(-b * p) + result = lower_expression([sigma0 + shared, Q * shared]) + + assert result.temporaries == ("x0 = ti.exp(-b*p)",) + assert result.returns == ("sigma0 + x0", "Q*x0") + + +# --------------------------------------------------------------------------- +# Case 3 — determinism (order='canonical'). +# --------------------------------------------------------------------------- + + +def test_cse_canonical_order_is_deterministic() -> None: + """Lowering the same expression twice is byte-identical (AC2).""" + b, p, sigma0, Q, K, n = sp.symbols("b p sigma0 Q K n") + shared = sp.exp(-b * p) + exprs = [sigma0 + Q * (1 - shared) + K * p**n, Q * shared, shared + p] + + first = lower_expression(exprs) + second = lower_expression(exprs) + + assert first == second + assert first.temporaries == second.temporaries + assert first.returns == second.returns + + +def test_lower_expression_result_is_immutable() -> None: + """``LoweredExpr`` is frozen — the tuples cannot be reassigned.""" + result = lower_expression(sp.Symbol("x") ** 2) + with pytest.raises((AttributeError, TypeError)): + result.returns = ("mutated",) # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Case 4 — fail loud (R2), no silent fallback. +# --------------------------------------------------------------------------- + + +def test_piecewise_lowers_to_ti_select() -> None: + """A ``Piecewise`` now lowers to ``ti.select`` (P2-4 replaced the P2-1 reject). + + P2-1 fail-loud-rejected ``Piecewise`` and pointed at the P2-4 phase; P2-4 + delivered that lowering, so an exhaustive two-branch switch emits a single + ``ti.select`` rather than raising. (The full nesting / budget behaviour lives + in ``test_lowering_table.py``.) + """ + x = sp.Symbol("x") + piece = sp.Piecewise((x, x > 0), (0, True)) + assert lower_expression(piece).returns == ("ti.select(x > 0, x, 0)",) + + +def test_unknown_applied_function_raises() -> None: + """A bogus undefined function fails loud rather than emitting garbage.""" + x = sp.Symbol("x") + foo = sp.Function("foo") + with pytest.raises(NotImplementedError, match="foo"): + lower_expression(foo(x)) + + +def test_unregistered_defined_function_raises() -> None: + """A defined-but-unmapped SymPy function (``erf``) fails loud in the printer.""" + x = sp.Symbol("x") + with pytest.raises(NotImplementedError, match="erf"): + TaichiExprPrinter().doprint(sp.erf(x)) + + +def test_non_expr_input_raises_type_error() -> None: + """A non-``Expr`` element is rejected — no silent coercion.""" + with pytest.raises(TypeError): + lower_expression(["not an expr"]) # type: ignore[list-item] + + +# --------------------------------------------------------------------------- +# R4 guard + allow-list alignment. +# --------------------------------------------------------------------------- + + +def test_module_uses_no_pycode_or_regex_substitution() -> None: + """AC1: the lowerer never uses ``pycode`` or ``re.sub`` (the R4 anti-pattern). + + Asserted against the module source directly so the guard cannot regress. + """ + source = _MODULE_SOURCE.read_text(encoding="utf-8") + assert "pycode" not in source + assert not re.search(r"re\.sub", source) + + +def test_taichi_map_aligns_with_cli_allowed_functions() -> None: + """The printer's allow-list matches P1-2's parser allow-list. + + Every function the CLI accepts into an R/H/Q expression must be lowerable, + or a legal law would parse but fail to emit. ``pow`` is a printer-only entry + (``Pow`` is not a parseable function name), so it is excluded from the + comparison. + """ + printer_functions = set(MATH_TO_TAICHI) - {"pow"} + assert printer_functions == set(_ALLOWED_FUNCTIONS) + for taichi_name in MATH_TO_TAICHI.values(): + assert taichi_name.startswith("ti.") diff --git a/packages/mechdsl-core/tests/lawgen/test_test_emitter.py b/packages/mechdsl-core/tests/lawgen/test_test_emitter.py new file mode 100644 index 0000000..18a0c70 --- /dev/null +++ b/packages/mechdsl-core/tests/lawgen/test_test_emitter.py @@ -0,0 +1,259 @@ +"""Unit tests for the generated-tests emitter (Task P3-2). + +Covers the four ``test_plan.cases`` from ``dev/plans/mfront_cycleM0/json/P3-2.json``: + +1. ``emit_tests`` on a simple spec → the generated source defines a reference-eval + test function AND an FD-derivative test function. +2. ``emit_tests`` with ``monotone_check=True`` → the generated source has the + monotonicity assertion/block. +3. ``emit_tests`` with ``monotone_check=False`` → the generated source has NO + monotonicity block. +4. the generated file is valid Python (``ast.parse`` succeeds). + +Plus the strong bonus: the emitted file for a monotone Voce law is executed +in-process with ``pytest`` and asserted to PASS — proving the reference/FD/ +monotonicity tests are correct, not merely parseable. The fail-loud (R2) route +(unsupported node → ``LawgenError``) is also covered. +""" + +from __future__ import annotations + +import ast +import subprocess +import sys +from typing import TYPE_CHECKING + +import pytest +import sympy as sp + +from mechdsl.lawgen.contracts import PlasticityCarrierSpec +from mechdsl.lawgen.diagnostics import LawgenError +from mechdsl.lawgen.test_emitter import ( + FACTOR_PRIMARY_VARIABLE, + FD_RTOL, + N_SAMPLE_POINTS, + emit_tests, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def _voce_spec(*, monotone_check: bool = False) -> PlasticityCarrierSpec: + """A Voce + power-law isotropic-hardening carrier, monotone in ``p``. + + ``R = sigma_y0 + Q*(1 - exp(-b*p)) + K*p**n`` — every term is non-decreasing + in ``p >= 0`` for positive parameters, so a real run of the generated + monotonicity test passes. H/Q here are rate-/temperature-independent, so their + FD derivative w.r.t. edot/T is 0 (FD ~ 0) — the general per-factor check still + passes without special-casing. + """ + p, edot, T = sp.symbols("p edot T") + sigma_y0, Q, b, K, n = sp.symbols("sigma_y0 Q b K n") + R = sigma_y0 + Q * (1 - sp.exp(-b * p)) + K * p**n + return PlasticityCarrierSpec( + name="voce", + parameters=("sigma_y0", "Q", "b", "K", "n"), + expressions={"R": R, "H": sp.diff(R, p), "Q": sp.Integer(1)}, + variable_bindings={"p": p, "edot": edot, "T": T}, + monotone_check=monotone_check, + ) + + +def _rate_thermal_spec() -> PlasticityCarrierSpec: + """A carrier with genuinely non-constant rate (H) and thermal (Q) factors. + + ``H = 1 + C*log(edot)`` (dH/dedot = C/edot != 0) and ``Q = 1 - A*(T - T0)`` + (dQ/dT = -A != 0), so the H->edot and Q->T FD checks exercise a *real* + non-zero derivative — not just the constant-factor (derivative 0) path. + """ + p, edot, T = sp.symbols("p edot T") + sigma_y0, K, C, T0, A = sp.symbols("sigma_y0 K C T0 A") + R = sigma_y0 + K * p + H = 1 + C * sp.log(edot) + Q = 1 - A * (T - T0) + return PlasticityCarrierSpec( + name="rate_thermal", + parameters=("sigma_y0", "K", "C", "T0", "A"), + expressions={"R": R, "H": H, "Q": Q}, + variable_bindings={"p": p, "edot": edot, "T": T}, + ) + + +# --------------------------------------------------------------------------- +# Case 1 — reference-eval + FD-derivative test functions are present. +# --------------------------------------------------------------------------- + + +class TestEmittedTestFunctions: + def test_has_reference_and_fd_tests(self, tmp_path: Path) -> None: + out = emit_tests(_voce_spec(), target_test_path=tmp_path / "test_gen_voce.py") + source = out.read_text(encoding="utf-8") + assert "def test_reference_eval(" in source + assert "def test_fd_derivative(" in source + + def test_fd_derivative_covers_all_three_factors(self, tmp_path: Path) -> None: + """The FD test is parametrized over R/H/Q, each vs its own primary axis. + + R/H/Q are three independent factors (hardening/rate/thermal); the emitter + must FD-check each factor's derivative w.r.t. its own primary variable + (R->p, H->edot, Q->T), not conflate H with d(R)/dp. + """ + out = emit_tests(_voce_spec(), target_test_path=tmp_path / "test_gen_voce.py") + source = out.read_text(encoding="utf-8") + # One parametrized FD test carrying all three (role, srepr, primary) rows. + assert "@pytest.mark.parametrize" in source + assert "FD_CASES" in source + for role, srepr_const in (("R", "R_SREPR"), ("H", "H_SREPR"), ("Q", "Q_SREPR")): + primary = FACTOR_PRIMARY_VARIABLE[role] + assert f"({role!r}, {srepr_const!r}, {primary!r})" in source + # H must NOT be asserted equal to d(R)/dp anywhere (the misread to avoid). + assert "sp.diff(R" not in source + assert "sp.diff(_rebuild(R_SREPR)" not in source + + def test_emit_tests_returns_written_path(self, tmp_path: Path) -> None: + target = tmp_path / "sub" / "test_gen_voce.py" + out = emit_tests(_voce_spec(), target_test_path=target) + assert out == target + assert out.exists() + + def test_fd_test_uses_rtol_at_or_below_1e_5(self, tmp_path: Path) -> None: + out = emit_tests(_voce_spec(), target_test_path=tmp_path / "test_gen_voce.py") + source = out.read_text(encoding="utf-8") + # The FD tolerance must be the standard-FD 1e-5, not the 1e-10 P4-2 gate. + assert FD_RTOL <= 1e-5 + assert f"FD_RTOL = {FD_RTOL!r}" in source + assert "1e-10" not in source + + def test_reference_test_uses_ten_sample_points(self, tmp_path: Path) -> None: + out = emit_tests(_voce_spec(), target_test_path=tmp_path / "test_gen_voce.py") + source = out.read_text(encoding="utf-8") + assert N_SAMPLE_POINTS == 10 + assert f"N_SAMPLE_POINTS = {N_SAMPLE_POINTS!r}" in source + + def test_taichi_smoke_block_is_guarded(self, tmp_path: Path) -> None: + out = emit_tests(_voce_spec(), target_test_path=tmp_path / "test_gen_voce.py") + source = out.read_text(encoding="utf-8") + assert "def test_taichi_smoke(" in source + assert 'pytest.importorskip("taichi")' in source + + +# --------------------------------------------------------------------------- +# Case 2 / Case 3 — monotonicity block presence is gated by monotone_check. +# --------------------------------------------------------------------------- + + +class TestMonotonicityGate: + def test_monotone_check_true_emits_block(self, tmp_path: Path) -> None: + out = emit_tests( + _voce_spec(monotone_check=True), target_test_path=tmp_path / "test_gen_voce.py" + ) + source = out.read_text(encoding="utf-8") + assert "def test_monotonicity(" in source + assert "not monotone" in source + + def test_monotone_check_false_omits_block(self, tmp_path: Path) -> None: + out = emit_tests( + _voce_spec(monotone_check=False), target_test_path=tmp_path / "test_gen_voce.py" + ) + source = out.read_text(encoding="utf-8") + assert "def test_monotonicity(" not in source + assert "not monotone" not in source + + +# --------------------------------------------------------------------------- +# Case 4 — the generated file is valid Python. +# --------------------------------------------------------------------------- + + +class TestGeneratedFileIsValidPython: + @pytest.mark.parametrize("monotone_check", [True, False]) + def test_ast_parse_succeeds(self, tmp_path: Path, monotone_check: bool) -> None: + out = emit_tests( + _voce_spec(monotone_check=monotone_check), + target_test_path=tmp_path / "test_gen_voce.py", + ) + source = out.read_text(encoding="utf-8") + # Must not raise SyntaxError. + tree = ast.parse(source) + # Sanity: it really is a module with the expected top-level test defs. + func_names = {node.name for node in tree.body if isinstance(node, ast.FunctionDef)} + assert {"test_reference_eval", "test_fd_derivative", "test_taichi_smoke"} <= func_names + + def test_generated_file_does_not_use_future_annotations(self, tmp_path: Path) -> None: + # PEP 563 stringizes annotations, which breaks Taichi kernel arg types — + # the generated file must NOT carry ``from __future__ import annotations``. + out = emit_tests(_voce_spec(), target_test_path=tmp_path / "test_gen_voce.py") + source = out.read_text(encoding="utf-8") + assert "from __future__ import annotations" not in source + + +# --------------------------------------------------------------------------- +# Strong bonus — actually RUN the generated file and assert it PASSES. +# --------------------------------------------------------------------------- + + +class TestGeneratedFileRuns: + @pytest.mark.parametrize( + ("spec_factory", "filename"), + [ + (lambda: _voce_spec(monotone_check=True), "test_gen_voce.py"), + (_rate_thermal_spec, "test_gen_rate_thermal.py"), + ], + ids=["voce_monotone", "rate_thermal"], + ) + def test_generated_tests_pass_under_pytest( + self, tmp_path: Path, spec_factory: object, filename: str + ) -> None: + """Run an emitted test file in a subprocess; expect all cases pass. + + Proves the emitted reference/FD/monotonicity tests are numerically correct, + not merely valid Python. The ``rate_thermal`` case exercises the H->edot and + Q->T FD checks with *non-zero* analytic derivatives (Voce's H/Q are + constant, i.e. derivative 0), so between them both FD regimes are covered. + The Taichi smoke test passes (Taichi installed) or skips (importorskip) — + both are non-failing, so the run returns exit code 0. + """ + out = emit_tests(spec_factory(), target_test_path=tmp_path / filename) # type: ignore[operator] + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + str(out), + "-p", + "no:cacheprovider", + "-o", + "addopts=", # ignore repo addopts (markers/coverage) for the isolated run + "-q", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, ( + f"generated test file failed under pytest:\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + +# --------------------------------------------------------------------------- +# Fail-loud (R2) — an unsupported node raises LawgenError, no file is written. +# --------------------------------------------------------------------------- + + +class TestFailLoud: + def test_unsupported_node_raises_lawgen_error(self, tmp_path: Path) -> None: + p, edot, T = sp.symbols("p edot T") + # ``erf`` is a defined SymPy function that is NOT in the Taichi allow-list. + bad = PlasticityCarrierSpec( + name="bad", + parameters=("a",), + expressions={"R": sp.erf(p), "H": sp.Integer(0), "Q": sp.Integer(1)}, + variable_bindings={"p": p, "edot": edot, "T": T}, + ) + target = tmp_path / "test_gen_bad.py" + with pytest.raises(LawgenError): + emit_tests(bad, target_test_path=target) + # No partial file was written (fail-loud before write). + assert not target.exists() diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/__init__.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-1.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-1.py new file mode 100644 index 0000000..5044137 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-1.py @@ -0,0 +1,84 @@ +"""Scaffold stubs for Task P1-1: TiconstitTarget profile + PlasticityCarrierSpec contract. + +Plan: dev/plans/mfront_cycleM0.md (lines 55-58) — MFront-mimic Cycle M0, Phase 1. +Deliverables under test (built in P1-1 exec): + packages/mechdsl-core/src/mechdsl/lawgen/{__init__,contracts}.py + +These are AutViam scaffold stubs — each `pytest.skip`s until P1-1 lands the +`mechdsl.lawgen` package, at which point ExecPhase replaces the bodies with real +assertions (mirroring tests/lawgen/test_contracts.py). One stub per test_plan case. +""" + +from __future__ import annotations + +import pytest +import sympy as sp + +from mechdsl.lawgen import PlasticityCarrierSpec, TiconstitTarget + + +class TestTaskP1_1: + """Tests for Task P1-1: TiconstitTarget + PlasticityCarrierSpec. AC covered: 1,2,3,4.""" + + @pytest.mark.unit + def test_ticonstit_target_all_defaults(self) -> None: + """Verifies: TiconstitTarget() instantiates with default fields. + AC1: contract_id == 'ticonstit.plasticity_carrier.v1', package == 'ticonstit.generated'. + Passes when: default instance carries the fixed contract id + package + ti_type_default.""" + target = TiconstitTarget() + assert target.contract_id == "ticonstit.plasticity_carrier.v1" + assert target.package == "ticonstit.generated" + assert target.ti_type_default == "ti.f64" + + @pytest.mark.unit + def test_ticonstit_target_overridden_budget_knobs(self) -> None: + """Verifies: budget knob fields override cleanly and default to the P2-2 constants. + AC3: budget knob defaults match P2-2 (max_expr_ops=400, max_cse_temps_per_func=96, + max_func_lines=220, max_total_generated_lines_per_class=900, max_piecewise_branches=8, + max_pow_with_symbolic_exponent=12). + Passes when: overrides take effect and defaults equal the six P2-2 limits.""" + # Defaults equal the six P2-2 limits. + default = TiconstitTarget() + assert default.max_expr_ops == 400 + assert default.max_cse_temps_per_func == 96 + assert default.max_func_lines == 220 + assert default.max_total_generated_lines_per_class == 900 + assert default.max_piecewise_branches == 8 + assert default.max_pow_with_symbolic_exponent == 12 + # Overrides take effect. + overridden = TiconstitTarget(max_expr_ops=42, max_piecewise_branches=3) + assert overridden.max_expr_ops == 42 + assert overridden.max_piecewise_branches == 3 + assert overridden.max_func_lines == 220 # untouched knob keeps its default + + @pytest.mark.unit + def test_plasticity_carrier_spec_rhq_expressions(self) -> None: + """Verifies: PlasticityCarrierSpec holds name, parameters, R/H/Q exprs, variable bindings. + AC2: spec round-trips name/parameters/expressions(R,H,Q)/variable_bindings. + Passes when: a spec built from SymPy R/H/Q expressions preserves every field.""" + p, edot, T = sp.symbols("p edot T") + sigma_y0, K, n = sp.symbols("sigma_y0 K n") + spec = PlasticityCarrierSpec( + name="voce", + parameters=("sigma_y0", "K", "n"), + expressions={ + "R": sigma_y0 + K * p**n, + "H": K * n * p ** (n - 1), + "Q": sp.Integer(1), + }, + variable_bindings={"p": p, "edot": edot, "T": T}, + ) + assert spec.name == "voce" + assert spec.parameters == ("sigma_y0", "K", "n") + assert sigma_y0 + K * p**n == spec.R + assert K * n * p ** (n - 1) == spec.H + assert sp.Integer(1) == spec.Q + assert set(spec.variable_bindings) == {"p", "edot", "T"} + + @pytest.mark.unit + def test_contract_id_validation_rejects_wrong_string(self) -> None: + """Verifies: contract_id validation rejects any string != the fixed contract id. + AC1: contract_id is validated at construction time. + Passes when: constructing TiconstitTarget with a wrong contract_id raises.""" + with pytest.raises(ValueError, match="contract_id"): + TiconstitTarget(contract_id="ticonstit.plasticity_carrier.v2") diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-2.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-2.py new file mode 100644 index 0000000..0518ab0 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-2.py @@ -0,0 +1,97 @@ +"""Plan-anchor tests for Task P1-2: mechdsl-lawgen compile CLI skeleton with dry-run. + +Plan: dev/plans/mfront_cycleM0.md (lines 59-62) — MFront-mimic Cycle M0, Phase 1. +Deliverables under test (built in P1-2 exec): + packages/mechdsl-core/src/mechdsl/lawgen/cli.py + `mechdsl-lawgen` entry point. + +These three tests anchor the plan's ``test_plan.cases`` (AC 1, 2, 3). The +exhaustive integration suite lives in ``tests/lawgen/test_cli.py``; here we pin +the acceptance criteria directly against ``mechdsl.lawgen.cli.main``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from mechdsl.lawgen.cli import main + +if TYPE_CHECKING: + from pathlib import Path + +_MINIMAL_LAW = ( + "name: linear_min\n" + "parameters: [sigma0, K, n]\n" + "variables: [p, edot, T]\n" + "expressions:\n" + ' R: "sigma0 + K*p**n"\n' + ' H: "1"\n' + ' Q: "1"\n' +) + + +class TestTaskP1_2: + """Tests for Task P1-2: mechdsl-lawgen compile CLI (dry-run). AC covered: 1,2,3.""" + + @pytest.mark.integration + def test_dryrun_minimal_yaml_emission_plan( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + """Verifies: `compile --target ticonstit --dry-run` prints the emission plan. + AC1: dry-run prints target contract_id, planned output paths, expressions to lower — + and writes no files. + Passes when: stdout contains the plan lines and the --out dir stays empty.""" + law = tmp_path / "linear_min.yaml" + law.write_text(_MINIMAL_LAW, encoding="utf-8") + out_dir = tmp_path / "out" + out_dir.mkdir() + + rc = main( + ["compile", str(law), "--target", "ticonstit", "--out", str(out_dir), "--dry-run"] + ) + + assert rc == 0 + out = capsys.readouterr().out + assert "ticonstit.plasticity_carrier.v1" in out # contract_id + assert "ticonstit.generated" in out # package + assert "plasticity/linear_min.py" in out # planned carrier path + assert "_manifest.json" in out # planned manifest entry + assert "R:" in out and "H:" in out and "Q:" in out # expressions to lower + # Dry-run writes nothing. + assert list(out_dir.iterdir()) == [] + + @pytest.mark.integration + def test_missing_yaml_key_exits_nonzero( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + """Verifies: a law YAML missing a required key exits non-zero with a human error. + AC2: invalid YAML → readable error, not a traceback. + Passes when: exit code != 0 and stderr carries a message naming the missing key.""" + law = tmp_path / "broken.yaml" + law.write_text("name: broken\nparameters: [K]\nvariables: [p]\n", encoding="utf-8") + + rc = main(["compile", str(law), "--target", "ticonstit", "--dry-run"]) + + assert rc != 0 + err = capsys.readouterr().err + assert "expressions" in err # names the missing key + assert "Traceback" not in err # readable error, not a traceback + + @pytest.mark.integration + def test_help_shows_compile_subcommand(self, capsys: pytest.CaptureFixture[str]) -> None: + """Verifies: `mechdsl-lawgen --help` (and `compile --help`) advertise the compile subcommand. + AC3: CLI is reachable after uv sync; compile subcommand documented. + Passes when: help text lists `compile` with --target/--out/--dry-run.""" + with pytest.raises(SystemExit) as top_exc: + main(["--help"]) + assert top_exc.value.code == 0 + assert "compile" in capsys.readouterr().out + + with pytest.raises(SystemExit) as sub_exc: + main(["compile", "--help"]) + assert sub_exc.value.code == 0 + sub_out = capsys.readouterr().out + assert "--target" in sub_out + assert "--out" in sub_out + assert "--dry-run" in sub_out diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-3.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-3.py new file mode 100644 index 0000000..8fd0db7 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-3.py @@ -0,0 +1,51 @@ +"""Scaffold stubs for Task P1-3: Reuse audit (REUSE.md) documenting MechDSL module composition. + +Plan: dev/plans/mfront_cycleM0.md (lines 63-65) — MFront-mimic Cycle M0, Phase 1. +Deliverable under test (built in P1-3 exec): + packages/mechdsl-core/src/mechdsl/lawgen/REUSE.md + +P1-3 is a doc-only task; these stubs assert the reuse-map artifact exists and covers +the four modules the ticonstit target composes. AutViam scaffold stubs — each +`pytest.skip`s until P1-3 writes REUSE.md; ExecPhase replaces the bodies with real +file assertions. One stub per test_plan case. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import mechdsl.lawgen + +# REUSE.md lives inside the importable ``mechdsl.lawgen`` package, so resolve it +# relative to the package directory (robust to cwd, mirroring the sibling +# plan_tests' package-import style rather than a hand-built source-tree path). +_REUSE_MD = Path(mechdsl.lawgen.__file__).resolve().parent / "REUSE.md" + + +class TestTaskP1_3: + """Tests for Task P1-3: REUSE.md reuse-map artifact. AC covered: 1,2,3.""" + + @pytest.mark.unit + def test_reuse_md_exists_and_nonempty(self) -> None: + """Verifies: lawgen/REUSE.md exists and is non-empty. + AC1/AC2: reuse-map committed into MechDSL. + Passes when: packages/mechdsl-core/src/mechdsl/lawgen/REUSE.md is present and > 0 bytes.""" + assert _REUSE_MD.is_file(), f"REUSE.md not found at {_REUSE_MD}" + assert _REUSE_MD.stat().st_size > 0, f"REUSE.md is empty at {_REUSE_MD}" + + @pytest.mark.unit + def test_reuse_md_mentions_all_four_modules(self) -> None: + """Verifies: REUSE.md names taichi_printer, artifact, lowering/, and the scaffold emitters. + AC1: every lawgen concern maps to an existing module (or is flagged as a P2 gap). + Passes when: all four module references appear in REUSE.md.""" + text = _REUSE_MD.read_text(encoding="utf-8") + # The scaffold reference may appear as either token; accept either. + scaffold_ok = "sympy_to_taichi" in text or "mechdsl_lawgen" in text + assert "taichi_printer" in text, "REUSE.md must mention taichi_printer" + assert "artifact" in text, "REUSE.md must mention artifact" + assert "lowering" in text, "REUSE.md must mention lowering" + assert scaffold_ok, ( + "REUSE.md must reference the scaffold (sympy_to_taichi or mechdsl_lawgen)" + ) diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-1.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-1.py new file mode 100644 index 0000000..282dbd6 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-1.py @@ -0,0 +1,77 @@ +"""Plan tests for Task P2-1: route expression lowering through a real printer. + +Plan: dev/plans/mfront_cycleM0.md (lines 76-78) — MFront-mimic Cycle M0, Phase 2. +Deliverable under test (built in P2-1 exec): + packages/mechdsl-core/src/mechdsl/lawgen/sympy_to_taichi.py + +Binding acceptance invariants (P1-3 REUSE.md, Gate-B-verified): the lowerer adds +a dedicated SymPy->Taichi printer (reusing the whitelist idea from +energy_emitter._MATH_TO_TAICHI + a StrPrinter subclass), NOT sp.pycode/regex (R4); +it applies deterministic sp.cse(order='canonical'); and CSE temporaries are +emitted before the return expressions. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import sympy as sp + +from mechdsl.lawgen import sympy_to_taichi as _lowerer_module +from mechdsl.lawgen.sympy_to_taichi import lower_expression + + +class TestTaskP2_1: + """Tests for Task P2-1: sympy_to_taichi lowerer (deterministic CSE). AC covered: 1-5.""" + + @pytest.mark.unit + def test_lower_simple_quadratic_to_taichi(self) -> None: + """Verifies: a simple SymPy expr lowers to the expected Taichi string. + AC3: output for a known expression matches the expected Taichi snippet (golden). + AC1: no pycode/re.sub is used in the lowerer module source.""" + x = sp.Symbol("x") + result = lower_expression(x**2 + 2 * x + 1) + + # Golden: no shared sub-expression, so no CSE temp; one return line. + # Since P2-4 the small-integer ``x**2`` is inlined to ``x*x`` (not ti.pow). + assert result.temporaries == () + assert result.returns == ("x*x + 2*x + 1",) + + # AC1: the R4 anti-pattern (pycode + regex substitution) is absent. + source = Path(_lowerer_module.__file__).read_text(encoding="utf-8") + assert "pycode" not in source + assert not re.search(r"re\.sub", source) + + @pytest.mark.unit + def test_repeated_subexpression_introduces_cse_temp(self) -> None: + """Verifies: a repeated sub-expression is factored into a CSE temporary. + AC1/AC4: sp.cse used (not pycode); CSE temporaries emitted before the return expr. + Passes when: an expr with a shared sub-term emits a temp assignment ahead of the result.""" + b, p = sp.symbols("b p") + shared = sp.exp(-b * p) + result = lower_expression(shared * (1 + shared)) + + # The shared exp(-b*p) is lifted to a temporary, printed as a ti.* call. + assert result.temporaries == ("x0 = ti.exp(-b*p)",) + assert result.returns == ("x0*(x0 + 1)",) + # AC4: the temporary assignment precedes and feeds the return expression. + assert result.temporaries[0].startswith("x0 = ") + assert "x0" in result.returns[0] + + @pytest.mark.unit + def test_cse_canonical_order_is_deterministic(self) -> None: + """Verifies: sp.cse(order='canonical') yields identical output across calls. + AC2: sp.cse is called with order='canonical' for determinism. + Passes when: lowering the same expr twice produces byte-identical emitted lines.""" + b, p, sigma0, Q, K, n = sp.symbols("b p sigma0 Q K n") + shared = sp.exp(-b * p) + exprs = [sigma0 + Q * (1 - shared) + K * p**n, Q * shared] + + first = lower_expression(exprs) + second = lower_expression(exprs) + + assert first == second + assert first.temporaries == second.temporaries + assert first.returns == second.returns diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-2.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-2.py new file mode 100644 index 0000000..eaaa571 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-2.py @@ -0,0 +1,141 @@ +"""Plan-anchor tests for Task P2-2: budget checks that fail emission before Taichi sees over-budget source. + +Plan: dev/plans/mfront_cycleM0.md (lines 79-82) — MFront-mimic Cycle M0, Phase 2. +Deliverable under test: + packages/mechdsl-core/src/mechdsl/lawgen/budgets.py + +The six limits are frozen and must match TiconstitTarget (P1-1) field names/defaults: +max_expr_ops=400, max_cse_temps_per_func=96, max_func_lines=220, +max_total_generated_lines_per_class=900, max_piecewise_branches=8, +max_pow_with_symbolic_exponent=12. + +P3-1 update (collect-all): ``check_all`` now accumulates EVERY budget violation +and raises one ``diagnostics.LawgenError`` carrying them all (each diagnostic's +``reason`` names the knob, the measured value, and the limit). The single-knob +fixtures below trip exactly one budget, so the aggregate carries one diagnostic. + +These are the seven test_plan.cases (one per budget knob + the compliant pass); +the exhaustive counter/hierarchy coverage lives in tests/lawgen/test_budgets.py. +""" + +from __future__ import annotations + +import pytest +import sympy as sp + +from mechdsl.lawgen.budgets import BudgetChecker +from mechdsl.lawgen.contracts import TiconstitTarget +from mechdsl.lawgen.diagnostics import LawgenError +from mechdsl.lawgen.sympy_to_taichi import LoweredExpr + + +def _lowered(n_temps: int = 0, n_returns: int = 1) -> LoweredExpr: + """A ``LoweredExpr`` with the requested temporary/return line counts.""" + return LoweredExpr( + temporaries=tuple(f"x{i} = 0.0" for i in range(n_temps)), + returns=tuple(f"r{i}" for i in range(n_returns)), + ) + + +class TestTaskP2_2: + """Tests for Task P2-2: budget checks. AC covered: fail-loud per knob + compliant pass. + + Under P3-1 the raised aggregate is ``LawgenError`` (collect-all); each + single-knob fixture trips exactly one budget, so the aggregate carries one + diagnostic whose ``reason`` names the knob + measured + limit. + """ + + @staticmethod + def _sole_reason(exc: LawgenError, knob: str) -> str: + """Assert the aggregate carries one diagnostic for ``knob`` and return its reason.""" + assert len(exc.diagnostics) == 1, [d.node for d in exc.diagnostics] + (diag,) = exc.diagnostics + assert diag.node == knob + assert diag.fix.strip() # actionable fix present + return diag.reason + + @pytest.mark.unit + def test_max_expr_ops_exceeded_raises_named_error(self) -> None: + """Verifies: exceeding max_expr_ops raises a LawgenError naming the knob + value + limit. + Passes when: an over-ops expr set raises with 'max_expr_ops' + measured + limit in the reason.""" + x = sp.Symbol("x") + checker = BudgetChecker(TiconstitTarget(max_expr_ops=2)) + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": x**2 + 2 * x + 1}, {"R": _lowered()}) + reason = self._sole_reason(exc.value, "max_expr_ops") + assert "4" in reason and "2" in reason # measured > limit + + @pytest.mark.unit + def test_max_cse_temps_per_func_exceeded_raises(self) -> None: + """Verifies: exceeding max_cse_temps_per_func raises a LawgenError. + Passes when: too many CSE temporaries trip the named budget (measured + limit in reason).""" + checker = BudgetChecker(TiconstitTarget(max_cse_temps_per_func=2)) + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": sp.Integer(1)}, {"R": _lowered(n_temps=3)}) + reason = self._sole_reason(exc.value, "max_cse_temps_per_func") + assert "3" in reason and "2" in reason + + @pytest.mark.unit + def test_max_func_lines_exceeded_raises(self) -> None: + """Verifies: exceeding max_func_lines raises a LawgenError. + Passes when: an over-length function trips the named budget (measured + limit in reason).""" + checker = BudgetChecker(TiconstitTarget(max_func_lines=3)) + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": sp.Integer(1)}, {"R": _lowered(n_temps=2, n_returns=2)}) + reason = self._sole_reason(exc.value, "max_func_lines") + assert "4" in reason and "3" in reason + + @pytest.mark.unit + def test_max_total_generated_lines_per_class_exceeded_raises(self) -> None: + """Verifies: exceeding max_total_generated_lines_per_class raises a LawgenError. + Passes when: an over-length class trips the named budget (measured + limit in reason).""" + checker = BudgetChecker( + TiconstitTarget(max_func_lines=5, max_total_generated_lines_per_class=5) + ) + lowered = { + "R": _lowered(n_temps=2, n_returns=1), # 3 lines + "H": _lowered(n_temps=2, n_returns=1), # 3 lines -> total 6 > 5 + } + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": sp.Integer(1), "H": sp.Integer(1)}, lowered) + reason = self._sole_reason(exc.value, "max_total_generated_lines_per_class") + assert "6" in reason and "5" in reason + + @pytest.mark.unit + def test_max_piecewise_branches_exceeded_raises(self) -> None: + """Verifies: exceeding max_piecewise_branches raises a LawgenError. + Passes when: a Piecewise with too many branches trips the named budget (measured + limit).""" + x = sp.Symbol("x") + piece = sp.Piecewise( + (x, x > 2), (2 * x, x > 1), (3 * x, x > 0), (sp.Integer(0), True) + ) # 4 branches + checker = BudgetChecker(TiconstitTarget(max_piecewise_branches=2)) + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": piece}, {"R": _lowered()}) + reason = self._sole_reason(exc.value, "max_piecewise_branches") + assert "4" in reason and "2" in reason + + @pytest.mark.unit + def test_max_pow_with_symbolic_exponent_exceeded_raises(self) -> None: + """Verifies: exceeding max_pow_with_symbolic_exponent raises a LawgenError. + Passes when: too many symbolic-exponent powers trip the named budget (measured + limit).""" + n = sp.Symbol("n") + bases = sp.symbols("a0:13") # 13 distinct symbols + expr = sp.Add(*[base**n for base in bases]) # 13 symbolic-exponent Pow nodes + checker = BudgetChecker(TiconstitTarget()) # default limit == 12 + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": expr}, {"R": _lowered()}) + reason = self._sole_reason(exc.value, "max_pow_with_symbolic_exponent") + assert "13" in reason and "12" in reason + + @pytest.mark.unit + def test_compliant_swift_voce_passes_check_all(self) -> None: + """Verifies: a compliant SwiftVoce expression set passes check_all with no error. + AC: budget knobs from TiconstitTarget (P1-1) override module defaults. + Passes when: check_all on in-budget expressions returns cleanly.""" + sigma0, Q, K, b, p, p0, n = sp.symbols("sigma0 Q K b p p0 n") + r = sigma0 + Q * (1 - sp.exp(-b * p)) + K * ((p + p0) ** n - p0**n) + exprs = {"R": r, "H": sp.Integer(1), "Q": sp.Integer(1)} + lowered = {role: _lowered(n_temps=1, n_returns=1) for role in exprs} + checker = BudgetChecker(TiconstitTarget()) + assert checker.check_all(exprs, lowered) is None diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-3.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-3.py new file mode 100644 index 0000000..2afee22 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-3.py @@ -0,0 +1,99 @@ +"""Plan tests for Task P2-3: numerical-guard injection (the key correctness task, plan risk R2). + +Plan: dev/plans/mfront_cycleM0.md (lines 83-86) — MFront-mimic Cycle M0, Phase 2. +Deliverable under test (built in P2-3 exec): + guard-injection logic in packages/mechdsl-core/src/mechdsl/lawgen/guard_transforms.py + + TaichiGuardedPrinter / lower_expression(guards=...) in sympy_to_taichi.py; + unit tests in packages/mechdsl-core/tests/lawgen/test_guard_injection.py. + +Guards reproduce Cycle 0 swift_voce.py hand-written guards: + pow(base, non-integer exp) -> ti.pow(ti.max(base, 1e-12), exp) (base floor, safe pattern) + log(x)/sqrt(x) -> ti.log/ti.sqrt(ti.max(x, 1e-12)) + division (variable denom) -> sign-preserving guard + ti.select(d >= 0, ti.max(d, 1e-12), ti.min(d, -1e-12)) + exp(...) -> UNGUARDED (matches the Voce idiom; the #1 risk) + +GOLDEN gate: the SwiftVoce R guard structure is asserted against string literals +transcribed from NumerixWeave libs/ticonstit/.../generated/plasticity/swift_voce.py +get_R (separate repo — NOT read at test time, R3). +""" + +from __future__ import annotations + +import pytest +import sympy as sp + +from mechdsl.lawgen.sympy_to_taichi import lower_expression + + +class TestTaskP2_3: + """Tests for Task P2-3: numerical-guard injection. AC covered: 1-5.""" + + @pytest.mark.unit + def test_pow_symbolic_exponent_gets_select_guard(self) -> None: + """Verifies: pow(x, alpha) with symbolic alpha emits a safe base-floor guard. + AC1: symbolic-exponent pow is wrapped in a safe pattern (base floored, not left bare). + Passes when: the emitted string floors the base ti.max(x, 1e-12) inside a ti.pow. + + (The plan title says "ti.select"; the AC allows "ti.select-wrapped form OR + equivalent safe pattern". swift_voce.py get_R/get_dR use the ti.max base-floor + — an equivalent safe pattern — so that is what is reproduced.)""" + x, alpha = sp.symbols("x alpha") + emitted = lower_expression(x**alpha).returns[0] + assert emitted == "ti.pow(ti.max(x, 1e-12), alpha)" + + @pytest.mark.unit + def test_log_argument_wrapped_with_ti_max(self) -> None: + """Verifies: log(x) emits ti.log(ti.max(x, 1e-12)). + AC2: log arguments domain-guarded. + Passes when: the emitted string contains ti.max(x, 1e-12) inside the log.""" + x = sp.Symbol("x") + emitted = lower_expression(sp.log(x)).returns[0] + assert emitted == "ti.log(ti.max(x, 1e-12))" + + @pytest.mark.unit + def test_sqrt_argument_wrapped_with_ti_max(self) -> None: + """Verifies: sqrt(x) emits ti.sqrt(ti.max(x, 1e-12)). + AC2: sqrt arguments domain-guarded. + Passes when: the emitted string contains ti.max(x, 1e-12) inside the sqrt.""" + x = sp.Symbol("x") + emitted = lower_expression(sp.sqrt(x)).returns[0] + assert emitted == "ti.sqrt(ti.max(x, 1e-12))" + + @pytest.mark.unit + def test_division_denominator_guarded(self) -> None: + """Verifies: 1/x guards the denominator with a SIGN-PRESERVING near-zero floor. + AC3: division denominators guarded (Gate-B Finding 1 — a sign-losing abs + floor would flip the sign of a/b for a runtime-negative denominator). + Passes when: the denominator is clamped to +/-1e-12 keeping its sign + (a no-op for |x| >= 1e-12).""" + x = sp.Symbol("x") + emitted = lower_expression(1 / x).returns[0] + assert emitted == "1/ti.select(x >= 0, ti.max(x, 1e-12), ti.min(x, -1e-12))" + # The naive sign-losing abs-floor form must NOT be used. + assert "ti.abs(x)" not in emitted + + @pytest.mark.unit + def test_golden_swift_voce_guard_structure(self) -> None: + """Verifies: lowering the SwiftVoce R expression reproduces Cycle 0's guard structure. + AC4: golden test against hand-written swift_voce.py get_R (string-pattern match). + AC5: the deliberately-unguardable exp stays bare; the Swift pow bases get floored. + Passes when: the generated guards match the hand-authored ones in swift_voce.py. + + Reference patterns transcribed from swift_voce.py get_R (NumerixWeave, NOT read + at test time — R3): base = ti.max(peeq + self.p0, 1e-12); p0_base = ti.max(self.p0, + 1e-12); ti.pow(base, self.n); ti.pow(p0_base, self.n); ti.exp(-self.b*peeq) bare.""" + sigma0, Qsat, b, peeq, K, p0, n = sp.symbols("sigma0 Qsat b peeq K p0 n") + R = sigma0 + Qsat * (1 - sp.exp(-b * peeq)) + K * ((peeq + p0) ** n - p0**n) + + emitted = lower_expression(R).returns[0] + + # Swift pow bases floored via ti.pow (the safe pattern). + assert "ti.pow(ti.max(p0 + peeq, 1e-12), n)" in emitted + assert "ti.pow(ti.max(p0, 1e-12), n)" in emitted + # exp is UNGUARDED (the #1 risk) — bare ti.exp, no ti.max on its arg. + assert "ti.exp(-b*peeq)" in emitted + assert "ti.max(-b*peeq, 1e-12)" not in emitted + # No un-guarded symbolic pow leaked through. + assert "peeq)**n" not in emitted + assert "p0**n" not in emitted diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-4.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-4.py new file mode 100644 index 0000000..0a6a512 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-4.py @@ -0,0 +1,107 @@ +"""Plan tests for Task P2-4: Taichi-safe lowering table + deterministic source hash. + +Plan: dev/plans/mfront_cycleM0.md (lines 87-90) — MFront-mimic Cycle M0, Phase 2. +Deliverable under test (built in P2-4 exec): + lowering table + source_hash in packages/mechdsl-core/src/mechdsl/lawgen/sympy_to_taichi.py; + packages/mechdsl-core/tests/lawgen/test_lowering_table.py + +Table: exp->ti.exp, Piecewise->nested ti.select (under branch budget), Pow(x,small_int)->mul. +source_hash = sha256 over the emitted lines in emission order (temporaries then returns). + +These plan-level assertions pin the six acceptance criteria; the exhaustive +behaviour (thresholds, boundaries, budget override, guarded branches) lives in +tests/lawgen/test_lowering_table.py. +""" + +from __future__ import annotations + +import re + +import pytest +import sympy as sp + +from mechdsl.lawgen.diagnostics import LawgenError +from mechdsl.lawgen.sympy_to_taichi import lower_expression + + +class TestTaskP2_4: + """Tests for Task P2-4: Taichi-safe lowering table + source hash. AC covered: 1-6.""" + + @pytest.mark.unit + def test_exp_lowers_to_ti_exp(self) -> None: + """Verifies: sp.exp(x) lowers to 'ti.exp(x)' (no raw 'exp'). + AC1: exp->ti.exp mapping. + Passes when: the emitted output contains 'ti.exp(x)' and no bare 'exp('.""" + x = sp.Symbol("x") + emitted = lower_expression(sp.exp(x)).returns[0] + + assert emitted == "ti.exp(x)" + # No bare ``exp(`` outside the ``ti.exp`` call. + assert emitted.replace("ti.exp(", "") == "x)" + + @pytest.mark.unit + def test_piecewise_within_budget_becomes_nested_select(self) -> None: + """Verifies: a Piecewise with <= max_piecewise_branches lowers to nested ti.select. + AC2: Piecewise -> nested ti.select under branch budget. + Passes when: a 3-branch Piecewise emits a nested ti.select chain.""" + x, y = sp.symbols("x y") + piece = sp.Piecewise((x, x > 0), (y, x < 0), (0, True)) + emitted = lower_expression(piece).returns[0] + + assert emitted == "ti.select(x > 0, x, ti.select(x < 0, y, 0))" + # 3 branches → 2 nested selects (right-nested chain). + assert emitted.count("ti.select") == 2 + + @pytest.mark.unit + def test_piecewise_over_budget_raises(self) -> None: + """Verifies: a Piecewise exceeding the branch budget fails loud (P2-2 budget, P3-1 aggregate). + AC3: over-budget Piecewise fails loud. + Passes when: a 9-branch Piecewise raises a LawgenError whose budget diagnostic + names the knob + measured (9) + limit (8).""" + a = sp.Symbol("a") + pairs = [(sp.Integer(i), a > i) for i in range(8)] + pairs.append((sp.Integer(99), sp.true)) + piece = sp.Piecewise(*pairs) + assert len(piece.args) == 9 + + with pytest.raises(LawgenError) as exc: + lower_expression(piece) + (diag,) = exc.value.diagnostics + assert diag.node == "max_piecewise_branches" + assert "max_piecewise_branches budget exceeded: 9 > 8" in diag.reason + + @pytest.mark.unit + def test_small_int_pow_inlined_as_multiplication(self) -> None: + """Verifies: Pow(x, 2) inlines to multiplication (x*x), not ti.pow. + AC4: Pow(x, small_int) -> multiplication (threshold documented). + Passes when: the emitted output for x**2 is 'x*x' (or inlined), not a ti.pow call.""" + x = sp.Symbol("x") + emitted = lower_expression(x**2).returns[0] + + assert emitted == "x*x" + assert "ti.pow" not in emitted + assert "**" not in emitted + + @pytest.mark.unit + def test_same_input_yields_same_source_hash(self) -> None: + """Verifies: lowering the same spec twice produces an identical source_hash. + AC5: deterministic source hash. + Passes when: two lower_expression calls on the same input hash-match.""" + sigma0, Q, b, p, K, n, p0 = sp.symbols("sigma0 Q b p K n p0") + expr = sigma0 + Q * (1 - sp.exp(-b * p)) + K * ((p + p0) ** n - p0**n) + + first = lower_expression(expr) + second = lower_expression(expr) + + assert first.source_hash == second.source_hash + + @pytest.mark.unit + def test_source_hash_is_64_hex_chars(self) -> None: + """Verifies: source_hash is a 64-char hex sha256 string. + AC6: source_hash format. + Passes when: the hash matches ^[0-9a-f]{64}$.""" + x = sp.Symbol("x") + result = lower_expression(sp.exp(x) + x**2) + + assert re.match(r"^[0-9a-f]{64}$", result.source_hash) + assert len(result.source_hash) == 64 diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-1.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-1.py new file mode 100644 index 0000000..ce596d2 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-1.py @@ -0,0 +1,115 @@ +"""Plan-anchor tests for Task P3-1: structured diagnostics for unsupported nodes + budget breaches. + +Plan: dev/plans/mfront_cycleM0.md (lines 98-100) — MFront-mimic Cycle M0, Phase 3. +Deliverable under test: + packages/mechdsl-core/src/mechdsl/lawgen/diagnostics.py + +LawgenDiagnostic(law, expression, node, reason, fix) — all 5 required strings. +DiagnosticCollector.add + raise_if_any() -> LawgenError (collect-all, no silent drop, R2). +P3-1 wires the collector into P2-2 (budget) + P2-4 (lowering unsupported-node path). + +The four test_plan.cases (exhaustive API/branch coverage lives in +tests/lawgen/test_diagnostics.py): +1. two unsupported nodes → both appear in one LawgenError.args +2. budget breach → diagnostic reason contains measured value + limit +3. no diagnostics → raise_if_any() is a no-op +4. fix field is non-empty for every diagnostic type +""" + +from __future__ import annotations + +import pytest +import sympy as sp + +from mechdsl.lawgen.budgets import BudgetChecker +from mechdsl.lawgen.contracts import TiconstitTarget +from mechdsl.lawgen.diagnostics import DiagnosticCollector, LawgenError +from mechdsl.lawgen.sympy_to_taichi import LoweredExpr, lower_expression + + +def _lowered(n_temps: int = 0, n_returns: int = 1) -> LoweredExpr: + """A ``LoweredExpr`` with the requested temporary/return line counts.""" + return LoweredExpr( + temporaries=tuple(f"x{i} = 0.0" for i in range(n_temps)), + returns=tuple(f"r{i}" for i in range(n_returns)), + ) + + +class TestTaskP3_1: + """Tests for Task P3-1: structured diagnostics. AC covered: 1-4.""" + + @pytest.mark.unit + def test_two_unsupported_nodes_both_reported(self) -> None: + """Verifies: two distinct unsupported-node diagnostics both surface in one LawgenError. + AC2: collect-all — no silent drop. + Passes when: LawgenError.args (and .diagnostics) contains both diagnostics.""" + x = sp.Symbol("x") + foo = sp.Function("foo") + bar = sp.Function("bar") + + with pytest.raises(LawgenError) as exc: + lower_expression(foo(x) + bar(x)) + + nodes = sorted(d.node for d in exc.value.diagnostics) + assert nodes == ["bar", "foo"] # both collected, neither dropped + # Both also discoverable off .args (the acceptance surface). + arg_text = " ".join(str(a) for a in exc.value.args) + assert "foo" in arg_text and "bar" in arg_text + + @pytest.mark.unit + def test_budget_breach_reason_has_measured_and_limit(self) -> None: + """Verifies: a budget-breach diagnostic's `reason` names the measured value and the limit. + AC3: budget diagnostic reason includes limit + measured. + Passes when: the diagnostic reason string contains both numbers.""" + x = sp.Symbol("x") + checker = BudgetChecker(TiconstitTarget(max_expr_ops=2)) + + with pytest.raises(LawgenError) as exc: + checker.check_all({"R": x**2 + 2 * x + 1}, {"R": _lowered()}) # 4 ops > 2 + + (diag,) = exc.value.diagnostics + assert diag.node == "max_expr_ops" + assert "4" in diag.reason # measured + assert "2" in diag.reason # limit + + @pytest.mark.unit + def test_no_diagnostics_raise_if_any_is_noop(self) -> None: + """Verifies: raise_if_any() is a no-op when no diagnostics were collected. + Passes when: an empty collector's raise_if_any() returns without raising.""" + collector = DiagnosticCollector() + assert collector.raise_if_any() is None + assert not collector + + @pytest.mark.unit + def test_fix_field_non_empty_for_every_diagnostic_type(self) -> None: + """Verifies: the `fix` field is a non-empty, actionable string for every supported diagnostic. + AC1: LawgenDiagnostic has all five fields incl. a meaningful `fix`. + Passes when: every emitted diagnostic type (unsupported node, non-exhaustive Piecewise, + each budget knob) has a non-empty fix.""" + x, y, n = sp.symbols("x y n") + + # (a) unsupported node. + with pytest.raises(LawgenError) as exc_node: + lower_expression(sp.Function("foo")(x)) + assert all(d.fix.strip() for d in exc_node.value.diagnostics) + + # (b) non-exhaustive Piecewise. + with pytest.raises(LawgenError) as exc_pw: + lower_expression(sp.Piecewise((x, x > 0))) + assert all(d.fix.strip() for d in exc_pw.value.diagnostics) + + # (c) all six budget knobs at once. + target = TiconstitTarget( + max_expr_ops=1, + max_cse_temps_per_func=1, + max_func_lines=1, + max_total_generated_lines_per_class=1, + max_piecewise_branches=1, + max_pow_with_symbolic_exponent=1, + ) + piece = sp.Piecewise((x**n, x > 0), (y**n, x < 0), (sp.Integer(0), True)) + with pytest.raises(LawgenError) as exc_budget: + BudgetChecker(target).check_all({"R": piece}, {"R": _lowered(n_temps=3, n_returns=2)}) + emitted_knobs = {d.node for d in exc_budget.value.diagnostics} + assert len(emitted_knobs) == 6 # all six knob types emitted + assert all(d.fix.strip() for d in exc_budget.value.diagnostics) diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-2.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-2.py new file mode 100644 index 0000000..45d842a --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-2.py @@ -0,0 +1,90 @@ +"""Plan tests for Task P3-2: generated-tests emitter per scalar law. + +Plan: dev/plans/mfront_cycleM0.md (lines 101-103) — MFront-mimic Cycle M0, Phase 3. +Deliverable under test: + packages/mechdsl-core/src/mechdsl/lawgen/test_emitter.py + +emit_tests(spec, ..., target_test_path) writes a VALID-Python pytest file (must pass +ast.parse) with: a Python reference eval (lambdify over the spec's symbol map), an +FD-derivative comparison (rtol <= 1e-5 — NOT the 1e-10 P4-2 gate), an optional +monotonicity assertion (iff spec.monotone_check), and an optional guarded Taichi JIT +smoke test. De-stubbed for the P3-2 exec. +""" + +from __future__ import annotations + +import ast +from typing import TYPE_CHECKING + +import pytest +import sympy as sp + +from mechdsl.lawgen.contracts import PlasticityCarrierSpec +from mechdsl.lawgen.test_emitter import emit_tests + +if TYPE_CHECKING: + from pathlib import Path + + +def _voce_spec(*, monotone_check: bool = False) -> PlasticityCarrierSpec: + """A Voce + power-law hardening carrier (monotone in ``p``).""" + p, edot, T = sp.symbols("p edot T") + sigma_y0, Q, b, K, n = sp.symbols("sigma_y0 Q b K n") + R = sigma_y0 + Q * (1 - sp.exp(-b * p)) + K * p**n + return PlasticityCarrierSpec( + name="voce", + parameters=("sigma_y0", "Q", "b", "K", "n"), + expressions={"R": R, "H": sp.diff(R, p), "Q": sp.Integer(1)}, + variable_bindings={"p": p, "edot": edot, "T": T}, + monotone_check=monotone_check, + ) + + +class TestTaskP3_2: + """Tests for Task P3-2: generated-tests emitter. AC covered: 1-5.""" + + @pytest.mark.integration + def test_emitted_file_has_reference_and_fd_tests(self, tmp_path: Path) -> None: + """AC2/AC5: reference eval + FD derivative (rtol <= 1e-5) test functions present. + + The FD test covers all three factors R/H/Q (hardening/rate/thermal), each + vs its own analytic derivative — not H conflated with d(R)/dp. + """ + out = emit_tests(_voce_spec(), target_test_path=tmp_path / "test_gen.py") + source = out.read_text(encoding="utf-8") + assert "def test_reference_eval(" in source + assert "def test_fd_derivative(" in source + # All three per-factor FD cases (role, srepr, own-primary) must be emitted. + assert "('R', 'R_SREPR', 'p')" in source + assert "('H', 'H_SREPR', 'edot')" in source + assert "('Q', 'Q_SREPR', 'T')" in source + # FD tolerance is the standard-FD 1e-5, never the 1e-10 P4-2 equivalence gate. + assert "FD_RTOL = 1e-05" in source + assert "1e-10" not in source + + @pytest.mark.integration + def test_monotone_check_true_emits_monotonicity_assertion(self, tmp_path: Path) -> None: + """AC3: monotonicity test present iff monotone_check is True.""" + out = emit_tests(_voce_spec(monotone_check=True), target_test_path=tmp_path / "test_gen.py") + source = out.read_text(encoding="utf-8") + assert "def test_monotonicity(" in source + assert "not monotone" in source + + @pytest.mark.integration + def test_monotone_check_false_omits_monotonicity(self, tmp_path: Path) -> None: + """AC3: no monotonicity block when the flag is off.""" + out = emit_tests( + _voce_spec(monotone_check=False), target_test_path=tmp_path / "test_gen.py" + ) + source = out.read_text(encoding="utf-8") + assert "def test_monotonicity(" not in source + + @pytest.mark.integration + def test_generated_file_is_valid_python(self, tmp_path: Path) -> None: + """AC1: generated file is valid Python; the Taichi block is guarded.""" + out = emit_tests(_voce_spec(monotone_check=True), target_test_path=tmp_path / "test_gen.py") + source = out.read_text(encoding="utf-8") + # Must not raise SyntaxError. + ast.parse(source) + # AC4: the Taichi JIT smoke test is guarded (skips without Taichi). + assert 'pytest.importorskip("taichi")' in source diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-3.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-3.py new file mode 100644 index 0000000..bf7e910 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-3.py @@ -0,0 +1,110 @@ +"""Plan-tests for Task P3-3: manifest emitter matching Cycle 0 _manifest.json schema. + +Plan: dev/plans/mfront_cycleM0.md (lines 104-106) — MFront-mimic Cycle M0, Phase 3. +Deliverable under test: mechdsl.lawgen.manifest — emit_manifest(spec, ...) + write_manifest(...). + +Six fields matching Cycle 0 _manifest.json: source_hash, generated_by, +target_contract, exports, parameters, tests (the real Cycle 0 laws entry has nine +fields — name/kind/source added — this asserts the six the AC names are present). +generated_by = "mechdsl-lawgen/". + +⚠️ RECONCILIATION (from Phase-2 handoff, RESOLVED in P3-3): the AC text says +source_hash matches LoweredResult.source_hash (emitted-lines hash), but Cycle 0's +manifest source_hash is the hash of the canonical INPUT formula string. P3-3 +reconciled to Cycle 0's convention (manifest.compute_input_formula_hash hashes the +input formula verbatim/UTF-8) so P4-2 can byte-verify against the real +_manifest.json. These tests assert against the reconciled input-formula convention. +Cross-repo discipline (R3): the Cycle 0 published hash is transcribed as a literal +constant here, never imported from NumerixWeave. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest +import sympy as sp + +from mechdsl.lawgen.contracts import PlasticityCarrierSpec +from mechdsl.lawgen.manifest import ( + compute_input_formula_hash, + emit_manifest, + write_manifest, +) + +if TYPE_CHECKING: + from pathlib import Path + +# Cycle 0's canonical SwiftVoce R formula string and its published source_hash +# (transcribed from NumerixWeave libs/ticonstit/.../generated/_manifest.json — read +# as data, never imported: R3). The input-formula-hash convention reproduces it. +_CYCLE0_R_FORMULA = "R = sigma0 + Q*(1-exp(-b*p)) + K*((p+p0)**n - p0**n)" +_CYCLE0_R_SOURCE_HASH = "7b5af3a8bb79c2e44e0055a7076dd2c9de2ce8c75eb2e262b80bb4e0232d557f" + + +def _swift_voce_entry() -> dict[str, object]: + """emit_manifest for a SwiftVoce carrier matching Cycle 0's R formula.""" + p, edot, T = sp.symbols("p edot T") + sigma0, Q, b, K, p0, n = sp.symbols("sigma0 Q b K p0 n") + R = sigma0 + Q * (1 - sp.exp(-b * p)) + K * ((p + p0) ** n - p0**n) + spec = PlasticityCarrierSpec( + name="swift_voce", + parameters=("sigma0", "Q", "b", "K", "p0", "n"), + expressions={"R": R, "H": sp.Integer(1), "Q": sp.Integer(1)}, + variable_bindings={"p": p, "edot": edot, "T": T}, + ) + return emit_manifest( + spec, + input_formula=_CYCLE0_R_FORMULA, + target_contract="SwiftVoce", + exports="SwiftVoce", + source="swift_voce.py", + tests=["tests/generated/test_swift_voce.py"], + ) + + +class TestTaskP3_3: + """Tests for Task P3-3: manifest emitter. AC covered: 1-5.""" + + @pytest.mark.unit + def test_manifest_has_all_six_required_fields(self) -> None: + """Verifies: the emitted manifest entry has all six Cycle-0 fields. + AC2/AC4: source_hash, generated_by, target_contract, exports, parameters, tests. + Passes when: every one of the six keys is present.""" + entry = _swift_voce_entry() + for required_field in ( + "source_hash", + "generated_by", + "target_contract", + "exports", + "parameters", + "tests", + ): + assert required_field in entry, f"manifest entry missing {required_field!r}" + + @pytest.mark.unit + def test_manifest_source_hash_matches_lowered_result(self) -> None: + """Verifies: the manifest's source_hash is the compile source hash. + AC3 (reconciled to Cycle 0): source_hash is the hash of the canonical INPUT + formula string, and it reproduces Cycle 0's published value. + Passes when: source_hash == compute_input_formula_hash(formula) == Cycle 0 hash.""" + entry = _swift_voce_entry() + assert entry["source_hash"] == compute_input_formula_hash(_CYCLE0_R_FORMULA) + assert entry["source_hash"] == _CYCLE0_R_SOURCE_HASH + + @pytest.mark.unit + def test_generated_by_contains_mechdsl_lawgen(self) -> None: + """Verifies: generated_by names the generator. + AC: generated_by contains 'mechdsl-lawgen'. + Passes when: manifest['generated_by'] contains 'mechdsl-lawgen'.""" + assert "mechdsl-lawgen" in str(_swift_voce_entry()["generated_by"]) + + @pytest.mark.unit + def test_manifest_is_valid_json(self, tmp_path: Path) -> None: + """Verifies: the emitted _manifest.json is valid, loadable JSON. + AC1: manifest is valid JSON. + Passes when: json.loads of the written manifest succeeds and carries the law.""" + out = write_manifest([_swift_voce_entry()], tmp_path / "_manifest.json") + loaded = json.loads(out.read_text(encoding="utf-8")) + assert loaded["laws"][0]["source_hash"] == _CYCLE0_R_SOURCE_HASH diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-1.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-1.py new file mode 100644 index 0000000..af09cb1 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-1.py @@ -0,0 +1,233 @@ +"""Plan-tests for Task P4-1: author swift_voce.yaml and compile end-to-end. + +Plan: dev/plans/mfront_cycleM0.md (lines 114-116) — MFront-mimic Cycle M0, Phase 4. +Deliverable under test: laws/plasticity/swift_voce.yaml (MechDSL) + the +mechdsl-lawgen compile pipeline producing swift_voce.py + _manifest.json + a +generated test file for the SwiftVoce hardening law. + +This is the MechDSL-side integration test. It exercises the full Phase 1-3 +pipeline (PlasticityCarrierSpec load -> lower_expression -> budgets/guards -> +carrier + manifest + test emit) via the CLI ``main`` entry point and asserts +byte-stable output with the canonical Cycle 0 source_hash. + +⚠️ EXEC-TIME CONSTRAINTS (see Phase_4_Scaffold_Validation.md): + * PATH COLLISION — the Cycle 0 hand-authored reference already lives at + NumerixWeave libs/ticonstit/.../generated/plasticity/swift_voce.py + (source_hash 7b5af3a8...). P4-1 must NOT clobber it; these tests emit the + candidate to a pytest ``tmp_path`` so nothing in NumerixWeave is touched. + * PARAMETER SET — to reproduce Cycle 0's source_hash the YAML yields the + canonical formula R = sigma0 + Q*(1-exp(-b*p)) + K*((p+p0)**n - p0**n) + (params sigma0, Q, b, K, n, p0). + * R3 — this test runs from the MechDSL venv (never NumerixWeave .venv). +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from mechdsl.lawgen.carrier_emitter import snake_case_module_name +from mechdsl.lawgen.cli import load_carrier_spec, main +from mechdsl.lawgen.contracts import PlasticityCarrierSpec + +if TYPE_CHECKING: + from types import ModuleType + +# The authoritative law YAML this task ships (repo-relative). This test file is +# packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-1.py, so the repo +# root is five parents up (mfront_cyclem0 → plan_tests → tests → mechdsl-core → +# packages → ). +_REPO_ROOT = Path(__file__).resolve().parents[5] +SWIFT_VOCE_YAML = _REPO_ROOT / "laws" / "plasticity" / "swift_voce.yaml" + +# Cycle 0's published source_hash — the SHA-256 of the canonical generator-input +# formula string ``"R = sigma0 + Q*(1-exp(-b*p)) + K*((p+p0)**n - p0**n)"``. +CANONICAL_SOURCE_HASH = "7b5af3a8bb79c2e44e0055a7076dd2c9de2ce8c75eb2e262b80bb4e0232d557f" + + +def _compile_to(out_dir: Path) -> int: + """Invoke the CLI compile on the shipped YAML, emitting to ``out_dir``.""" + return main(["compile", str(SWIFT_VOCE_YAML), "--target", "ticonstit", "--out", str(out_dir)]) + + +class TestTaskP4_1: + """Tests for Task P4-1: swift_voce.yaml + end-to-end compile. AC covered: 1-5.""" + + @pytest.mark.integration + def test_swift_voce_yaml_parses_into_carrier_spec(self) -> None: + """Verifies: laws/plasticity/swift_voce.yaml loads into PlasticityCarrierSpec. + AC1: the YAML loads without error. + Passes when: the loader returns a PlasticityCarrierSpec with R/H/Q expressions.""" + spec = load_carrier_spec(SWIFT_VOCE_YAML) + assert isinstance(spec, PlasticityCarrierSpec) + assert spec.name == "SwiftVoce" + # The canonical Cycle 0 parameter set (Q, not the placeholder epsilon0). + assert spec.parameters == ("sigma0", "Q", "b", "K", "n", "p0") + # All three role expressions are present; H/Q are the neutral rate/thermal + # factors (== 1), NOT dR/dp. + assert set(spec.expressions) == {"R", "H", "Q"} + assert spec.H == 1 + assert spec.Q == 1 + # R references every material parameter. + assert {s.name for s in spec.R.free_symbols} >= {"sigma0", "Q", "b", "K", "n", "p0"} + + @pytest.mark.integration + def test_compile_dry_run_writes_nothing( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + """Verifies: mechdsl-lawgen compile --dry-run succeeds and writes no files. + AC2: compile exits 0 in dry-run. + Passes when: exit code 0 and the target dir is unchanged.""" + out = tmp_path / "generated" + rc = main( + [ + "compile", + str(SWIFT_VOCE_YAML), + "--target", + "ticonstit", + "--out", + str(out), + "--dry-run", + ] + ) + assert rc == 0 + printed = capsys.readouterr().out + assert "emission plan (dry-run" in printed + assert "SwiftVoce" in printed + # Dry-run must not create the output directory or any artifact. + assert not out.exists() + + @pytest.mark.integration + def test_compile_emits_swift_voce_and_manifest(self, tmp_path: Path) -> None: + """Verifies: compile produces swift_voce.py + _manifest.json + test file. + AC3/AC4: the emitted module and manifest are written to the (scratch) target + and the manifest carries the correct source_hash. + Passes when: all three artifacts exist and manifest source_hash == 7b5af3a8...""" + out = tmp_path / "generated" + rc = _compile_to(out) + assert rc == 0 + + module = snake_case_module_name("SwiftVoce") + carrier = out / "plasticity" / f"{module}.py" + manifest = out / "_manifest.json" + test_file = out / "tests" / f"test_{module}.py" + + # AC3: all three artifacts exist. + assert carrier.is_file() + assert manifest.is_file() + assert test_file.is_file() + + # The carrier is snake_case (swift_voce.py) but exports the CamelCase class. + assert carrier.name == "swift_voce.py" + carrier_text = carrier.read_text(encoding="utf-8") + assert f"source_hash: {CANONICAL_SOURCE_HASH}" in carrier_text + assert "class SwiftVoce:" in carrier_text + # INV-DG-1: the generated runtime carrier imports Taichi only. + assert "import taichi as ti" in carrier_text + for forbidden in ("import sympy", "import mechdsl", "import ticonstit"): + assert forbidden not in carrier_text + + # AC4: the manifest carries the canonical Cycle 0 source_hash + schema. + doc = json.loads(manifest.read_text(encoding="utf-8")) + entry = doc["laws"][0] + assert entry["name"] == "SwiftVoce" + assert entry["source"] == "swift_voce.py" + assert entry["exports"] == "SwiftVoce" + assert entry["source_hash"] == CANONICAL_SOURCE_HASH + assert entry["target_contract"] == "VoceHardeningModel" + assert entry["parameters"]["required"] == ["sigma0", "Q", "b"] + assert entry["parameters"]["optional"] == ["K", "n", "p0"] + + @pytest.mark.integration + def test_compile_is_byte_stable_across_two_runs(self, tmp_path: Path) -> None: + """Verifies: re-running compile yields byte-identical swift_voce.py. + AC5: determinism — two runs produce identical file content + source_hash. + Passes when: the two emitted files compare byte-equal.""" + module = snake_case_module_name("SwiftVoce") + out_a = tmp_path / "run_a" + out_b = tmp_path / "run_b" + assert _compile_to(out_a) == 0 + assert _compile_to(out_b) == 0 + + for relative in ( + Path("plasticity") / f"{module}.py", + Path("_manifest.json"), + Path("tests") / f"test_{module}.py", + ): + bytes_a = (out_a / relative).read_bytes() + bytes_b = (out_b / relative).read_bytes() + assert bytes_a == bytes_b, f"{relative} is not byte-stable across two compiles" + + @pytest.mark.slow + @pytest.mark.integration + def test_generated_smoke_kernel_runs(self, tmp_path: Path) -> None: + """Verifies: the emitted test file's JIT smoke kernel actually compiles + runs. + + Regression guard for the smoke-kernel symbol-form bug: the generated + ``test_taichi_smoke`` builds a ``@ti.kernel`` that pins every *bare* + material-parameter name to a placeholder local and returns the lowered R. + If the compiler ever feeds the carrier's rebound ``self.`` R into + ``emit_tests`` again, the kernel references ``self`` with no ``self`` in + scope and Taichi raises ``TaichiNameError``. This test loads the emitted + file as a module and executes ``test_taichi_smoke`` (which JIT-compiles and + calls the kernel), so such a regression fails loudly here rather than only + in the shipped artifact. Marked ``slow`` — it invokes the Taichi JIT.""" + pytest.importorskip("taichi") + module = snake_case_module_name("SwiftVoce") + out = tmp_path / "generated" + assert _compile_to(out) == 0 + + test_file = out / "tests" / f"test_{module}.py" + generated = _load_generated_module(test_file, "generated_test_swift_voce") + + # The generated smoke test itself JIT-compiles the lowered R kernel and + # asserts the result is finite. Running it here fails on a self/peeq leak. + generated.test_taichi_smoke() + + @pytest.mark.integration + def test_compile_enables_formula_matches_spec_guard( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Verifies: the CLI real-emission path passes ``check_matches_spec=True`` to + ``emit_manifest``, so the hashed input_formula is validated against ``spec.R``. + + Wiring guard: the emitted swift_voce.yaml is internally consistent (param + ``Q`` in both formula and parameters), so the check passes on the happy path + (covered by the other tests). Here we force ``formula_matches_spec`` to return + ``False``; the compile must then fail loud (exit non-zero, spec-mismatch + error, no files written). If ``check_matches_spec`` were dropped from the CLI + call, the guard would never be consulted and the compile would still succeed — + so this test fails, catching the regression.""" + import mechdsl.lawgen.manifest as manifest_mod + + monkeypatch.setattr(manifest_mod, "formula_matches_spec", lambda *_a, **_k: False) + out = tmp_path / "generated" + rc = _compile_to(out) + assert rc != 0, "compile should fail when the formula does not match spec.R" + assert "not symbolically equal" in capsys.readouterr().err + # Fail-loud path writes nothing. + assert ( + not (out / "plasticity" / snake_case_module_name("SwiftVoce")) + .with_suffix(".py") + .exists() + ) + + +def _load_generated_module(path: Path, module_name: str) -> ModuleType: + """Import an emitted Python file as a module so its functions can be called. + + Used to execute the generated ``test_taichi_smoke`` in-process — the emitted + file must be a valid, runnable Python module, and importing it here is what + genuinely exercises the generated kernel (not just an existence check).""" + spec = importlib.util.spec_from_file_location(module_name, path) + assert spec is not None and spec.loader is not None, f"could not load {path}" + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-2.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-2.py new file mode 100644 index 0000000..f988918 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-2.py @@ -0,0 +1,36 @@ +"""Plan-tests for Task P4-2: equivalence gate (emitted SwiftVoce vs Cycle 0 class). + +Plan: dev/plans/mfront_cycleM0.md (lines 117-119) — MFront-mimic Cycle M0, Phase 4. +Deliverable under test: NumerixWeave +libs/ticonstit/tests/generated/test_swift_voce_equivalence.py. + +⚠️ CROSS-REPO / R3: the equivalence gate compares the *emitted* SwiftVoce against +Cycle 0's hand-authored SwiftVoce (and, at K=0, VoceHardeningModel) to rtol=1e-10. +Both classes are Taichi @ti.func code that lives in NumerixWeave and can only be +instantiated inside the NumerixWeave uv venv — importing ticonstit eagerly loads +Taichi (Cycle 0 P1-2). It therefore CANNOT run inside the MechDSL venv, so this +MechDSL-side entry is a documentation/skip marker only. The real assertions live in +the NumerixWeave test file above and are exercised by: + + cd /Users/shmuelosovski/Github/Personal/NumerixWeave \\ + && uv run pytest libs/ticonstit/tests/generated/test_swift_voce_equivalence.py -v + +The comparison is NUMERICAL (rtol=1e-10), not byte/AST — batched CSE reorganizes +derivative structure (Phase-2 handoff note 2) while staying numerically equal. +""" + +from __future__ import annotations + +import pytest + + +class TestTaskP4_2: + """Equivalence gate marker. Real gate runs in NumerixWeave venv. AC covered: 1-4.""" + + @pytest.mark.integration + def test_equivalence_gate_runs_in_numerixweave(self) -> None: + """Verifies (cross-repo): emitted SwiftVoce matches the hand-authored class + for R/H/Q at N=20 sample points, at K=0 matches VoceHardeningModel, and the + source_hash is stable across two compile runs — all to rtol=1e-10. + Passes when: the NumerixWeave equivalence test passes (run there, not here).""" + pytest.skip("stub — cross-repo gate; runs in NumerixWeave .venv (R3)") diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-3.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-3.py new file mode 100644 index 0000000..79b7b98 --- /dev/null +++ b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-3.py @@ -0,0 +1,39 @@ +"""Plan-tests for Task P4-3: document release ordering (cross-repo build edge). + +Plan: dev/plans/mfront_cycleM0.md (lines 120-122) — MFront-mimic Cycle M0, Phase 4. +Deliverables under test: + * MechDSL: RELEASE_ORDER.md (repo root) — the 3-step release runbook. + * NumerixWeave: libs/ticonstit/src/ticonstit/generated/GENERATED.md — committed- + artifacts seam note linking back to RELEASE_ORDER.md. + +The MechDSL-checkable piece is that RELEASE_ORDER.md exists and documents the three +steps (compile -> commit artifacts -> NumerixWeave verifies source_hash). The DAG +guard (python tools/check_dependency_graph.py exits 0; no mechdsl/sympy runtime +import) runs in NumerixWeave (R3), so that half is a cross-repo skip marker. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[5] + + +class TestTaskP4_3: + """Release-ordering docs + cross-repo DAG guard. AC covered: 1-4.""" + + @pytest.mark.integration + def test_release_order_md_exists_with_three_steps(self) -> None: + """Verifies: MechDSL RELEASE_ORDER.md exists and lists the 3 release steps. + AC1: exact CLI commands for compile -> commit artifacts -> verify source_hash. + Passes when: RELEASE_ORDER.md is present and names all three steps.""" + pytest.skip("stub — implement after Task P4-3") + + @pytest.mark.integration + def test_dependency_graph_guard_runs_in_numerixweave(self) -> None: + """Verifies (cross-repo): NumerixWeave stays free of any mechdsl/sympy runtime + import — python tools/check_dependency_graph.py exits 0. + Passes when: the DAG guard passes in NumerixWeave (run there, not here).""" + pytest.skip("stub — cross-repo DAG guard; runs in NumerixWeave (R3)") diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_5.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_5.py index 2037432..d45c048 100644 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_5.py +++ b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_5.py @@ -67,6 +67,11 @@ # ``june16`` is an active backlog/roadmap planning note (2026-06-16), not a # superseded plan. "june16", + # ``mfront_cycleM0`` is the active MFront-mimic Cycle M0 plan (MechDSL producer + # side that generates NumerixWeave's ticonstit.generated.* constitutive laws) — + # Phase 1 (contracts + mechdsl-lawgen CLI) merged, Phases 2-4 pending; it is the + # authoritative execution source for that work, not superseded. + "mfront_cycleM0", } ACTIVE_TASK_DIRS = { "recovery_plan_latex_contract", diff --git a/packages/ti-runtime/pyproject.toml b/packages/ti-runtime/pyproject.toml index 77c5c17..a17ecdc 100644 --- a/packages/ti-runtime/pyproject.toml +++ b/packages/ti-runtime/pyproject.toml @@ -8,7 +8,7 @@ version = "0.2.0" description = "Neutral Taichi runtime: vector primitives, Tier-1 @ti.func helpers, and solver/operator injection seams for MechDSL-generated code (PlanJune14 PJ-0)" readme = "README.md" license = "MIT" -requires-python = ">=3.12,<3.13" +requires-python = ">=3.11,<3.14" authors = [ { name = "Shmuel Osovski" }, ] diff --git a/pyproject.toml b/pyproject.toml index bf7b783..75cc594 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "mechdsl-workspace" version = "0.2.0" description = "MechDSL monorepo — LaTeX-to-FEM compiler + algorithm transpiler" -requires-python = ">=3.12,<3.13" +requires-python = ">=3.11,<3.14" dependencies = [ "markdown>=3.10.2", "numpy>=2.4.2", @@ -24,15 +24,15 @@ exclude-newer = "2026-02-20T00:00:00Z" # (migrated from deprecated [tool.uv] dev-dependencies) [dependency-groups] dev = [ - "pytest>=7.0", - "pytest-cov>=4.0", - "mypy>=1.8", - "ruff>=0.3", + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "mypy>=1.11", + "ruff>=0.7", "pre-commit>=3.5", ] verify = ["torch>=2.0"] docs = [ - "mkdocs-material>=9.5", + "mkdocs-material>=9.7.2", ] # ---------- shared tool config ---------- diff --git a/uv.lock b/uv.lock index 889527d..b0e2408 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 -requires-python = "==3.12.*" +requires-python = ">=3.11, <3.14" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] [options] exclude-newer = "2026-02-20T00:00:00Z" @@ -24,7 +28,7 @@ version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -37,6 +41,19 @@ version = "3.51.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6b/6f/817b270f836c56fd6354aff5da9b96e36895b5b777bda3682692907e6591/apsw-3.51.2.0.tar.gz", hash = "sha256:916271dcf55fc3fd150354b6dbbf76d75a1a5e77cbefca3c3603a8b9c51f9529", size = 1156490, upload-time = "2026-01-10T16:47:33.028Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c3/654de560ef048ba068254ca7ad2100e34701860ee02022304d3134f2c96f/apsw-3.51.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:702edd757aba2f2662ea5f96d24819f7425c4baf6b1c93389c4290a8efec9b05", size = 1994976, upload-time = "2026-01-10T16:45:30.154Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/cf1e85d9e33d0ae467605e3f68460f13bbe3c823b6d43c0b0e052290cd15/apsw-3.51.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9af7fc328790a431af52a551315e938048172cb7a67834ab9fee32b23916f195", size = 1926087, upload-time = "2026-01-10T16:45:32.229Z" }, + { url = "https://files.pythonhosted.org/packages/6e/29/3c3a987730c5e8a6a9c47ad63c29123cd3acda84afb93f3d8a4a613bffb7/apsw-3.51.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2014a08445a7019bef5ae4e0970f82d95e2714969a15e2f7f377d59fdee66bfd", size = 7296847, upload-time = "2026-01-10T16:45:34.236Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/a1380378a2b78901ba0d1578589d8fd5519425a7be89c9f80eb997a5db2d/apsw-3.51.2.0-cp311-cp311-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4d62418abc3c29d1c0e6748c0c990228044d2e6b0eb6c1018f5d18ac4af90f0", size = 6981719, upload-time = "2026-01-10T16:45:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/fa/8d/66b5ccb36bc0f7d89f6d1c5998ebb7590ab404be165f2e0335b164c2d908/apsw-3.51.2.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:9856c8568aa08d61a8ec30b2121188bc9106e72e96c58c3d05e90ac020df52c6", size = 7141245, upload-time = "2026-01-10T16:45:37.835Z" }, + { url = "https://files.pythonhosted.org/packages/ec/53/9a28101d15c0916b4c2a76a17b046ded1bd618e544874454e7f175b0a737/apsw-3.51.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6bfd910c308b356d3612f91fe0002500df4fbee7d5dd301a2a298d5a08a1b2bb", size = 7277539, upload-time = "2026-01-10T16:45:39.527Z" }, + { url = "https://files.pythonhosted.org/packages/1f/40/d5ba2963886c18c2ff66127d972a80001a0d43a4e75ff02a063602eea308/apsw-3.51.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e8333d4f5ad70dbd9f99b1952f43be64c1a921aa619ace28e61e1e7ff709bc28", size = 7250403, upload-time = "2026-01-10T16:45:41.928Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8e/eeb732da15f2203bedd6f8f4ef2da994d88ab4a485155cef03d68f3fd401/apsw-3.51.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f8ab6c8ad5e90c83344c04be0b454532d299e970d403ff069ae7c398b9a7e34f", size = 7125534, upload-time = "2026-01-10T16:45:44.123Z" }, + { url = "https://files.pythonhosted.org/packages/32/ac/41a9b2fd66046957773b256071c6d66dc3949acdab6b3c865add977ba4a0/apsw-3.51.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eed6abed56c9f15e7084d67491f1f3d2f92b65281369dc3de80d50494510c536", size = 7206662, upload-time = "2026-01-10T16:45:45.943Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/16db408e57ea07ad141ca7aa0e007d686ad36e0b0db0f73167b6b10e8d79/apsw-3.51.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c9f520a7d87023d3a6485eeacbcb2b4a25e52de259ef82e80d176f68f62d414", size = 7267680, upload-time = "2026-01-10T16:45:47.74Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6f/bdf0c572598f5d7121180f518c01663ba6824326e3ef88b8e704c2168011/apsw-3.51.2.0-cp311-cp311-win32.whl", hash = "sha256:c31f69fee1639303ef62cf6f3f491a2e77a62c15860a2e73bc1f4359d1824154", size = 1621431, upload-time = "2026-01-10T16:45:49.692Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9b/a10c4c3d0c8357ecfc7e51a42441150305a7a7db6fe9a71719f9427c3da3/apsw-3.51.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:29d120476d074f4b4fd6fa754593887393106497c3c260e1841b2fcddaadc5c5", size = 1814499, upload-time = "2026-01-10T16:45:51.598Z" }, + { url = "https://files.pythonhosted.org/packages/55/84/f42630791b90d53b1fafea7d44ecf928d35b4795e1fdc9e44baca5910f28/apsw-3.51.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:02ad0f7c9b962ba586fb21b58c286213fd05d7478cd5aff6005902c44650b79a", size = 1637854, upload-time = "2026-01-10T16:45:53.651Z" }, { url = "https://files.pythonhosted.org/packages/cf/1e/3d1da9827cb120ed9c19f6beb3f94c3836a8eac64c8fd7fa24ce40fbdbb8/apsw-3.51.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c91517f875033a447d1b7412bd128a2618821c467532d4531841b754eb01f9d5", size = 1995767, upload-time = "2026-01-10T16:45:55.22Z" }, { url = "https://files.pythonhosted.org/packages/81/bf/9f6dcde7c11465b2bcfe52c9e5c9df393dfb78bc59414e9ddbb5fd6ca6b9/apsw-3.51.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dc3e6c3756446696a37903a8d22ceb4b5ec5eb7c0c6db010c0d32d9add5a77d0", size = 1926162, upload-time = "2026-01-10T16:45:56.72Z" }, { url = "https://files.pythonhosted.org/packages/ca/db/83b5f423ec6d566d6394a7f25b415450410d0466655b330820626dd1be0e/apsw-3.51.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1072f52c792e5ada878df74b2bec32e31e733fae7d31810e462bf645c1f06207", size = 7296545, upload-time = "2026-01-10T16:45:59.062Z" }, @@ -50,6 +67,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/54/dfd7e28a9da187feacad31add0537f3caa4ea46832ebc20f9866279b62ee/apsw-3.51.2.0-cp312-cp312-win32.whl", hash = "sha256:d8b196b882adec2c1795e6e28875cf33d55807623ccb4f51fe124ca910608e66", size = 1621733, upload-time = "2026-01-10T16:46:15.006Z" }, { url = "https://files.pythonhosted.org/packages/2a/92/a7da406dec09d5d30f75c54837f6810717cfa53a29221c2ed7ef628e031f/apsw-3.51.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:220ef45e01ef8881addc7eecec2124b59a34ddc093289e24618589d63bb5de9f", size = 1813545, upload-time = "2026-01-10T16:46:17.085Z" }, { url = "https://files.pythonhosted.org/packages/2b/96/4ab4b0a24cf3c460502f2e5769e281ddd21dfd728a785d947d6a9969e4fb/apsw-3.51.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:59f509343b9036c9d8a4af4b91cd8e94288b61432a4101d9a648632dbdb19e69", size = 1637413, upload-time = "2026-01-10T16:46:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/f2/95/11b69a90569e44b7c5afb7c67c168f948b73cfe2ef9eae35555a205c8bd0/apsw-3.51.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cd0574768c0f4ec324f45a1281883dda450f60f29ea553dd10553c089d8e95c8", size = 1994320, upload-time = "2026-01-10T16:46:20.772Z" }, + { url = "https://files.pythonhosted.org/packages/78/17/cbcb379fa28feac3ebe00dcd185436e55c6dedbefebab245b28d4aec1020/apsw-3.51.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23e6328bc03b55b8978e2ab20e9a8bc931b452d0aa9765458734484c17a3064d", size = 1924165, upload-time = "2026-01-10T16:46:22.325Z" }, + { url = "https://files.pythonhosted.org/packages/02/ff/9989235ff37559b149da8ed57b86a3806f6e93434445350ebf6cf521f007/apsw-3.51.2.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f0cb7034ff787fab3976e4cd251a632528a31f2e663f4ab054a18974c6304f96", size = 7307313, upload-time = "2026-01-10T16:46:23.925Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/2333165a51facc688ac4a462bf4a5cb3056628a3861e192f9030c0d04d55/apsw-3.51.2.0-cp313-cp313-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cb30283213beab2ef55494a018ed93e8240b6f7b76c106c8ec18a4715976b510", size = 6981288, upload-time = "2026-01-10T16:46:25.67Z" }, + { url = "https://files.pythonhosted.org/packages/af/66/b85d840c39c9d31fa050b9f0c5eecedf95354d8bc4b1f1269275f3da1511/apsw-3.51.2.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:b067214059b17c86d03b08e032416f7c5ee67c7bcab9cd8d84e6ae71874acf1c", size = 7137378, upload-time = "2026-01-10T16:46:27.353Z" }, + { url = "https://files.pythonhosted.org/packages/84/07/7ebe77239b227b888f43f23cf33e54491de0591c3834253f47473c06672b/apsw-3.51.2.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:064e00e09ffd1a14c75c8569dd4c8712c16a51ce00992e08a2fa468cbd286d53", size = 7304636, upload-time = "2026-01-10T16:46:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/71/79/a1429794f702c17c72cdf949ce8647ff4e4c56b54bd1410af1c63f16ead8/apsw-3.51.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:29d84d5242a4741fcd8d317e72d51ab64aef59bb55c6414f8a5198d6ac9e4238", size = 7244268, upload-time = "2026-01-10T16:46:30.893Z" }, + { url = "https://files.pythonhosted.org/packages/db/e6/f68b4355564ef8ceaff0442d67703984997dfa3716f242768d0f44e0da07/apsw-3.51.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:fc48ab2e1ba537b527f0dd483ab88d9a75a6953d0ca89d7575d0fe5789a1b424", size = 7134172, upload-time = "2026-01-10T16:46:32.666Z" }, + { url = "https://files.pythonhosted.org/packages/a2/06/8048325b8847714290eb33fa5f55d577ea18d66939e6866ca64bc8bbee60/apsw-3.51.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ee00cd4649e857da484e9652394dca42e59aa31d76c45e6dc8b3c12ded9b5758", size = 7198704, upload-time = "2026-01-10T16:46:34.677Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/3bf8b0681acd16626fcee48fbd247df67b2fbd95101322ec2deb574f6045/apsw-3.51.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9480991ced9bcdbc7ff9ebca9105ab45733f46caedf9f72f3e907f616eaaab54", size = 7286837, upload-time = "2026-01-10T16:46:36.711Z" }, + { url = "https://files.pythonhosted.org/packages/07/7b/254cbfff260007bf107625568bd36ead430d19c688429c3565c885f37f3b/apsw-3.51.2.0-cp313-cp313-win32.whl", hash = "sha256:8fabd40376dcf6333007e0f9aef3fc2eddc802e68b2b90b47baf54ab503e52e4", size = 1620612, upload-time = "2026-01-10T16:46:38.839Z" }, + { url = "https://files.pythonhosted.org/packages/dc/de/f6c33da2c11cf0a43e8349f34486ec9ba2b4921cfd33e5f04738dd49fb3a/apsw-3.51.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:088e45c3a2396518904c7eed3a7bb38a3737d1122760787dad0e4f3e6418cea5", size = 1812291, upload-time = "2026-01-10T16:46:40.846Z" }, + { url = "https://files.pythonhosted.org/packages/a9/71/89cfdfa3a7bfae1108bc42f066321d6742fe0fc859af3a7002697d183489/apsw-3.51.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8b31574d7e3f6e0080fb4277c242d284557cf6f101520890b329ab8ea63926db", size = 1636589, upload-time = "2026-01-10T16:46:42.449Z" }, ] [[package]] @@ -83,6 +113,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/39/3765df263e08a4df37f4f43cb5aa3c6c17a4bdd42ecfe841e04c26037171/backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", size = 381075, upload-time = "2026-02-16T19:10:04.322Z" }, { url = "https://files.pythonhosted.org/packages/0f/f0/35240571e1b67ffb19dafb29ab34150b6f59f93f717b041082cdb1bfceb1/backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", size = 392874, upload-time = "2026-02-16T19:10:06.314Z" }, { url = "https://files.pythonhosted.org/packages/e3/63/77e8c9745b4d227cce9f5e0a6f68041278c5f9b18588b35905f5f19c1beb/backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", size = 398787, upload-time = "2026-02-16T19:10:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/c5/71/c754b1737ad99102e03fa3235acb6cb6d3ac9d6f596cbc3e5f236705abd8/backrefs-6.2-py313-none-any.whl", hash = "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b", size = 400747, upload-time = "2026-02-16T19:10:09.791Z" }, { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, ] @@ -123,6 +154,22 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, @@ -139,6 +186,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] @@ -169,6 +232,21 @@ version = "7.13.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, @@ -184,9 +262,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cuda-bindings" version = "12.9.4" @@ -195,7 +308,10 @@ dependencies = [ { name = "cuda-pathfinder" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e7/b47792cc2d01c7e1d37c32402182524774dadd2d26339bd224e0e913832e/cuda_bindings-12.9.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c912a3d9e6b6651853eed8eed96d6800d69c08e94052c292fec3f282c5a817c9", size = 12210593, upload-time = "2025-10-21T14:51:36.574Z" }, { url = "https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8", size = 12151019, upload-time = "2025-10-21T14:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/56/e465c31dc9111be3441a9ba7df1941fe98f4aa6e71e8788a3fb4534ce24d/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32bdc5a76906be4c61eb98f546a6786c5773a881f3b166486449b5d141e4a39f", size = 11906628, upload-time = "2025-10-21T14:51:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/1e6be415e37478070aeeee5884c2022713c1ecc735e6d82d744de0252eee/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56e0043c457a99ac473ddc926fe0dc4046694d99caef633e92601ab52cbe17eb", size = 11925991, upload-time = "2025-10-21T14:51:56.535Z" }, ] [[package]] @@ -304,6 +420,13 @@ version = "0.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, @@ -311,6 +434,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, ] [[package]] @@ -382,6 +512,19 @@ version = "0.8.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, @@ -395,6 +538,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, ] [[package]] @@ -424,6 +580,17 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, @@ -435,6 +602,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] [[package]] @@ -471,9 +660,9 @@ verify = [ requires-dist = [ { name = "algo2code", marker = "extra == 'verify'", editable = "packages/algo2code" }, { name = "nrpylatex", git = "https://github.com/SOSOVSKI/nrpylatex" }, - { name = "numpy", specifier = ">=1.24" }, + { name = "numpy", specifier = ">=2.4.2" }, { name = "opt-einsum", specifier = ">=3.3" }, - { name = "pyyaml", specifier = ">=6.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "scipy", specifier = ">=1.17.0" }, { name = "sympy", specifier = ">=1.12" }, { name = "taichi", marker = "extra == 'verify'", specifier = ">=1.7" }, @@ -516,13 +705,13 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=1.8" }, + { name = "mypy", specifier = ">=1.11" }, { name = "pre-commit", specifier = ">=3.5" }, - { name = "pytest", specifier = ">=7.0" }, - { name = "pytest-cov", specifier = ">=4.0" }, - { name = "ruff", specifier = ">=0.3" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.7" }, ] -docs = [{ name = "mkdocs-material", specifier = ">=9.5" }] +docs = [{ name = "mkdocs-material", specifier = ">=9.7.2" }] verify = [{ name = "torch", specifier = ">=2.0" }] [[package]] @@ -624,12 +813,24 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] @@ -674,6 +875,17 @@ version = "2.4.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, @@ -685,6 +897,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, + { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, ] [[package]] @@ -943,7 +1183,7 @@ name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage" }, + { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] @@ -1009,6 +1249,15 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, @@ -1019,6 +1268,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] [[package]] @@ -1095,6 +1354,16 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/4b/c89c131aa87cad2b77a54eb0fb94d633a842420fa7e919dc2f922037c3d8/scipy-1.17.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:2abd71643797bd8a106dff97894ff7869eeeb0af0f7a5ce02e4227c6a2e9d6fd", size = 31381316, upload-time = "2026-01-10T21:24:33.42Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5f/a6b38f79a07d74989224d5f11b55267714707582908a5f1ae854cf9a9b84/scipy-1.17.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:ef28d815f4d2686503e5f4f00edc387ae58dfd7a2f42e348bb53359538f01558", size = 27966760, upload-time = "2026-01-10T21:24:38.911Z" }, + { url = "https://files.pythonhosted.org/packages/c1/20/095ad24e031ee8ed3c5975954d816b8e7e2abd731e04f8be573de8740885/scipy-1.17.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:272a9f16d6bb4667e8b50d25d71eddcc2158a214df1b566319298de0939d2ab7", size = 20138701, upload-time = "2026-01-10T21:24:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/89/11/4aad2b3858d0337756f3323f8960755704e530b27eb2a94386c970c32cbe/scipy-1.17.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:7204fddcbec2fe6598f1c5fdf027e9f259106d05202a959a9f1aecf036adc9f6", size = 22480574, upload-time = "2026-01-10T21:24:47.266Z" }, + { url = "https://files.pythonhosted.org/packages/85/bd/f5af70c28c6da2227e510875cadf64879855193a687fb19951f0f44cfd6b/scipy-1.17.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc02c37a5639ee67d8fb646ffded6d793c06c5622d36b35cfa8fe5ececb8f042", size = 32862414, upload-time = "2026-01-10T21:24:52.566Z" }, + { url = "https://files.pythonhosted.org/packages/ef/df/df1457c4df3826e908879fe3d76bc5b6e60aae45f4ee42539512438cfd5d/scipy-1.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dac97a27520d66c12a34fd90a4fe65f43766c18c0d6e1c0a80f114d2260080e4", size = 35112380, upload-time = "2026-01-10T21:24:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bb/88e2c16bd1dd4de19d80d7c5e238387182993c2fb13b4b8111e3927ad422/scipy-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb7446a39b3ae0fe8f416a9a3fdc6fba3f11c634f680f16a239c5187bc487c0", size = 34922676, upload-time = "2026-01-10T21:25:04.287Z" }, + { url = "https://files.pythonhosted.org/packages/02/ba/5120242cc735f71fc002cff0303d536af4405eb265f7c60742851e7ccfe9/scipy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:474da16199f6af66601a01546144922ce402cb17362e07d82f5a6cf8f963e449", size = 37507599, upload-time = "2026-01-10T21:25:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/08629657ac6c0da198487ce8cd3de78e02cfde42b7f34117d56a3fe249dc/scipy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:255c0da161bd7b32a6c898e7891509e8a9289f0b1c6c7d96142ee0d2b114c2ea", size = 36380284, upload-time = "2026-01-10T21:25:15.632Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4a/465f96d42c6f33ad324a40049dfd63269891db9324aa66c4a1c108c6f994/scipy-1.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b0ac3ad17fa3be50abd7e69d583d98792d7edc08367e01445a1e2076005379", size = 24370427, upload-time = "2026-01-10T21:25:20.514Z" }, { url = "https://files.pythonhosted.org/packages/0b/11/7241a63e73ba5a516f1930ac8d5b44cbbfabd35ac73a2d08ca206df007c4/scipy-1.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0d5018a57c24cb1dd828bcf51d7b10e65986d549f52ef5adb6b4d1ded3e32a57", size = 31364580, upload-time = "2026-01-10T21:25:25.717Z" }, { url = "https://files.pythonhosted.org/packages/ed/1d/5057f812d4f6adc91a20a2d6f2ebcdb517fdbc87ae3acc5633c9b97c8ba5/scipy-1.17.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:88c22af9e5d5a4f9e027e26772cc7b5922fab8bcc839edb3ae33de404feebd9e", size = 27969012, upload-time = "2026-01-10T21:25:30.921Z" }, { url = "https://files.pythonhosted.org/packages/e3/21/f6ec556c1e3b6ec4e088da667d9987bb77cc3ab3026511f427dc8451187d/scipy-1.17.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3cd947f20fe17013d401b64e857c6b2da83cae567adbb75b9dcba865abc66d8", size = 20140691, upload-time = "2026-01-10T21:25:34.802Z" }, @@ -1105,6 +1374,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/61/0470810c8a093cdacd4ba7504b8a218fd49ca070d79eca23a615f5d9a0b0/scipy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0cf46c8013fec9d3694dc572f0b54100c28405d55d3e2cb15e2895b25057996e", size = 37405953, upload-time = "2026-01-10T21:26:07.75Z" }, { url = "https://files.pythonhosted.org/packages/92/ce/672ed546f96d5d41ae78c4b9b02006cedd0b3d6f2bf5bb76ea455c320c28/scipy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:0937a0b0d8d593a198cededd4c439a0ea216a3f36653901ea1f3e4be949056f8", size = 36328121, upload-time = "2026-01-10T21:26:16.509Z" }, { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" }, + { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" }, + { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" }, + { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" }, + { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" }, + { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" }, ] [[package]] @@ -1140,7 +1429,7 @@ version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } wheels = [ @@ -1170,9 +1459,15 @@ dependencies = [ { name = "rich" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/23/89/3a920b880e058b4d8c5b0fe1e695481725707aab5ac35b3e91295903dda0/taichi-1.7.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4cbb5a16cac228862c5da2ec71ef722e637c7a5ebf636f8f11000c2f8a9b6693", size = 50437174, upload-time = "2025-07-31T14:49:20.548Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c6/90b110c26588e9c8f1f71d1485b176e547b581075102d54aeb38d2e48ae4/taichi-1.7.4-cp311-cp311-manylinux_2_27_x86_64.whl", hash = "sha256:5c3c1624daeb1554c1a2b6ee9f9b8398bd8392d7f89fc65395f94baecf049f89", size = 56180510, upload-time = "2025-07-31T14:48:47.605Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/0495da9f8f0afa801a8e2da262fb75814bf9a4d788946c40bfc9340bf088/taichi-1.7.4-cp311-cp311-win_amd64.whl", hash = "sha256:767d977f077efcc83eb746a8dd1ccd196db782f48eac07c495922b36f8828e2c", size = 83206769, upload-time = "2025-07-31T14:48:59.236Z" }, { url = "https://files.pythonhosted.org/packages/ef/5b/7c7d6b8259fba064a4b3908f67f2488efbc3104d2a2fbaba00d81d30bac2/taichi-1.7.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c7d188f8a8a15f07b197aa881517ffc7459663ee25a0e36636ce347c0649353", size = 50345635, upload-time = "2025-07-31T14:49:27.27Z" }, { url = "https://files.pythonhosted.org/packages/74/74/c7aca2af38d38efe9ad8430864e97e87c0b0d4affa2ea6cc4aecbbcfdb0e/taichi-1.7.4-cp312-cp312-manylinux_2_27_x86_64.whl", hash = "sha256:6f1303aedae3ea25e33cef5f30259fc2f66c7f0287433c4e31bdb25fdcd4d81e", size = 56298504, upload-time = "2025-07-31T14:48:55.232Z" }, { url = "https://files.pythonhosted.org/packages/27/32/5882f3fafbd981fe060c7ee96355161b82c78e7624a4460eefaf2760ea64/taichi-1.7.4-cp312-cp312-win_amd64.whl", hash = "sha256:d078481d84032d9284a12a0b78672a4a2915786d9106791fad657c09352e9565", size = 83312702, upload-time = "2025-07-31T14:49:03.715Z" }, + { url = "https://files.pythonhosted.org/packages/93/62/7a5c8562550e17054ea59e41de9b82680f19b5e45442f6967f442aeb8e60/taichi-1.7.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a907fc86029c4b5ba85352a77f48af14717711466def8d6d2b8b17d75311c30f", size = 50439402, upload-time = "2025-07-31T14:48:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/81/cd/3858352ede95ad71a8bec677da440011b42df0214ee675a3dd3f0dea607a/taichi-1.7.4-cp313-cp313-manylinux_2_27_x86_64.whl", hash = "sha256:001ff64725e58e25ff832facc4ff1ed5ded968c64d5cd46275795999f1cce4e0", size = 56298954, upload-time = "2025-07-31T14:49:17.104Z" }, + { url = "https://files.pythonhosted.org/packages/10/43/0f4eac57b2eaee9e906139794eb12e752a7f8d077bbd1be695acdc9d117c/taichi-1.7.4-cp313-cp313-win_amd64.whl", hash = "sha256:ff9847a788c2193df61626266eb2df2ce679c372cb1669ffa806e7c45722ddc7", size = 83313386, upload-time = "2025-07-31T14:48:38.371Z" }, ] [[package]] @@ -1186,6 +1481,42 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "taichi", specifier = ">=1.7" }] +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + [[package]] name = "torch" version = "2.10.0" @@ -1211,17 +1542,31 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-nvshmem-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "setuptools" }, + { name = "setuptools", marker = "python_full_version >= '3.12'" }, { name = "sympy" }, { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "typing-extensions" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" }, { url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" }, + { url = "https://files.pythonhosted.org/packages/78/89/f5554b13ebd71e05c0b002f95148033e730d3f7067f67423026cc9c69410/torch-2.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3282d9febd1e4e476630a099692b44fdc214ee9bf8ee5377732d9d9dfe5712e4", size = 145992610, upload-time = "2026-01-21T16:25:26.327Z" }, + { url = "https://files.pythonhosted.org/packages/ae/30/a3a2120621bf9c17779b169fc17e3dc29b230c29d0f8222f499f5e159aa8/torch-2.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2f9edd8dbc99f62bc4dfb78af7bf89499bca3d753423ac1b4e06592e467b763", size = 915607863, upload-time = "2026-01-21T16:25:06.696Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3d/c87b33c5f260a2a8ad68da7147e105f05868c281c63d65ed85aa4da98c66/torch-2.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:29b7009dba4b7a1c960260fc8ac85022c784250af43af9fb0ebafc9883782ebd", size = 113723116, upload-time = "2026-01-21T16:25:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/61/d8/15b9d9d3a6b0c01b883787bd056acbe5cc321090d4b216d3ea89a8fcfdf3/torch-2.10.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:b7bd80f3477b830dd166c707c5b0b82a898e7b16f59a7d9d42778dd058272e8b", size = 79423461, upload-time = "2026-01-21T16:24:50.266Z" }, { url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" }, { url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" }, { url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" }, { url = "https://files.pythonhosted.org/packages/c9/5c/dee910b87c4d5c0fcb41b50839ae04df87c1cfc663cf1b5fca7ea565eeaa/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6d3707a61863d1c4d6ebba7be4ca320f42b869ee657e9b2c21c736bf17000294", size = 79498198, upload-time = "2026-01-21T16:24:34.704Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" }, + { url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" }, + { url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" }, + { url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" }, + { url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" }, ] [[package]] @@ -1229,7 +1574,10 @@ name = "triton" version = "3.6.0" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" }, { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, + { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, ] [[package]] @@ -1280,12 +1628,24 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, ] [[package]] @@ -1308,9 +1668,15 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, @@ -1332,6 +1698,19 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, @@ -1345,6 +1724,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, ] [[package]] @@ -1353,6 +1759,15 @@ version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, @@ -1362,5 +1777,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ]