diff --git a/.gitignore b/.gitignore index ddeee12..8f195ee 100644 --- a/.gitignore +++ b/.gitignore @@ -62,8 +62,14 @@ ti_cache/ .publisher-worktrees/ .sesskey +# Public-release staging tree (curated copy pushed to CEmM2/MechDSL) +/dev/MechDSL/ + # MkDocs build output /site/ # Generated wiki / code-intelligence index (logic-loom / akms tooling) .repo_wiki/ + +# comment-sweep working tree +/.comment-review/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4de4a52..71e978e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,39 +6,31 @@ All notable changes to this project will be documented in this file. ## [0.2.0] - 2026-06-16 -### Added — PlanJune14: Seams & Bodies (fully-generated solver/operator over a lean Taichi runtime) +### Added — Fully-generated solver over a lean Taichi runtime -`dev/plans/PlanJune14.md` demonstrates the "later from algo2code-generated code" branch (Decision D8) end-to-end: every numerical kernel the Newton driver needs is generated from LaTeX by `algo2code` and injected over lean `ti_runtime` seams, with no NumPy in the hot paths. Full closure record in `dev/plans/PlanJune14_closure.md`. +Every numerical kernel the Newton driver needs can now be generated from LaTeX by `algo2code` and injected over lean `ti_runtime` seams, with no NumPy in the hot paths. -- **`ti-runtime` package (PJ-0)**: new neutral Taichi runtime package (`packages/ti-runtime/`) — vector primitives, Tier-1 `@ti.func` helpers, and solver/operator/integrator injection seams (`ti_runtime.{fields,hex8,seams,tensor_ti,vector_ops}`). `mechdsl-core` takes a production dependency on it via the `verify` extra; `algo2code` joins the same extra. -- **Matrix-free tangent operator seam (PJ-2 / PJ-3 / PJ-5)**: `% type A callable` (a.k.a. `A:callable`) emits an in-place `A(out, p)` matching the `ti_runtime` `apply_A(out, x)` contract — no dense `A` field, no inner matvec loop — injected via `LinearSolveContext.set_operator`. `local_tangent` einsum routes through Layer-4b and emits an `@ti.kernel` matvec. Proven for the elastic operator (PJ-3) and the dissipative J2 algorithmic tangent (PJ-5). -- **Generated linear solver + Jacobi preconditioner seam (PJ-4)**: PCG injected via `set_solver`, Jacobi via `set_preconditioner`. **Opt-in (Option 1)**: `get_default_solver()` still returns the imported `ScipyCGSolver` as the default fallback — the imported solver is retained, not removed; selecting the all-Taichi seam path is explicit. -- **Time-integration seam (PJ-6)**: Newmark-β / HHT integrators injected via `TimeIntegrationContext.set_integrator` / `step`. -- **Governance (PJ-7)**: `pending` status value added to `dev/tracking/STATUS_LEGEND.md`; PlanJune14 allowlisted in the anti-drift guard. Design-doc addenda (Decision D8 + `11-ALGO2CODE.md` §8.3 / §2.4) are staged for manual maintainer apply (see the closure doc). +- **`ti-runtime` package**: new neutral Taichi runtime package (`packages/ti-runtime/`) — vector primitives, Tier-1 `@ti.func` helpers, and solver/operator/integrator injection seams (`ti_runtime.{fields,hex8,seams,tensor_ti,vector_ops}`). `mechdsl-core` takes a production dependency on it via the `verify` extra; `algo2code` joins the same extra. +- **Matrix-free tangent operator seam**: `% type A callable` (a.k.a. `A:callable`) emits an in-place `A(out, p)` matching the `ti_runtime` `apply_A(out, x)` contract — no dense `A` field, no inner matvec loop — injected via `LinearSolveContext.set_operator`. `local_tangent` einsum emits an `@ti.kernel` matvec. Proven for the elastic operator and the dissipative J2 algorithmic tangent. +- **Generated linear solver + Jacobi preconditioner seam**: PCG injected via `set_solver`, Jacobi via `set_preconditioner`. Opt-in: `get_default_solver()` still returns the imported `ScipyCGSolver` as the default fallback — selecting the all-Taichi seam path is explicit. +- **Time-integration seam**: Newmark-β / HHT integrators injected via `TimeIntegrationContext.set_integrator` / `step`. -### Added — AKMS executable-bridge Phase 1 (Tier-1 integration façade) +### Added — Integration façade (Tier-1) -`dev/plans/akms_executable_bridge.md` Phase 1 lands the stable, machine-readable surface that lets MechDSL be driven as the AKMS-Learn `executable_bridge` (Phases 2–3 — the AKMS adapter and Logic-Loom plugin — execute in their own repos). +A stable, machine-readable surface for driving MechDSL programmatically from external tools. -- **`mechdsl.integration` façade (P1-1..P1-5)**: new package `packages/mechdsl-core/src/mechdsl/integration/__init__.py` exposing five entry points — `capabilities()`, `model_catalog()`, `compile_from_sources(*, problem_source, energy_source, energy_file, profile)`, `transpile_algorithm(algpseudocode, backend)`, and `verify(kind, params)`. The façade wraps existing entry points (`compile_latex`, `algo2code.transpile`, the verify harness) and returns JSON-serialisable summaries; no IR layer is bypassed. Bound as `mechdsl.integration` and added to `mechdsl.__all__`. -- **Taichi-free Tier-1 contract**: `capabilities()` declares `taichi_required_for: ["verify"]`; importing the module and calling the other four entry points never fires `ti.init`. All heavy imports are lazy (inside helper bodies), and the invariant is proven by fresh-interpreter subprocess guards asserting `'taichi' not in sys.modules`. `model_catalog()` enumerates **12 constitutive models** — eight `symbolic.models.*` introspected live (numpy + sympy), `lemaitre`, and three `lib.plasticity*` listed statically (those modules exec transpiled Taichi source at import). -- **`verify()` kinds**: `patch_test`, `rigid_body`, `ad_oracle_svk`, `ad_oracle_j2`, and `benchmark` (cantilever / cook_membrane over `mechdsl.verify.benchmarks`). `compile_from_sources` returns a JSON-safe `element_ir_summary` (five scalar fields) plus `content_hash` (`ArtifactBundle.content_hash()`, semantic IR only — excludes emitted source). -- **Catalog hardening**: `model_catalog()` is memoised behind a cached `_build_model_catalog()` snapshot and returns per-call deep copies (callers can't corrupt the cache). `_introspect_model_class` now **fails loudly** instead of silently returning `((), False)` — which uncovered and fixed a latent bug where `HGOModel(mat, fiber_dirs)` was being mis-introspected (constructor now filled via signature-aware `_dummy_model_ctor_args`). A `@pytest.mark.slow` subprocess test guards `_LIB_PLASTICITY_CATALOG` against drift from the real `J2KinematicMaterial` / `J2MixedMaterial` dataclasses. -- **`verify('benchmark', …)` convergence**: `passed` now requires `relative_error <= tolerance` when the benchmark compared against a reference (cantilever vs Euler-Bernoulli; cook_membrane Hex8 reference path) — previously it only checked the solve ran to completion. The default cook_membrane prescribed-displacement smoke cell reports `relative_error = NaN`, so it stays completion-only and now flags `reference_checked: false`. New `details` keys: `relative_error` (float | None), `tolerance` (float), `reference_checked` (bool). Tolerance defaults to the benchmark's own `tip_tolerance`, else 2%. -- **Tests**: `tests/test_integration_surface.py` (canonical façade contract) + `tests/plan_tests/akms_executable_bridge/test_P1-1..5.py`; README gains a `mechdsl.integration` façade section. `akms_executable_bridge` registered as an active plan in `test_p7_5.py` governance allowlists. +- **`mechdsl.integration` façade**: new package exposing five entry points — `capabilities()`, `model_catalog()`, `compile_from_sources(*, problem_source, energy_source, energy_file, profile)`, `transpile_algorithm(algpseudocode, backend)`, and `verify(kind, params)`. The façade wraps existing entry points (`compile_latex`, `algo2code.transpile`, the verify harness) and returns JSON-serialisable summaries; no IR layer is bypassed. +- **Taichi-free Tier-1 contract**: `capabilities()` declares `taichi_required_for: ["verify"]`; importing the module and calling the other four entry points never fires `ti.init`. All heavy imports are lazy, and the invariant is proven by fresh-interpreter subprocess guards. `model_catalog()` enumerates **12 constitutive models**. +- **`verify()` kinds**: `patch_test`, `rigid_body`, `ad_oracle_svk`, `ad_oracle_j2`, and `benchmark` (cantilever / cook_membrane over `mechdsl.verify.benchmarks`). `compile_from_sources` returns a JSON-safe `element_ir_summary` plus `content_hash` (semantic IR only — excludes emitted source). +- **Catalog hardening**: `model_catalog()` is memoised and returns per-call deep copies; model introspection fails loudly instead of silently returning empty results. +- **`verify('benchmark', …)` convergence**: `passed` now requires `relative_error <= tolerance` when the benchmark compared against a reference — previously it only checked the solve ran to completion. New `details` keys: `relative_error`, `tolerance`, `reference_checked`. -### Added — post_recovery_plan Phases 1–7 +### Added — Boundary conditions, math grammar, and generated plasticity -`dev/plans/post_recovery_plan.md` lands seven follow-up phases that close residual gaps from the LaTeX-first recovery. - -- **Phase 1 — Neumann boundary directive flow into emitted code (P1-1..P1-7)**: `BoundaryCondition.traction` widened to `str | tuple[float, float, float] | None`; new `surface_tag` field; `emit_neumann_f_ext_kernel` (literal-baked) and `emit_neumann_f_ext_kernel_for_ir` (parametric) added to `mechdsl.codegen.taichi_printer`; `compile_latex` façade now surfaces `bundle.f_ext_kernel`; symbolic traction routes through directive-only path. Golden coverage in `packages/mechdsl-core/tests/golden/boundary_neumann.ti.txt`. -- **Phase 2 — `docs` pytest-marker tier (P2-1..P2-3)**: registered `@pytest.mark.docs` for documentation-anchor / contract tests; swapped `integration → docs` on the recovery-plan `test_p7_3..6.py` family; new `docs-tests` CI job routes the tier on every PR. -- **Phase 3 — Boundary-condition handoff documentation (P3-1, P3-2)**: `compile_latex` docstring gains an explicit BC handoff paragraph; new `test_compile_latex_docstring.py` regression test pins the paragraph at the canonical entry point. -- **Phase 4 — nrpylatex math grammar integration (P4-1..P4-5)**: NRPyLaTeX 1.4.0 wired through `mechdsl.symbolic.nrpylatex_bridge`; rank-2 surrogate algorithms cover SVK PK1 emission and J2 yield (`σ:σ`) until the upstream parser supports `\det` / `\log` / `\sqrt(s:s)`. Round-trip suite at `tests/test_nrpylatex_round_trip.py`. -- **Phase 5 — `algo2code` radial-return substitution (P5-1..P5-5)**: J2 power-law radial-return scalar Newton loop authored in algpseudocode (`dev/algorithms/radial_return_j2.tex`); `mechdsl.lib.plasticity` consumes `algo2code.transpile(..., backend="taichi")` at module load — the imported algorithm is the runtime function, not a hand translation. Feature-flag dispatcher (`MECHDSL_USE_IMPORTED_RR`). -- **Phase 6 — Test-layer hardening (P6-1..P6-4)**: new `tests/_e2e_helpers.py` consolidates the previously-duplicated `_import_generated_module` helper (P6-1, P6-2 promoted two of four call sites; P7 cleanup promoted the remaining two so all four sites import from one source); `test_phase6_exit.py` cleanup-detector switched from a line-number whitelist to an in-source `intentional-cleanup-site` marker with a ±3-line proximity window — survives ruff reformats. -- **Phase 7 — Docs polish + governance reconciliation (P7-1..P7-7)**: `dev/examples/README.md` regains its `## Inventory` anchor; `test_p7_3.py` ordering check scoped to runnable code fences (markdown `python` / `bash`), with three-prefix path matching (`dev/examples/`, `./dev/examples/`, `/dev/examples/`) for the example-script reference; per-invocation uuid-derived module name in `test_p7_2.py`; `_SUPERSEDED.md` separates runtime-active from archived sub-deliverables; new `baseline-stability` CI job smoke-imports `algo2code` and runs `pytest --collect-only` on both packages on every push / PR. -- **`P2-2` docs-collection invariant retired**: the per-phase prefix list (widened in P3-1 / P4-5 / P5-5 / P7) replaced with a single directory prefix `post_recovery_plan/`. Removes the recurring widen-on-each-phase pattern flagged in `Handoff_Phase_6.md`. +- **Neumann boundary directive flow into emitted code**: `BoundaryCondition.traction` widened to `str | tuple[float, float, float] | None`; new `surface_tag` field; literal-baked and parametric `f_ext` kernel emitters added to `mechdsl.codegen.taichi_printer`; `compile_latex` now surfaces `bundle.f_ext_kernel`. Golden coverage included. +- **NRPyLaTeX math grammar integration**: NRPyLaTeX 1.4.0 wired through `mechdsl.symbolic.nrpylatex_bridge`; rank-2 surrogate algorithms cover SVK PK1 emission and J2 yield (`σ:σ`) until the upstream parser supports `\det` / `\log` / `\sqrt(s:s)`. Round-trip test suite included. +- **`algo2code` radial-return substitution**: the J2 power-law radial-return scalar Newton loop is authored in algpseudocode (`examples` + `dev/algorithms/radial_return_j2.tex`); `mechdsl.lib.plasticity` consumes `algo2code.transpile(..., backend="taichi")` at module load — the imported algorithm is the runtime function, not a hand translation. Feature-flag dispatcher (`MECHDSL_USE_IMPORTED_RR`). +- **`docs` pytest-marker tier**: `@pytest.mark.docs` registered for documentation-anchor / contract tests, routed by a dedicated CI job. ### Fixed — `algo2code` parser @@ -46,36 +38,27 @@ All notable changes to this project will be documented in this file. - **Binary `/` in expressions**: `parse_term` now recognises the `SLASH` token (previously dropped, e.g. `a + b / c` parsed as `a + b`). - **Scalar-only algorithms**: `taichi_codegen._emit_driver` no longer emits the dangling `n = b.shape[0]` line when no vector argument is present. -### Added — Recovery: LaTeX-first contract restoration - -The `back2latex` meta-plan reshapes `dev/plans/recovery_plan_latex_contract.md` so `Aut_Faciam` can ingest it, then the recovery plan's first three phases land: - -- **Canonical entry point**: `mechdsl.compile_latex(source: str, profile: str = "mvp") -> ArtifactBundle` parses `% mechanics` directives, adapts the resulting context dict to a `ProblemIR`, and forwards through the existing pipeline. `mechdsl.compile` (legacy programmatic path) is preserved verbatim. Allowed profile set is exposed as `mechdsl.ALLOWED_PROFILES = frozenset({"mvp"})` (extend via this set, never relax inline). -- **`ProblemIR` semantic enrichment** (recovery R2 / P3-1): four new optional frozen dataclasses — `FieldSpec`, `DomainSpec`, `MeshContract`, `ResidualContract` — exposed from `mechdsl.ir.mechanics_ir`. `ProblemIR` carries them as new optional fields with safe defaults (`fields=()`, `domain=None`, `mesh_contract=None`, `residual_contract=None`). `ProblemIR.to_dict/from_dict` extended to round-trip both legacy (no enrichment keys) and enriched dicts. Backward compat verified across 84 `ProblemIR(...)` construction sites. -- **`FieldSpec.kind` validation**: rejects out-of-vocabulary values; allowed set exposed as `mechdsl.ir.mechanics_ir.ALLOWED_FIELD_KINDS = frozenset({"scalar", "vector", "tensor"})`. -- **Immutable enrichment metadata**: `DomainSpec.metadata`, `MeshContract.metadata`, `ResidualContract.metadata` wrap their backing dicts in `MappingProxyType` so the frozen-dataclass invariant extends through nested mutation. -- **Tier policy and stability contract** (recovery R0): new `## Support tiers` and `### Stability policy` sections in `README.md`. Two tiers: `MVP-stable` (Hex8 + Total Lagrangian + Taichi backend + SVK/J2-power-law) vs `experimental` (MFEM/MOOSE codegen, explicit dynamics, non-MVP materials, non-canonical elements). Module docstrings on `codegen/mfem_printer.py`, `codegen/moose_printer.py`, `solver/lumped_mass.py`, `symbolic/models/__init__.py`, and `ir/mechanics_ir.py::ElementType` carry the `experimental` marker. -- **Frontend architectural split** (recovery R1.3): new `packages/mechdsl-core/src/mechdsl/frontend/ARCHITECTURE.md` documents NRPyLaTeX as the parser of record vs the local adapter / normalizer / validator triad (parser.py / directives.py / build_context / two_point.py). Each module's docstring identifies its role. -- **Tracker status vocabulary** (recovery R0 / P1-4): canonical four-value set defined in new `dev/tracking/STATUS_LEGEND.md` — `not_started`, `done`, `deferred`, `implemented-via-substitute` (plus Aut_Faciam-internal `in_progress`). Replaces the legacy two-value `not_started` / `done` set that conflated three genuinely different states. -- **Frontend deferral history note**: `dev/reviews/frontend_drift_history.md` classifies the deferred MVP `P2.1..P2.5` work against three patterns (planned-but-deferred / never-planned / implemented-via-substitute). Cross-linked to `drift_20_04.md` and the recovery plan. -- **MVP plan supersession**: `dev/plans/MVP_plan.md` and `MVP_sprint{1,2,3}.md` carry banners pointing at the recovery plan; the five legacy `P2.x` rows in `tasks-tracker_MVP_plan.md` are retagged `implemented-via-substitute` with substitute citations. -- **README Quickstart**: leads with the `compile_latex` LaTeX-source example; the programmatic `build_context` path moves to a Secondary subsection. -- **First LaTeX-source contract test suite**: `packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_5.py` is the first test that begins from a real LaTeX source and reaches an `ArtifactBundle` (SVK + J2 power-law). Companion `test_p2_6.py` covers 10 negative paths through `compile_latex` (unsupported constructs, malformed directives, index semantics, stable-message contract). -- **MVP-stable subset contract** (recovery R2 / P3-4): new `MVP_STABLE_SUBSET` constant (a frozen `MvpStableSubset` dataclass) in `mechdsl.ir.mechanics_ir` enumerates every `ProblemIR` axis covered by the canonical compile path. New `ProblemIR.assert_mvp_stable() / is_mvp_stable()` methods enforce the contract at the IR boundary; `MvpSubsetViolation` subclasses `UnsupportedError` so existing rule-based catches still apply. Contract documented in new `packages/mechdsl-core/src/mechdsl/ir/ARCHITECTURE.md` (sibling of the implementation, since `dev/design_docs/` is hook-protected). -- **Canonical context-dict adapter** (recovery R2 / P3-2): new `ProblemIR.from_context()` and `BoundaryCondition.from_context()` classmethods replace three private duplicates that had drifted across `mechdsl/__init__.py`, `tests/test_full_pipeline.py`, and `tests/test_formulation_switching.py`. The adapter accepts the canonical `name` key as well as legacy `region`/`face` aliases and `dofs`/`components` aliases for the boundary subschema. `compile_latex` now calls the classmethod directly. -- **Targeted IR validation** (recovery R2 / P3-5): six new construction-time checks on `ProblemIR.__post_init__` that surface previously-silent malformed IRs at the construction site instead of deep inside codegen / runtime — duplicate boundary names, out-of-range BC component indices, duplicate spatial / material coordinate names, duplicate `FieldSpec.name` entries, BC `field_name` consistency with declared `fields`. The compile-path `compile_latex` now calls `assert_mvp_stable()`, which adds the seventh check (required material parameters for MVP-stable models) at the user-visible boundary. In-tree research code that builds minimal IRs for shape-only testing is unaffected. -- **Centralized boundary/domain semantic assumptions** (recovery R2 / P3-3): new `ProblemIR.required_region_tags()` and `ProblemIR.derived_mesh_contract()` helpers expose the previously-scattered "BC name == mesh boundary tag" assumption as a single source of truth on the IR. New `mechdsl.solver.mesh_io.validate_mesh_against_contract()` bridges `MeshContract` ↔ `HexMesh.boundary_tags` so the IR/mesh mismatch raises `BoundaryRegionError` at the boundary instead of a deep `KeyError` in codegen / runtime. -- **`ElementIR` execution-contract enrichment** (recovery R3 / P4-1): four new optional frozen dataclasses — `GeometrySummary`, `MaterialEvalContract` (with `ALLOWED_STRESS_MEASURES` / `ALLOWED_STRAIN_MEASURES` allowlists), `LocalForceDescriptor`, `LocalTangentDescriptor` — exposed from `mechdsl.ir.element_ir`. `ElementIR` carries them as new optional fields with safe `None` defaults. Construction-time consistency checks reject mismatched `n_dof`, mismatched `n_quad`, and TL/UL stress-measure conflicts. New `ElementIR.to_dict / from_dict` round-trips both legacy and enriched forms; basis / quadrature numerics are reconstructed from canonical constructors via `element_type`. -- **`EinsumSpec` / `LocalisationResult` demoted to derived views** (recovery R3 / P4-2): docstrings explicitly mark them as derived optimization views over `ElementIR` (the post-P4-1 primary semantic carrier). New `LocalisationResult.from_element_ir(element_ir, problem_ir)` classmethod materialises the optimizer-view bundle from an enriched `ElementIR` — making the derived-view relationship explicit in code. Production path `localise(problem_ir)` unchanged. -- **Lowering emits enriched `ElementIR` first** (recovery R3 / P4-3): new `_enrich_element_ir(legacy_ir, problem_ir)` helper in `fe_localise.py` populates the four P4-1 contract dataclasses from the `ProblemIR` / `ElementIR` semantics. `localise()` now emits the enriched IR first, then derives the einsum optimizer view via `LocalisationResult.from_element_ir`. `ArtifactBundle.from_pipeline` surfaces the four contract blocks in `element_ir_summary`; pre-P4-3 bundles round-trip cleanly (new keys default to `None`). -- **Deterministic lowering rejections with Plan-B pointers** (recovery R3 / P4-4): new `LocalisationError(UnsupportedError, ValueError)` exception re-exported from `mechdsl.lowering`. Multiple inheritance preserves back-compat with both `UnsupportedError` (per `.claude/rules/ir.md`) and pre-P4-4 callers that caught the bare `ValueError`. New `_check_stable_path_combo(problem_ir)` helper fires axis-by-axis (formulation → element → material); every rejection message names the offending construct AND the Plan-B phase that adds support. -- **Artifact bundling reflects enriched IR ownership** (recovery R3 / P4-5): new `ArtifactBundle.element_ir_dict` field carries the canonical `ElementIR.to_dict()` contract surface (P4-1 enrichment included). Default empty dict so pre-P4-5 bundles round-trip cleanly. `content_hash` deliberately unchanged so legacy goldens survive verbatim. The bundle docstring now spells out the ownership hierarchy: `problem_ir_dict` (semantic input) → `element_ir_dict` (primary semantic carrier) → `element_ir_summary` (legacy summary, derived) → `contraction_plans` (derived optimizer view). - -### Changed - -- **`build_context()`** is now documented as the **secondary** programmatic entry point. It remains importable and functional; `compile_latex` is the canonical surface for new code and documentation. - - +### Added — LaTeX-first contract + +- **Canonical entry point**: `mechdsl.compile_latex(source: str, profile: str = "mvp") -> ArtifactBundle` parses `% mechanics` directives, adapts the resulting context dict to a `ProblemIR`, and forwards through the existing pipeline. `mechdsl.compile` (legacy programmatic path) is preserved verbatim. Allowed profile set is exposed as `mechdsl.ALLOWED_PROFILES`. +- **`ProblemIR` semantic enrichment**: four new optional frozen dataclasses — `FieldSpec`, `DomainSpec`, `MeshContract`, `ResidualContract` — exposed from `mechdsl.ir.mechanics_ir`, carried by `ProblemIR` with safe defaults and full `to_dict`/`from_dict` round-tripping of both legacy and enriched dicts. +- **`FieldSpec.kind` validation**: rejects out-of-vocabulary values; allowed set exposed as `ALLOWED_FIELD_KINDS = frozenset({"scalar", "vector", "tensor"})`. +- **Immutable enrichment metadata**: nested metadata dicts wrapped in `MappingProxyType` so the frozen-dataclass invariant extends through nested mutation. +- **Tier policy and stability contract**: new `## Support tiers` and `### Stability policy` README sections. Two tiers: `MVP-stable` (Hex8 + Total Lagrangian + Taichi backend + SVK/J2-power-law) vs `experimental` (MFEM/MOOSE codegen, explicit dynamics, non-MVP materials, non-canonical elements), with module-docstring markers on experimental modules. +- **Frontend architectural split**: `mechdsl/frontend/ARCHITECTURE.md` documents NRPyLaTeX as the parser of record vs the local adapter / normalizer / validator triad. +- **README Quickstart**: leads with the `compile_latex` LaTeX-source example; the programmatic `build_context` path moves to a secondary subsection. +- **LaTeX-source contract tests**: end-to-end suites that begin from a real LaTeX source and reach an `ArtifactBundle` (SVK + J2 power-law), plus 10 negative paths through `compile_latex` (unsupported constructs, malformed directives, index semantics, stable-message contract). +- **MVP-stable subset contract**: new `MVP_STABLE_SUBSET` constant in `mechdsl.ir.mechanics_ir` enumerates every `ProblemIR` axis covered by the canonical compile path; `ProblemIR.assert_mvp_stable() / is_mvp_stable()` enforce it at the IR boundary (`MvpSubsetViolation` subclasses `UnsupportedError`). Documented in `mechdsl/ir/ARCHITECTURE.md`. +- **Canonical context-dict adapter**: `ProblemIR.from_context()` and `BoundaryCondition.from_context()` classmethods replace three drifted private duplicates; legacy `region`/`face` and `dofs`/`components` aliases accepted. +- **Targeted IR validation**: six new construction-time checks on `ProblemIR.__post_init__` (duplicate boundary names, out-of-range BC component indices, duplicate coordinate/field names, BC `field_name` consistency), plus required-material-parameter checking via `assert_mvp_stable()` on the compile path. +- **Centralized boundary/domain semantic assumptions**: `ProblemIR.required_region_tags()` and `derived_mesh_contract()` expose the "BC name == mesh boundary tag" assumption as a single source of truth; `mechdsl.solver.mesh_io.validate_mesh_against_contract()` raises `BoundaryRegionError` at the boundary instead of a deep `KeyError` in codegen. +- **`ElementIR` execution-contract enrichment**: four new optional frozen dataclasses — `GeometrySummary`, `MaterialEvalContract` (with stress/strain-measure allowlists), `LocalForceDescriptor`, `LocalTangentDescriptor` — carried by `ElementIR` with construction-time consistency checks (mismatched `n_dof` / `n_quad`, TL/UL stress-measure conflicts) and full `to_dict`/`from_dict` round-tripping. +- **`EinsumSpec` / `LocalisationResult` demoted to derived views**: explicitly documented as derived optimization views over `ElementIR`; new `LocalisationResult.from_element_ir(element_ir, problem_ir)` materialises the optimizer-view bundle. +- **Lowering emits enriched `ElementIR` first**: `localise()` emits the enriched IR, then derives the einsum optimizer view; `ArtifactBundle.from_pipeline` surfaces the contract blocks in `element_ir_summary` with clean round-tripping of pre-enrichment bundles. +- **Deterministic lowering rejections**: new `LocalisationError(UnsupportedError, ValueError)` re-exported from `mechdsl.lowering`; rejections fire axis-by-axis (formulation → element → material) and every message names the offending construct and the roadmap phase that adds support. +- **Artifact bundling reflects enriched IR ownership**: new `ArtifactBundle.element_ir_dict` field carries the canonical `ElementIR.to_dict()` contract surface; `content_hash` deliberately unchanged so legacy goldens survive verbatim. + +### Added — Verification benchmarks - **Thick cylinder benchmark** (TL × SVK × Hex8): radial and hoop stress compared against Lamé closed-form; 5% gate on peak hoop stress (`mechdsl.verify.benchmarks.thick_cylinder`) - **Necking bar benchmark** (TL × J2 × Hex8): load-displacement history compared against Simo & Hughes reference and committed golden within 2% (`mechdsl.verify.benchmarks.necking_bar`) @@ -83,32 +66,32 @@ The `back2latex` meta-plan reshapes `dev/plans/recovery_plan_latex_contract.md` - **HGO fiber-strip benchmark** (TL × HGO × Hex8): uniaxial FEM stress compared against closed-form HGO reference via damped-Newton lateral-stretch solve; 5% gate at multiple stretch levels (`mechdsl.verify.benchmarks.hgo_strip`) - **`mechdsl.verify.benchmarks` module**: unified `BenchmarkResult` dataclass + kwargs-injection contract for all four benchmarks; `verify` package remains free of `tests/` imports -### Added — Plan B Phase 9: Contraction-Family Registry (B9) +### Added — Contraction-Family Registry - **8-family taxonomy**: `Family` enum classifying all emitted einsum strings — `DISPLACEMENT_GRADIENT`, `FORCE_INTEGRATION`, `MATERIAL_TANGENT_CONTRACTION`, `STRAIN_ENERGY`, `MASS_MATRIX`, `GEOMETRIC_STIFFNESS`, `KINEMATIC_INTEGRATION`, `FALLBACK` — enabling family-aware dispatch and JIT-budget planning (`mechdsl.codegen.family_registry`) - **Family-aware emission dispatch**: backend printers select optimised code paths per family; rollback flag re-routes to the generic path on unsupported families - **JIT budget regression suite**: parametric test grid over all (element × material × backend) triples; emitted line counts checked against the 512 / 2000 / 5000 limits -### Added — Plan B Phase 8: MFEM + MOOSE Backends (B8) +### Added — MFEM + MOOSE Backends (experimental) -- **MFEM printer**: C++ `NonlinearFormIntegrator` + `BilinearFormIntegrator` emission; Voigt conversion helpers (`voigt_tensorial_to_engineering`); CMakeLists template; MPI-compatible output (`mechdsl.codegen.mfem_printer`) +- **MFEM printer**: C++ `NonlinearFormIntegrator` + `BilinearFormIntegrator` emission; Voigt conversion helpers; CMakeLists template; MPI-compatible output (`mechdsl.codegen.mfem_printer`) - **MOOSE printer**: `ComputeStressBase` + `RankTwoTensor` emission; MOOSE input-file template; material-block code generation (`mechdsl.codegen.moose_printer`) - **Cross-backend verification**: Taichi vs MFEM vs MOOSE patch-test equivalence within relative tolerance; mesh-exporter utilities shared across all three backends -### Added — Plan B Phase 7: Explicit Dynamics (B7) +### Added — Explicit Dynamics (experimental) - **Lumped mass matrix**: HRZ row-sum lumping for Hex8 (`mechdsl.solver.mass`) - **Central-difference explicit driver**: Courant-stable velocity-Verlet loop with diagonal mass inversion and residual-force assembly (`mechdsl.solver.explicit`) - **Critical time step helper**: `courant_dt(mesh, E, nu, rho)` from minimum element characteristic length and longitudinal wave speed - **Free vibration cross-check**: explicit driver verified against implicit quasi-static reference on a cantilever beam -### Added — Plan B Phase 6: Continuum Damage (B6) +### Added — Continuum Damage - **Lemaitre damage model**: scalar isotropic damage coupled to J2 power-law plasticity; effective-stress principle with strain equivalence; de Souza Neto triaxiality factor; nucleation threshold `eps_D`; clamped at `D_MAX = 1 − 1e-6` (`mechdsl.symbolic.models.lemaitre`) - **History field integration**: Lemaitre `D` field tracked through Newton iterations; element deletion assembly at `D >= D_crit` - **Notched bar localisation test**: D=0 regression; damage confirmed to localise at the notch-root element -### Added — Plan B Phase 5: Additional Elements (B5) +### Added — Additional Elements - **Tet4 linear tetrahedron**: 4-node element, 1-point Gauss quadrature (`mechdsl.codegen.tet4_tables`) - **Tet10 quadratic tetrahedron**: 10-node serendipity, 4-point Gauss quadrature (`mechdsl.codegen.tet10_tables`) @@ -117,7 +100,7 @@ The `back2latex` meta-plan reshapes `dev/plans/recovery_plan_latex_contract.md` - **Flanagan-Belytschko hourglass control**: stabilisation for reduced-integration Hex8 (`mechdsl.codegen.hourglass`) - **ElementFactory API**: `ElementFactory.create(element_type, integration_scheme)` — uniform construction replacing element-specific imports -### Added — Plan B Phase 4: Hyperelastic Models (B4) +### Added — Hyperelastic Models - **Neo-Hookean model**: isochoric-volumetric split (`Psi = mu/2*(I1_bar-3) + kappa/2*(J-1)^2`); analytic PK2 stress and closed-form 4th-order tangent; `NeoHookeanMaterial.from_E_nu()` convenience factory (`mechdsl.symbolic.models.neo_hookean`) - **Mooney-Rivlin model**: two-parameter `(C10, C01)` with volumetric penalty; analytic stress and FD tangent (`mechdsl.symbolic.models.mooney_rivlin`) @@ -125,27 +108,31 @@ The `back2latex` meta-plan reshapes `dev/plans/recovery_plan_latex_contract.md` - **HGO anisotropic model**: Holzapfel-Gasser-Ogden dispersion model with two fiber families; fiber activation gate (`E_fi > 0`); FD tangent robust at the activation boundary (`mechdsl.symbolic.models.hgo`) - **AD oracle**: automatic-differentiation cross-check verifying PK2 stress and Voigt tangent for all four models against FD and AD references -### Added — Plan B Phase 3: Viscoplasticity (B3) +### Added — Viscoplasticity - **Perzyna viscoplasticity**: backward-Euler return map with overstress function; rate-dependent yield; consistent algorithmic tangent (`mechdsl.symbolic.models.perzyna`) - **Johnson-Cook flow stress**: strain-rate sensitivity, adiabatic heating via Taylor-Quinney coefficient, JC parameter validation (`mechdsl.symbolic.models.johnson_cook`) - **Rate-sensitivity acceptance suite**: Perzyna collapses to J2 in the quasi-static limit; rate/thermal cross-checks -### Added — Plan B Phase 1: Updated Lagrangian (B1) +### Added — Updated Lagrangian - **Updated Lagrangian formulation**: spatial shape gradients, Cauchy stress residual, and Jaumann material + geometric stiffness tangent emission — full UL codegen alongside existing Total Lagrangian - **ConfigurationIR extension**: `Formulation.UPDATED_LAGRANGIAN` variant with `reference_frame` and `stress_measure` on `ProblemIR` - **Objective stress rates**: Jaumann and Truesdell rate functions with full-F Piola push-forward (`mechdsl.symbolic.objective_rates`) - **Formulation switching**: `% mechanics set formulation updated_lagrangian` directive; `build_context` and codegen auto-infer configuration from formulation -- **TL/UL equivalence verification**: handwritten UL reference solver (`ref_hex8_ul.py`), rigid rotation invariance test, and TL-vs-UL displacement comparison within 1e-10 +- **TL/UL equivalence verification**: handwritten UL reference solver, rigid rotation invariance test, and TL-vs-UL displacement comparison within 1e-10 -### Added — Plan B Phase 2: Convected Coordinates (B2) +### Added — Convected Coordinates - **Curvilinear reference configurations**: `MetricField` wrapper with symmetry validation, convected metric `g_IJ = G_ref^T C G_ref`, symbolic metric inversion (`mechdsl.symbolic.convected`) - **Differential geometry**: covariant/contravariant base vectors, Christoffel symbols `Gamma^K_{IJ}` with Cartesian fast-path, covariant derivatives for contravariant vectors, covariant vectors, and rank-2 tensors - **Metric-assign directives**: `% mechanics assign gDD --metric_current` parser directive via NRPyLaTeX integration - **Curvilinear patch test**: SVK stress through convected pathway verified constant across 15 (r, theta) points; Cartesian-convected equivalence within 1e-13 +### Changed + +- **`build_context()`** is now documented as the **secondary** programmatic entry point. It remains importable and functional; `compile_latex` is the canonical surface for new code and documentation. + ### Fixed - **UL tangent**: Jaumann + Hadamard geometric stiffness replaced with Truesdell + standard geometric stiffness for correct spatial tangent @@ -162,20 +149,12 @@ The `back2latex` meta-plan reshapes `dev/plans/recovery_plan_latex_contract.md` - **Taichi code generation**: deterministic source emission for solver kernels and Newton drivers - **Solver infrastructure**: CG/PCG adapters, structured Hex8 mesh utilities, boundary-condition codegen, adaptive load stepping, and history-field lifecycle - **Verification assets**: handwritten NumPy references, golden regression bundles, patch test, rigid body, cantilever, Cook's membrane, necking bar, and full-pipeline end-to-end tests -- **Examples and docs**: README installation/quickstart/architecture guide and runnable programmatic examples for cantilever, plastic uniaxial, Cook's membrane, necking bar, and patch test -- **CI tiers**: fast push validation, broader PR validation, and nightly e2e benchmark coverage with regression issue filing +- **Examples and docs**: README installation/quickstart/architecture guide and runnable examples for cantilever, plastic uniaxial, Cook's membrane, necking bar, and patch test +- **CI tiers**: fast push validation, broader PR validation, and nightly e2e benchmark coverage ### Fixed - **Taichi codegen** (5 critical fixes): J2 Newton `ti.static` → runtime for `break` support; quadrature loop to `ti.static` for Python list access; Newton non-convergence raises `RuntimeError`; NaN/Inf guard on residual; node loops to runtime per convention - **Error handling** (8 fixes): CG/PCG breakdown warning; J2 radial return stall guard; emitted CG failure counter; FLOPS extraction sentinel (-1.0); reference elastic Newton `for...else`; boundary codegen face area/axis/empty guards - **Type validation** (7 types): `__post_init__` on `J2PowerLawMaterial`, `SVKMaterial`, `HexMesh`, `QuadratureRule`, `DirichletBC`, `NeumannBC`; `ReturnMappingResult` frozen; `HistoryFields` descriptive errors + duplicate guard -- **CI**: workspace install consistency across jobs and nightly benchmark failures downgraded from merge blockers to issue-creation events -- **Tests** (+25): Error path tests (radial return non-convergence, stalled Newton, degenerate element, invalid face); `__post_init__` validation tests for all 6 types; tolerance tightening (rigid body 1e-10, elastic FD tangent 1e-8) - **Reference solvers**: Dirichlet BC tangent changed from zeroing to identity (`Kv[bc_mask] = v[bc_mask]`) for CG non-singularity -- **Comments**: Simo & Hughes §3.3 → §3.4; "unit normal" → "flow direction"; function rename `emit_constitutive_stub` → `emit_constitutive_update`; convention docs updated for quadrature point carve-out - -### Not yet implemented - -- Phase 2: LaTeX frontend parsing (blocked on NRPyLaTeX fork dependency) -- Plan B features beyond the MVP scope, including Updated Lagrangian (B1), curvilinear reference coordinates (B2), advanced constitutive models (B3/B4/B6), additional elements (B5), explicit dynamics (B7), and alternative backends (B8) diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 591f6e8..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,101 +0,0 @@ - -# GitNexus — Code Intelligence - -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. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/MechDSL/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/MechDSL/context` | Codebase overview, check index freshness | -| `gitnexus://repo/MechDSL/clusters` | All functional areas | -| `gitnexus://repo/MechDSL/processes` | All execution flows | -| `gitnexus://repo/MechDSL/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - - diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e25abb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Shmuel Osovski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 214af9c..fbf93d4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MechDSL -[![CI](https://github.com/SOSOVSKI/MechDSL/actions/workflows/ci.yml/badge.svg)](https://github.com/SOSOVSKI/MechDSL/actions/workflows/ci.yml) +[![CI](https://github.com/CEmM2/MechDSL/actions/workflows/ci.yml/badge.svg)](https://github.com/CEmM2/MechDSL/actions/workflows/ci.yml) > **Write your mechanics in LaTeX. Get a tested finite-element solver back.** > @@ -38,7 +38,7 @@ viscoplasticity, and Lemaitre continuum damage. Backends: Taichi (MVP-stable), M Clone the repository, install the workspace with `uv`, and keep all commands under `uv run`: ```bash -git clone https://github.com/SOSOVSKI/MechDSL.git +git clone https://github.com/CEmM2/MechDSL.git cd MechDSL uv sync ``` @@ -79,10 +79,10 @@ print(bundle.content_hash()) localisation, einsum planning, and Taichi emission. A runnable version of this canonical first-run example lives in -[`dev/examples/run_compile_latex.py`](dev/examples/run_compile_latex.py): +[`examples/run_compile_latex.py`](examples/run_compile_latex.py): ```bash -uv run python dev/examples/run_compile_latex.py +uv run python examples/run_compile_latex.py ``` ### Programmatic API (advanced / testing aid) @@ -143,19 +143,19 @@ print(bundle.element_ir_summary) print(bundle.content_hash()) ``` -Runnable examples live in `dev/examples/`. The canonical LaTeX-first +Runnable examples live in `examples/`. The canonical LaTeX-first script is listed first; the remaining scripts use the programmatic API and are kept as advanced/testing aids: ```bash -uv run python dev/examples/run_compile_latex.py # canonical: LaTeX -> compile_latex +uv run python examples/run_compile_latex.py # canonical: LaTeX -> compile_latex # Programmatic API examples (advanced / testing aids): -uv run python dev/examples/elastic_cantilever.py -uv run python dev/examples/plastic_uniaxial.py -uv run python dev/examples/cook_membrane.py -uv run python dev/examples/necking_bar.py -uv run python dev/examples/patch_test.py -uv run python dev/examples/run_pipeline.py # SVK + J2, write emitted Taichi source to disk +uv run python examples/elastic_cantilever.py +uv run python examples/plastic_uniaxial.py +uv run python examples/cook_membrane.py +uv run python examples/necking_bar.py +uv run python examples/patch_test.py +uv run python examples/run_pipeline.py # SVK + J2, write emitted Taichi source to disk ``` ## Usage examples @@ -256,9 +256,9 @@ uv run mkdocs serve # live preview at http://127.0.0.1:8000 uv run mkdocs build # static site into ./site ``` -The authoritative design specs remain under [`dev/design_docs/`](dev/design_docs/) -(read-only); the `docs/` site is the friendly, task-oriented entry point that links into -them. +The authoritative design specs live in the internal `dev/design_docs/` tree of the +private development repository (read-only); the `docs/` site is the friendly, +task-oriented entry point. ## Architecture @@ -273,16 +273,13 @@ Layer 5 Einsum IR contraction-family registry, plans, and JIT-budget-aware Layer 6 Codegen deterministic source emission — Taichi (MVP-stable), MFEM (experimental, C++), MOOSE (experimental) ``` -Design docs are authoritative and read-only: - -- [`dev/design_docs/00-OVERVIEW.md`](dev/design_docs/00-OVERVIEW.md) for the document map -- [`dev/design_docs/01-ARCHITECTURE.md`](dev/design_docs/01-ARCHITECTURE.md) for pipeline structure -- [`dev/design_docs/08-VERIFICATION.md`](dev/design_docs/08-VERIFICATION.md) for the verification matrix -- [`dev/design_docs/PLAN-B.md`](dev/design_docs/PLAN-B.md) for post-MVP roadmap phases +The design-doc set (document map, pipeline structure, verification matrix, and the +post-MVP roadmap) is maintained in the internal `dev/design_docs/` tree of the +private development repository. Per-layer architecture notes (live alongside the source they describe): -- [`packages/mechdsl-core/src/mechdsl/frontend/ARCHITECTURE.md`](packages/mechdsl-core/src/mechdsl/frontend/ARCHITECTURE.md) — Layer 1 split: NRPyLaTeX as parser of record (math grammar) vs the local adapter / normalizer / validator triad (`parser.py` + `directives.py` + `build_context` + `two_point.py`). Introduced by recovery-plan Phase 2 (R1.3). +- [`packages/mechdsl-core/src/mechdsl/frontend/ARCHITECTURE.md`](packages/mechdsl-core/src/mechdsl/frontend/ARCHITECTURE.md) — Layer 1 split: NRPyLaTeX as parser of record (math grammar) vs the local adapter / normalizer / validator triad (`parser.py` + `directives.py` + `build_context` + `two_point.py`). ### `mechdsl-core` ↔ `algo2code` integration @@ -295,40 +292,33 @@ calls through. Concrete adapters (`ScipyCGSolver`, `CGSolver`, `PCGSolver`, and `Algo2CodePCGSolver`) all satisfy that interface and are selected via `mechdsl.solver.integration.select_linear_solver(...)`. -Recovery-plan Phase 6 landed the canonical PCG path (P6-1 through P6-3): -`Algo2CodePCGSolver` is a verbatim line-by-line Python translation of the +The canonical PCG path: `Algo2CodePCGSolver` is a verbatim line-by-line Python translation of the LaTeX algpseudocode held in `algo2code.library.pcg.PCG_ALGORITHM_LATEX`, which is the single source of truth for the PCG algorithm. It is opt-in via `select_linear_solver("generated")` or `newton_solve(..., linear_solver=...)` — the default remains -`ScipyCGSolver` until further validation. The `algo2code`-generated -radial-return constitutive seam (Plan A Phase A9) is deferred per -recovery plan §P6-4. +`ScipyCGSolver` until further validation. -The authoritative architecture reference for this seam is -[`dev/design_docs/11-ALGO2CODE.md`](dev/design_docs/11-ALGO2CODE.md) -(see §1.1 for the integration points and §2.5 for the canonical PCG -algpseudocode). +The authoritative architecture reference for this seam is the `11-ALGO2CODE` +design doc in the internal `dev/design_docs/` tree (§1.1 for the integration +points, §2.5 for the canonical PCG algpseudocode). ## Support tiers -MechDSL classifies every public feature into one of two support tiers (introduced by -[`dev/plans/recovery_plan_latex_contract.md`](dev/plans/recovery_plan_latex_contract.md) -Phase 1): +MechDSL classifies every public feature into one of two support tiers: - **`MVP-stable`** — features supporting the canonical LaTeX-driven compile path: Hex8 element, Total Lagrangian formulation, convected curvilinear coordinates, St. Venant–Kirchhoff elasticity, J2 plasticity with power-law hardening, and the Taichi backend. These are the only surfaces guaranteed to remain stable across - recovery work. + releases. - **`experimental`** — features preserved in the tree but not part of the canonical contract: MFEM and MOOSE codegen backends, explicit dynamics, non-MVP materials (Mooney-Rivlin, Ogden, HGO, viscoplasticity, damage), and non-canonical elements (Hex8-R, Hex20, Tet4, Tet10). These remain available for use but may - shift, lose tests, or become labeled deprecated as the recovery plan - progresses. + shift, lose tests, or become labeled deprecated as development progresses. -The recovery plan is additive: experimental scope is **not** deleted; it is +The tier split is additive: experimental scope is **not** deleted; it is labeled so the canonical story is unambiguous. ### `mechdsl.integration` — MVP-stable machine-readable façade @@ -367,7 +357,7 @@ vr = verify("patch_test", {"lam": 1.0, "mu": 1.0}) # pays the Taichi cost print(vr["passed"]) ``` -Do not add entry points to this module without a plan-level decision; the +Do not add entry points to this module without a deliberate design decision; the surface is a machine API contract, not a convenience library. ### Stability policy @@ -375,19 +365,14 @@ surface is a machine API contract, not a convenience library. The two tiers carry different commitments: - **MVP-stable** features have a stable public API and a passing test suite - on every commit to `main`. Breaking changes require an entry in the - recovery plan (or a follow-up plan) and a tracker row that follows the - status vocabulary in [`dev/tracking/STATUS_LEGEND.md`](dev/tracking/STATUS_LEGEND.md). + on every commit to `main`. Breaking changes require a documented design decision and a + changelog entry. - **experimental** features may evolve, lose tests, or be deprecated without a release-note entry. They live behind module docstrings that say so (see e.g. `packages/mechdsl-core/src/mechdsl/codegen/mfem_printer.py`). Reaching for an experimental feature is supported, but only with the understanding that the contract is provisional. -The full motivation, including the historical drift that led to this -policy, is in [`dev/plans/recovery_plan_latex_contract.md`](dev/plans/recovery_plan_latex_contract.md) -and [`dev/reviews/frontend_drift_history.md`](dev/reviews/frontend_drift_history.md). - ## CI and Verification The repository currently runs three CI tiers: @@ -414,4 +399,5 @@ from `build_context()` through emitted Taichi source generation. | B9 | Contraction-family registry + family-aware emission dispatch | Done | | B10 | Verification benchmark suite (thick cylinder, necking, HGO strip) | Done | -See [`dev/design_docs/PLAN-B.md`](dev/design_docs/PLAN-B.md) for the full roadmap. +The full roadmap lives in the internal `dev/design_docs/` tree of the private +development repository. diff --git a/RELEASE_ORDER.md b/RELEASE_ORDER.md deleted file mode 100644 index 9fafb68..0000000 --- a/RELEASE_ORDER.md +++ /dev/null @@ -1,139 +0,0 @@ -# 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/dev/algorithms/jacobi.tex b/dev/algorithms/jacobi.tex new file mode 100644 index 0000000..bea46db --- /dev/null +++ b/dev/algorithms/jacobi.tex @@ -0,0 +1,51 @@ +% Point-Jacobi preconditioner apply (PlanJune14 P4-1). +% +% Computes the Jacobi (diagonal) preconditioned vector: +% +% z_I = r_I / max(d_I, eps) +% +% where ``d`` is the diagonal of the system matrix (point-Jacobi: M = diag(d)), +% ``r`` is the residual vector, ``z`` is the output (M^{-1} r), and ``eps`` is a +% small positive guard against near-zero diagonal entries. All operands are +% degree-of-freedom vectors (one value per node or per component). +% +% Grammar note (2026-06-15, P4-1): +% ----------------------------------- +% algo2code currently lowers vector/vector division (`z = r / d`) to the Python +% expression `z = (r / d)`, which is not runnable for `ti.Vector.field` operands +% (Taichi raises TypeError: unsupported operand type(s) for /: MatrixField / +% MatrixField). The grammar supports scalar operations and vector +/-/scale/ +% callable-matvec, but has no `vec_ediv` (elementwise divide) primitive. +% +% The clean fix is a new `vec_ediv(z, r, d, eps)` primitive in ti_runtime +% vector_ops — exactly what P4-1 adds. The generated body below is therefore +% realised as a thin `@ti.data_oriented` adapter +% (`mechdsl.solver.jacobi_preconditioner.GeneratedJacobiPreconditioner`) whose +% `_apply` kernel calls `ti_runtime.vector_ops.ediv(z, r, d, eps)` — a +% generated body in the sense that the algorithm is expressed in LaTeX and +% realised via a structured adapter, not hand-coded heuristics. +% +% When algo2code gains a `vec_ediv` primitive (or scalar-field elementwise ops), +% this box can be re-transpiled to a fully auto-generated driver with no adapter +% shim. +% +% Inputs +% ------ +% r : residual vector (numerator) +% d : diagonal vector (denominator) +% eps : small positive guard (scalar) -- assumed positive (SPD diagonal guard) +% +% Output +% ------ +% z : preconditioned vector = M^{-1} r + +% algorithm jacobi_apply +% backend taichi +% args r:vector, d:vector, eps:scalar + +% type z vector + +\begin{algorithmic} +\State $z_I = r_I \, / \, \max(d_I,\, \varepsilon)$ \Comment{elementwise; for all DOF indices $I$} +\Return $z$ +\end{algorithmic} diff --git a/dev/algorithms/newmark.tex b/dev/algorithms/newmark.tex new file mode 100644 index 0000000..866e2c2 --- /dev/null +++ b/dev/algorithms/newmark.tex @@ -0,0 +1,99 @@ +% Newmark-beta implicit time integration — single step (PlanJune14 P6-1). +% +% The canonical Newmark-beta step authored for the **all-Taichi on-device +% time-integration seam path** (the temporal analogue of the matrix-free PCG in +% ``pcg.tex``): the per-step acceleration solve ``solve_a`` is a *callable* +% (matrix-free), so this box transpiles via +% ``algo2code.transpile(..., runtime='ti_runtime')`` into a body that advances a +% dynamic state ``(u, v, a)`` one step over ``ti.Vector.field`` DOF vectors +% **without NumPy in the hot path** — every update is a ``ti_runtime.vector_ops`` +% AXPBY/copy call and the only non-primitive call is the injected ``solve_a``. +% +% Scheme (Hughes 2000, Box 9.1; Newmark 1959) +% ------------------------------------------- +% For the semidiscrete equation of motion ``M a + C v + K u = F`` the Newmark +% family advances ``(u_n, v_n, a_n) -> (u_{n+1}, v_{n+1}, a_{n+1})`` via the +% predictor/corrector split: +% +% predictors (known from step n): +% u_pred = u_n + dt*v_n + dt^2*(1/2 - beta)*a_n +% v_pred = v_n + dt*(1 - gamma)*a_n +% +% acceleration solve (the only system solve — folded into the callable): +% a_{n+1} = solve_a(u_pred, v_pred) +% i.e. solve (M + gamma*dt*C + beta*dt^2*K) a_{n+1} +% = F_{n+1} - C*v_pred - K*u_pred +% +% correctors: +% u_{n+1} = u_pred + beta*dt^2 * a_{n+1} +% v_{n+1} = v_pred + gamma*dt * a_{n+1} +% +% gamma = 1/2 gives second-order accuracy; beta = 1/4 (with gamma = 1/2) is the +% average-acceleration / trapezoidal rule — unconditionally stable, no numerical +% damping. HHT-alpha is a one-parameter generalisation (alpha-weighted residual); +% it reuses the same predictor/corrector skeleton and is intentionally NOT +% authored here — Newmark-beta is the P6-1 deliverable, HHT is optional/next. +% +% Seam contract (mirrors ti_runtime.seams.TimeIntegrationContext) +% --------------------------------------------------------------- +% solve_a(u_pred, v_pred, a_out) — acceleration solve: a_out = a_{n+1} +% (out LAST; the in-place callable convention, +% same shape as the PCG operator A(out, x)). +% The callable owns the mass / damping / stiffness / external-force data; the box +% is mass-/material-agnostic, exactly like ``pcg.tex`` is operator-agnostic. +% +% Grammar note (2026-06-15, P6-1) +% ------------------------------- +% This box transpiles **directly** through algo2code in ``runtime='ti_runtime'`` +% mode with no grammar gap: the predictor/corrector updates are all +% ``scalar*vector`` and ``vector +/- vector`` (fused ``_v.vec_add`` AXPBY calls), +% the scalar coefficients ``c0..c3`` are plain scalar arithmetic, and the +% acceleration solve is an in-place ``callable`` (``solve_a``) — the matrix-free +% seam form (11-ALGO2CODE §8.3). No NumPy, no dense ``_matvec``. (Verified: the +% emitted ``newmark_step`` body contains only ``_v.vec_add`` / ``_v.copy`` / +% ``solve_a(...)``.) +% +% The two predictor scalar coefficients are precomputed into named scalars +% (``c0``, ``c1``) and the corrector coefficients into (``c2``, ``c3``) so each +% vector update fuses to a single AXPBY; ``dt`` itself is the predictor velocity +% coefficient (``u + dt*v``). +% +% Inputs +% ------ +% u : displacement vector at step n (carries the state; updated in place) +% v : velocity vector at step n (updated in place) +% a : acceleration vector at step n (updated in place) +% solve_a : acceleration solve (callable, in-place ``solve_a(u_pred, v_pred, a_out)``) +% dt : time step (scalar) +% beta : Newmark beta (scalar; 1/4 = average acceleration) +% gamma : Newmark gamma (scalar; 1/2 = second order, no algorithmic damping) +% +% Output +% ------ +% u, v, a — the advanced state (u_{n+1}, v_{n+1}, a_{n+1}) + +% algorithm newmark_step +% backend taichi +% args u:vector, v:vector, a:vector, solve_a:callable, dt:scalar, beta:scalar, gamma:scalar + +% type u_pred vector +% type v_pred vector +% type a_new vector +% type c_0 scalar +% type c_1 scalar +% type c_2 scalar +% type c_3 scalar + +\begin{algorithmic} +\State $c_0 = dt \cdot dt \cdot (0.5 - \beta)$ % scalar +\State $c_1 = dt \cdot (1 - \gamma)$ % scalar +\State $c_2 = \beta \cdot dt \cdot dt$ % scalar +\State $c_3 = \gamma \cdot dt$ % scalar +\State $u_{\text{pred}} = u + dt \cdot v + c_0 \cdot a$ % vector +\State $v_{\text{pred}} = v + c_1 \cdot a$ % vector +\State $a_{\text{new}} = \text{solve\_a}(u_{\text{pred}}, v_{\text{pred}})$ % vector +\State $u = u_{\text{pred}} + c_2 \cdot a_{\text{new}}$ % vector +\State $v = v_{\text{pred}} + c_3 \cdot a_{\text{new}}$ % vector +\State $a = a_{\text{new}}$ % vector +\Return $u, v, a$ +\end{algorithmic} diff --git a/dev/algorithms/pcg.tex b/dev/algorithms/pcg.tex new file mode 100644 index 0000000..0d051bf --- /dev/null +++ b/dev/algorithms/pcg.tex @@ -0,0 +1,103 @@ +% Matrix-free Preconditioned Conjugate Gradient (PCG) — PlanJune14 P4-2. +% +% The canonical PCG algorithm authored for the **all-Taichi on-device seam path** +% (Option 1): the operator ``A`` and the preconditioner ``M_inv`` are both +% *callables* (matrix-free), so this box transpiles via +% ``algo2code.transpile(..., runtime='ti_runtime')`` into a body that solves +% ``A x = b`` over ``ti.Vector.field`` DOF vectors without ever forming or storing +% a matrix and without NumPy in the hot path. +% +% Provenance +% ---------- +% Identical in algorithm to the canonical PCG LaTeX +% (``algo2code.library.pcg.PCG_ALGORITHM_LATEX`` / the hand twin +% ``mechdsl.solver.import_adapter.Algo2CodePCGSolver``) and to the matrix-free +% ``_CALLABLE_PCG_LATEX`` proven runnable in P2-2. The *only* surface change vs +% the canonical box is the operator type: +% +% canonical: ``% args A:matrix, ... apply_M_inv:callable, ...`` +% here: ``% args A:callable, ... M_inv:callable, ...`` +% +% A matrix ``A`` lowers to a dense scalar-indexed ``_matvec`` (incompatible with +% the ``ti.Vector.field`` layout the seam uses); a callable ``A`` lowers to the +% in-place ``A(out, p)`` operator call — the ``ti_runtime`` ``apply_A(out, x)`` +% seam contract. The P2-2 runtime-mode guard requires exactly this: a callable +% operator + a vector argument. +% +% Seam contract (mirrors ti_runtime.seams.LinearSolveContext) +% ----------------------------------------------------------- +% A(out, x) — operator: out = A @ x (out FIRST) +% M_inv(r, z) — preconditioner: z = M^{-1} r (out LAST) +% +% This box productionizes the PJ-1 spike's hand-written ``pcg`` body +% (``packages/mechdsl-core/tests/spike/svk_hex8_taichi.py``): the generated body +% maps line-for-line to that hand-written PCG, but is *derived from LaTeX* rather +% than hand-coded. +% +% Inputs +% ------ +% A : matrix-free operator (callable, in-place ``A(out, x)``) +% b : right-hand side vector +% x : solution vector (carries the initial guess; solved in place) +% M_inv : preconditioner apply (callable, in-place ``M_inv(r, z)``) +% tol : relative convergence tolerance (scalar) +% maxiter : maximum iteration count (scalar) +% +% Output +% ------ +% x, iterations, residual_norm, converged +% +% ``converged`` is a hard non-convergence flag: literal ``1`` on the two +% success returns (the ``r_0 = 0`` early-out and the in-loop tolerance hit), +% literal ``0`` on maxiter exhaustion and on the ``|pq| < 1e-300`` breakdown +% (which ``break``s out of the loop and falls through to the maxiter return). +% A caller that sees ``converged == 0`` must NOT consume ``x`` -- the inner +% solve failed and ``x`` is garbage (see seam_solve.make_seam_solver, which +% raises rather than letting a Newton step advance on it). + +% algorithm pcg +% backend taichi +% args A:callable, b:vector, x:vector, M_inv:callable, tol:scalar, maxiter:scalar + +% type r vector +% type z vector +% type p vector +% type q vector +% type rho scalar +% type rho_new scalar +% type alpha scalar +% type beta scalar +% type pq scalar +% type r0_norm scalar +% type r_norm scalar + +\begin{algorithmic} +\State $r = b - A \cdot x$ % vector +\State $r_0 = \lVert r \rVert_2$ % scalar +\If{$r_0 = 0$} + \Return $x, 0, 0, 1$ +\EndIf +\State $z = M^{-1}(r)$ % vector +\State $p = z$ % vector +\State $\rho = r^\top z$ % scalar +\For{$k = 1, 2, \ldots, \text{maxiter}$} + \State $q = A \cdot p$ % vector + \State $pq = p^\top q$ % scalar + \If{$|pq| < 10^{-300}$} + \State \textbf{break} + \EndIf + \State $\alpha = \frac{\rho}{pq}$ % scalar + \State $x = x + \alpha \, p$ % vector + \State $r = r - \alpha \, q$ % vector + \State $r_n = \lVert r \rVert_2$ % scalar + \If{$r_n < \text{tol} \cdot r_0$} + \Return $x, k, r_n, 1$ + \EndIf + \State $z = M^{-1}(r)$ % vector + \State $\rho_{\text{new}} = r^\top z$ % scalar + \State $\beta = \frac{\rho_{\text{new}}}{\rho}$ % scalar + \State $p = z + \beta \, p$ % vector + \State $\rho = \rho_{\text{new}}$ % scalar +\EndFor +\Return $x, \text{maxiter}, \lVert r \rVert_2, 0$ +\end{algorithmic} diff --git a/dev/algorithms/radial_return_j2.tex b/dev/algorithms/radial_return_j2.tex new file mode 100644 index 0000000..14d8364 --- /dev/null +++ b/dev/algorithms/radial_return_j2.tex @@ -0,0 +1,80 @@ +% post_recovery_plan Phase 5 (P5-1): J2 radial-return algpseudocode source. +% +% Scalar Newton inner loop for J2 plasticity with power-law isotropic +% hardening. Solves for the plastic multiplier increment dl given the +% trial von Mises equivalent stress, the prior accumulated equivalent +% plastic strain, and the material parameters: +% +% sigma_y(alpha) = sigy0 + K * alpha^n +% +% The scalar loop operates on the trial-stress / hardening axes only; +% all tensor algebra (deviatoric stress, von Mises, return-mapping +% reconstruction, algorithmic tangent) is orchestrated in Python by the +% mechdsl-core wrapper at packages/mechdsl-core/src/mechdsl/lib/plasticity.py. +% +% This is the production algo2code source consumed by +% ``mechdsl.lib.plasticity.radial_return`` via ``algo2code.transpile`` +% (post_recovery_plan Phase 5 P5-3 — algo2code expr_parser bugs that +% previously deferred direct emission landed in the same phase under +% P5-1's parser fix). Bit-equality with the imported reference path is +% asserted by +% ``packages/mechdsl-core/tests/test_j2_radial_return_parity.py`` (P5-4). +% +% Inputs +% ------ +% sigma_eq : von Mises equivalent of the trial deviatoric stress +% alpha : alpha_old, prior accumulated equivalent plastic strain +% mu : shear modulus (second Lame parameter) +% K : isotropic hardening modulus +% n : power-law exponent +% sigy0 : initial yield stress +% tol : Newton residual tolerance (passed through; the fixed- +% iteration loop converges quadratically and the wrapper +% applies the imported reference tolerance externally) +% max_iter : Newton max iterations +% +% Outputs +% ------- +% plastic : 1 if plastic flow occurred, 0 if elastic step +% alpha_new : alpha_old + delta_lambda (when plastic) +% dl : plastic multiplier increment (delta_lambda) + +% algorithm radial_return_j2 +% backend taichi +% args sigma_eq:scalar, alpha:scalar, mu:scalar, K:scalar, n:scalar, sigy0:scalar, tol:scalar, max_iter:scalar + +% type sy scalar +% type f scalar +% type dl scalar +% type k scalar +% type Hp scalar +% type res scalar +% type denom scalar +% type ap scalar +% type plastic scalar +% type alpha_new scalar + +\begin{algorithmic} +\State $sy = sigy0 + K \cdot \alpha^{n}$ +\State $f = sigma_eq - sy$ +\If{$f < 0$} + \Return $0, \alpha, 0$ +\EndIf +\State $dl = 0$ +\For{$k = 1, 2, \ldots, max_iter$} + \State $ap = \alpha + dl$ + \If{$ap < tol$} + \State $sy = sigy0$ + \State $Hp = 0$ + \Else + \State $sy = sigy0 + K \cdot ap^{n}$ + \State $Hp = K \cdot n \cdot ap^{n - 1}$ + \EndIf + \State $res = sigma_eq - 3 \cdot \mu \cdot dl - sy$ + \State $denom = 3 \cdot \mu + Hp$ + \State $dl = dl + res / denom$ +\EndFor +\State $alpha_new = \alpha + dl$ +\State $plastic = 1$ +\Return $plastic, alpha_new, dl$ +\end{algorithmic} diff --git a/dev/algorithms/radial_return_j2_kinematic.tex b/dev/algorithms/radial_return_j2_kinematic.tex new file mode 100644 index 0000000..65ec698 --- /dev/null +++ b/dev/algorithms/radial_return_j2_kinematic.tex @@ -0,0 +1,83 @@ +% constitutive_latex Phase 6 (P6-2): J2 kinematic linear-hardening +% radial-return algpseudocode source. +% +% Scalar return-map inner solve for J2 plasticity with LINEAR KINEMATIC +% hardening (Prager). The yield surface translates (back-stress beta) +% instead of expanding: yield is measured on the RELATIVE (shifted) +% stress xi = dev(S) - beta, with a CONSTANT yield radius sigma_y0 +% (no isotropic hardening — all hardening lives in beta). +% +% Yield function (von Mises of the relative stress): +% +% f = ||xi||_eq - sigma_y0, ||xi||_eq = sqrt(3/2 * xi:xi) +% +% For LINEAR kinematic hardening the discrete consistency condition is +% LINEAR in the plastic-multiplier increment, so dl has a CLOSED FORM: +% +% ||xi_trial||_eq - sigma_y0 - (3*mu + H_kin)*dl = 0 +% => dl = (||xi_trial||_eq - sigma_y0) / (3*mu + H_kin) +% +% This scalar source is authored as the SAME fixed-iteration loop +% structure as the isotropic power-law variant +% (``dev/algorithms/radial_return_j2.tex``) for transpile-pattern +% consistency: the residual ``res = xi_eq - (3*mu + H_kin)*dl - sigy0`` +% is linear in dl, so the Newton update (denominator ``3*mu + H_kin``) +% converges in a single step and every subsequent iteration leaves dl +% unchanged at the exact root (res = 0 => the closed form above). +% Authoring it as a loop +% keeps P6-1/P6-2/P6-3 on one structural pattern that algo2code already +% transpiles end-to-end. +% +% Back-stress update (Prager, discrete) is performed in TENSOR space by +% the mechdsl-core wrapper: +% +% beta_new = beta_old + (2/3)*H_kin*dl*N_flow, N_flow = xi/||xi|| +% +% with ||N_flow||_eq = 1, so |dbeta|_eq = (2/3)*H_kin*dl and the +% relative stress contracts at rate (3*mu + H_kin)*dl per the closed +% form above. All tensor algebra (deviatoric split, relative stress, +% von Mises of xi, stress reconstruction, plastic-strain and back-stress +% update, algorithmic tangent) is orchestrated in Python by +% ``mechdsl.lib.plasticity_kinematic``. This scalar source owns ONLY the +% plastic-multiplier solve, exactly as the isotropic variant does. +% +% Inputs +% ------ +% xi_eq : von Mises equivalent of the trial RELATIVE stress xi_trial +% mu : shear modulus (second Lame parameter) +% H_kin : linear kinematic-hardening (Prager) modulus +% sigy0 : (constant) yield stress +% tol : residual tolerance (passed through; the linear solve is +% exact so the loop converges in one iteration) +% max_iter : iteration cap (>= 1 suffices; kept for pattern symmetry) +% +% Outputs +% ------- +% plastic : 1 if plastic flow occurred, 0 if elastic step +% dl : plastic multiplier increment (delta_lambda) + +% algorithm radial_return_j2_kinematic +% backend taichi +% args xi_eq:scalar, mu:scalar, H_kin:scalar, sigy0:scalar, tol:scalar, max_iter:scalar + +% type f scalar +% type dl scalar +% type k scalar +% type res scalar +% type denom scalar +% type plastic scalar + +\begin{algorithmic} +\State $f = xi_eq - sigy0$ +\If{$f < 0$} + \Return $0, 0$ +\EndIf +\State $dl = 0$ +\State $denom = 3 \cdot \mu + H_kin$ +\For{$k = 1, 2, \ldots, max_iter$} + \State $res = xi_eq - 3 \cdot \mu \cdot dl - H_kin \cdot dl - sigy0$ + \State $dl = dl + res / denom$ +\EndFor +\State $plastic = 1$ +\Return $plastic, dl$ +\end{algorithmic} diff --git a/dev/algorithms/radial_return_j2_mixed.tex b/dev/algorithms/radial_return_j2_mixed.tex new file mode 100644 index 0000000..7e98d0f --- /dev/null +++ b/dev/algorithms/radial_return_j2_mixed.tex @@ -0,0 +1,112 @@ +% constitutive_latex Phase 6 (P6-3): J2 MIXED-hardening radial-return +% algpseudocode source. +% +% Scalar return-map inner solve for J2 plasticity with MIXED hardening: +% the yield surface BOTH translates (linear kinematic back-stress beta, +% Prager — from P6-2) AND expands (power-law isotropic radius growth +% sigma_y(alpha) = sigy0 + K*alpha^n — from P6-1), simultaneously. Yield +% is measured on the RELATIVE (shifted) stress xi = dev(S) - beta against +% the EXPANDING radius sigma_y(alpha): +% +% f = ||xi||_eq - sigma_y(alpha), ||xi||_eq = sqrt(3/2 * xi:xi) +% sigma_y(alpha) = sigy0 + K * alpha^n +% +% Because the isotropic part is a NONLINEAR power law in +% alpha = alpha_old + dl, the discrete consistency condition is NONLINEAR +% in the plastic-multiplier increment dl, so — unlike the pure-linear +% kinematic variant (P6-2, closed form) — this needs the scalar NEWTON +% loop of the isotropic variant (P6-1). The kinematic part contributes a +% LINEAR term (3*mu + H_kin)*dl exactly as in P6-2; the isotropic part +% contributes the nonlinear sigma_y(alpha_old + dl) exactly as in P6-1. +% +% Consistency residual (root in dl): +% +% r(dl) = ||xi_trial||_eq - (3*mu + H_kin)*dl - sigma_y(alpha_old + dl) +% +% Newton derivative: +% +% r'(dl) = -(3*mu + H_kin) - K*n*(alpha_old + dl)^(n-1) +% +% so the Newton update is dl <- dl + r / (3*mu + H_kin + Hp) with +% Hp = K*n*ap^(n-1) the isotropic slope d(sigma_y)/d(alpha). This reduces +% to P6-1 when H_kin = 0 (xi_eq == sigma_eq, beta == 0) and to P6-2 when +% K = 0 (sigma_y(alpha) == sigy0, constant radius, linear residual). +% +% The alpha->0 guard mirrors j2_power_law.py / radial_return_j2.tex: for +% hardening exponents n < 1 the slope K*n*ap^(n-1) diverges as ap -> 0+, +% so below the tolerance ap < tol the radius is held at sigy0 and the +% slope Hp at 0. +% +% This scalar source owns ONLY the plastic-multiplier solve. All tensor +% algebra (deviatoric split, relative stress xi, von Mises of xi, radial +% stress reconstruction, plastic-strain / Prager back-stress / accumulated +% plastic-strain alpha updates, algorithmic tangent) is orchestrated in +% Python by ``mechdsl.lib.plasticity_mixed`` — the same division of labour +% as the isotropic (``mechdsl.lib.plasticity``) and kinematic +% (``mechdsl.lib.plasticity_kinematic``) variants. The flow normal and +% back-stress factors are identical to P6-2: +% +% nf = (3/2) * xi / ||xi||_eq (von Mises gradient) +% dEp = dl * nf (plastic strain increment) +% beta += (2/3) * H_kin * dl * nf (Prager back-stress, linear) +% alpha += dl (accumulated equivalent plastic strain) +% +% Inputs +% ------ +% xi_eq : von Mises equivalent of the trial RELATIVE stress xi_trial +% alpha : alpha_old, prior accumulated equivalent plastic strain +% mu : shear modulus (second Lame parameter) +% K : isotropic hardening modulus (power-law coefficient) +% n : isotropic hardening exponent +% H_kin : linear kinematic-hardening (Prager) modulus +% sigy0 : initial yield stress +% tol : alpha->0 guard threshold / residual tolerance (passed +% through; the Newton loop converges quadratically and the +% wrapper applies the reference tolerance externally) +% max_iter : Newton max iterations +% +% Outputs +% ------- +% plastic : 1 if plastic flow occurred, 0 if elastic step +% alpha_new : alpha_old + delta_lambda (when plastic) +% dl : plastic multiplier increment (delta_lambda) + +% algorithm radial_return_j2_mixed +% backend taichi +% args xi_eq:scalar, alpha:scalar, mu:scalar, K:scalar, n:scalar, H_kin:scalar, sigy0:scalar, tol:scalar, max_iter:scalar + +% type sy scalar +% type f scalar +% type dl scalar +% type k scalar +% type Hp scalar +% type res scalar +% type denom scalar +% type ap scalar +% type plastic scalar +% type alpha_new scalar + +\begin{algorithmic} +\State $sy = sigy0 + K \cdot \alpha^{n}$ +\State $f = xi_eq - sy$ +\If{$f < 0$} + \Return $0, \alpha, 0$ +\EndIf +\State $dl = 0$ +\For{$k = 1, 2, \ldots, max_iter$} + \State $ap = \alpha + dl$ + \If{$ap < tol$} + \State $sy = sigy0$ + \State $Hp = 0$ + \Else + \State $sy = sigy0 + K \cdot ap^{n}$ + \State $Hp = K \cdot n \cdot ap^{n - 1}$ + \EndIf + \State $res = xi_eq - 3 \cdot \mu \cdot dl - H_kin \cdot dl - sy$ + \State $denom = 3 \cdot \mu + H_kin + Hp$ + \State $dl = dl + res / denom$ +\EndFor +\State $alpha_new = \alpha + dl$ +\State $plastic = 1$ +\Return $plastic, alpha_new, dl$ +\end{algorithmic} diff --git a/docs/algo2code/getting-started.md b/docs/algo2code/getting-started.md index b0a3964..617c79e 100644 --- a/docs/algo2code/getting-started.md +++ b/docs/algo2code/getting-started.md @@ -8,7 +8,7 @@ This page takes you from a fresh clone to a transpiled algorithm. simplest way to get it is the workspace install: ```bash -git clone https://github.com/SOSOVSKI/MechDSL.git +git clone https://github.com/CEmM2/MechDSL.git cd MechDSL uv sync --all-packages --all-groups --all-extras ``` diff --git a/docs/mechdsl-core/constitutive-models.md b/docs/mechdsl-core/constitutive-models.md index e1108c8..92d17a7 100644 --- a/docs/mechdsl-core/constitutive-models.md +++ b/docs/mechdsl-core/constitutive-models.md @@ -109,7 +109,7 @@ Any energy you can write in LaTeX can be compiled without touching the model cod ``` The example energy snippets in -[`dev/examples/`](https://github.com/SOSOVSKI/MechDSL/tree/main/dev/examples) +[`examples/`](https://github.com/CEmM2/MechDSL/tree/main/examples) (`neo_hookean_energy.tex`, `mooney_rivlin_energy.tex`, `ogden_energy.tex`, `hgo_energy.tex`, `svk_energy.tex`) show the exact LaTeX the parser accepts. diff --git a/docs/mechdsl-core/examples.md b/docs/mechdsl-core/examples.md index 052f31c..83ee898 100644 --- a/docs/mechdsl-core/examples.md +++ b/docs/mechdsl-core/examples.md @@ -1,7 +1,7 @@ # Examples gallery Every example here is a runnable script in -[`dev/examples/`](https://github.com/SOSOVSKI/MechDSL/tree/main/dev/examples). They share +[`examples/`](https://github.com/CEmM2/MechDSL/tree/main/examples). They share a `gen_meshes.py` helper that builds the small structured meshes they use. !!! tip "Run them with `uv`" @@ -14,7 +14,7 @@ The MVP-stable, documentation-preferred path. Parses `% mechanics` directives an all six layers. ```bash -uv run python dev/examples/run_compile_latex.py +uv run python examples/run_compile_latex.py ``` See [Getting started](getting-started.md) for a line-by-line walk-through of what this @@ -35,17 +35,17 @@ exercise classic FEM verification benchmarks. | `run_pipeline.py` | SVK + J2 end-to-end; writes the emitted Taichi source to disk | ```bash -uv run python dev/examples/elastic_cantilever.py -uv run python dev/examples/cook_membrane.py -uv run python dev/examples/necking_bar.py -uv run python dev/examples/plastic_uniaxial.py -uv run python dev/examples/patch_test.py -uv run python dev/examples/run_pipeline.py +uv run python examples/elastic_cantilever.py +uv run python examples/cook_membrane.py +uv run python examples/necking_bar.py +uv run python examples/plastic_uniaxial.py +uv run python examples/patch_test.py +uv run python examples/run_pipeline.py ``` ## LaTeX energy snippets -The `.tex` files under `dev/examples/` are the exact strain-energy inputs the parser +The `.tex` files under `examples/` are the exact strain-energy inputs the parser accepts for the derive-from-energy path: | File | Model | @@ -59,7 +59,7 @@ accepts for the derive-from-energy path: ```python from mechdsl import compile_latex -bundle = compile_latex(problem_source, energy_file="dev/examples/mooney_rivlin_energy.tex") +bundle = compile_latex(problem_source, energy_file="examples/mooney_rivlin_energy.tex") ``` ## Cyclic plasticity & the Bauschinger effect @@ -69,7 +69,7 @@ The J2 kinematic and mixed hardening models (`mechdsl.lib.plasticity_kinematic` (loading → unloading → reverse) in the test suite. The kinematic/mixed models re-yield in reverse *below* the forward yield magnitude — the Bauschinger effect — which the isotropic model cannot reproduce. See -[`packages/mechdsl-core/tests/plan_tests/constitutive_latex/`](https://github.com/SOSOVSKI/MechDSL/tree/main/packages/mechdsl-core/tests/plan_tests/constitutive_latex) +[`packages/mechdsl-core/tests/plan_tests/constitutive_latex/`](https://github.com/CEmM2/MechDSL/tree/main/packages/mechdsl-core/tests/plan_tests/constitutive_latex) for the cyclic differential tests and the reduction cross-checks, and the [constitutive model catalog](constitutive-models.md#j2-plasticity-kinematic-prager-hardening) for the API. diff --git a/docs/mechdsl-core/getting-started.md b/docs/mechdsl-core/getting-started.md index 6252a63..7c9a089 100644 --- a/docs/mechdsl-core/getting-started.md +++ b/docs/mechdsl-core/getting-started.md @@ -16,7 +16,7 @@ This page takes you from a fresh clone to a compiled solver bundle. ## Install ```bash -git clone https://github.com/SOSOVSKI/MechDSL.git +git clone https://github.com/CEmM2/MechDSL.git cd MechDSL uv sync --all-packages --all-groups --all-extras ``` @@ -58,10 +58,10 @@ uv run python first_run.py ``` A runnable copy of this lives at -[`dev/examples/run_compile_latex.py`](https://github.com/SOSOVSKI/MechDSL/blob/main/dev/examples/run_compile_latex.py): +[`examples/run_compile_latex.py`](https://github.com/CEmM2/MechDSL/blob/main/examples/run_compile_latex.py): ```bash -uv run python dev/examples/run_compile_latex.py +uv run python examples/run_compile_latex.py ``` ### What just happened @@ -82,7 +82,7 @@ golden files. ## Compiling from a `.tex` file Because the directives are plain LaTeX comments, you can keep them in a real document. -See [`dev/examples/elastic_cantilever.tex`](https://github.com/SOSOVSKI/MechDSL/blob/main/dev/examples/elastic_cantilever.tex): +See [`examples/elastic_cantilever.tex`](https://github.com/CEmM2/MechDSL/blob/main/examples/elastic_cantilever.tex): ```latex % mechanics dim 3 @@ -101,7 +101,7 @@ Read it and pass the contents to `compile_latex`: from pathlib import Path from mechdsl import compile_latex -source = Path("dev/examples/elastic_cantilever.tex").read_text() +source = Path("examples/elastic_cantilever.tex").read_text() bundle = compile_latex(source) ``` @@ -112,12 +112,12 @@ are invisible to LaTeX. For energy-based hyperelastic models you can hand `compile_latex` the strain-energy function and let it auto-differentiate. The energy lives in a `.tex` snippet (see -[`dev/examples/neo_hookean_energy.tex`](https://github.com/SOSOVSKI/MechDSL/blob/main/dev/examples/neo_hookean_energy.tex)): +[`examples/neo_hookean_energy.tex`](https://github.com/CEmM2/MechDSL/blob/main/examples/neo_hookean_energy.tex)): ```python from mechdsl import compile_latex -bundle = compile_latex(problem_source, energy_file="dev/examples/neo_hookean_energy.tex") +bundle = compile_latex(problem_source, energy_file="examples/neo_hookean_energy.tex") ``` The compiler parses Ψ, differentiates to get **S = ∂Ψ/∂E** and **C = ∂²Ψ/∂E²**, and diff --git a/docs/mechdsl-core/latex-directives.md b/docs/mechdsl-core/latex-directives.md index 1a7291b..b2c24bf 100644 --- a/docs/mechdsl-core/latex-directives.md +++ b/docs/mechdsl-core/latex-directives.md @@ -3,7 +3,7 @@ Every MechDSL directive is a LaTeX comment that begins with `% mechanics` and sits on its own line. This page documents the directives exercised by the canonical `compile_latex` path. Examples here are taken from the runnable inputs in -[`dev/examples/`](https://github.com/SOSOVSKI/MechDSL/tree/main/dev/examples). +[`examples/`](https://github.com/CEmM2/MechDSL/tree/main/examples). !!! note "Authoritative grammar" The full DSL grammar (including planned directives) lives in diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 911f744..25fdd05 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -1,9 +1,8 @@ # How it works This page is for the curious user who wants to understand the machinery before relying -on it. The authoritative, read-only design docs live under -[`dev/design_docs/`](https://github.com/SOSOVSKI/MechDSL/tree/main/dev/design_docs); this -is a guided tour. +on it. The authoritative, read-only design docs live in the internal `dev/design_docs/` +tree of the private development repository; this is a guided tour. ## The six layers diff --git a/docs/reference/faq.md b/docs/reference/faq.md index 0980103..0584bdf 100644 --- a/docs/reference/faq.md +++ b/docs/reference/faq.md @@ -77,7 +77,7 @@ feature is on the roadmap, not broken. - They're processed **in order** — a directive that references a symbol must come after the one that defines it. - Use the syntax from the [directive reference](../mechdsl-core/latex-directives.md), which mirrors the - runnable inputs in `dev/examples/`. The design-doc grammar in `02-LATEX-DSL.md` includes + runnable inputs in `examples/`. The design-doc grammar in `02-LATEX-DSL.md` includes planned directives that the current `compile_latex` path may not yet consume. ### Generated code changed unexpectedly / a golden test failed @@ -110,6 +110,6 @@ uv run mkdocs build # static site into ./site ## Still stuck? -- The authoritative specs are under - [`dev/design_docs/`](https://github.com/SOSOVSKI/MechDSL/tree/main/dev/design_docs). -- Open an issue at . +- The authoritative specs live in the internal `dev/design_docs/` tree of the + private development repository. +- Open an issue at . diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..176b006 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,104 @@ +# MechDSL examples + +Runnable, self-contained examples that exercise the canonical +`mechdsl-core` compile path. Each script is invoked under `uv run` from +the repository root. + +## Inventory + +- [Headline product story (equation-bearing LaTeX-first)](#headline-product-story-equation-bearing-latex-first) — `run_compile_latex_equation.py`, `run_compile_latex.py`. +- [Programmatic API examples (advanced / testing aids)](#programmatic-api-examples-advanced--testing-aids) — `elastic_cantilever.py`, `cook_membrane.py`, `necking_bar.py`, `patch_test.py`, `plastic_uniaxial.py`, `gen_meshes.py`, `run_pipeline.py`, `run_elastic_reference.py`. +- [LaTeX-math grammar (post_recovery_plan Phase 4, P4-5)](#latex-math-grammar-post_recovery_plan-phase-4-p4-5) — `svk_latex_math.tex`. +- [`algo2code`-generated PCG seam (opt-in)](#algo2code-generated-pcg-seam-opt-in) — runtime knob, no separate script. + +## Headline product story (equation-bearing LaTeX-first) + +The MVP-stable contract is LaTeX-driven, and the fgram full-grammar story +is *equation-bearing* LaTeX: a source that declares physics fields, the +constitutive role of each tensor (`Psi` = strain energy, `S` = PK2 stress), +and the weak-form residual — not only a built-in material name. New users +should start here: + +| Script | What it shows | +|--------|---------------| +| `run_compile_latex_equation.py` | Equation-bearing LaTeX (field/constitutive/weak_form) -> `mechdsl.compile_latex` -> Taichi, with the LaTeX-derived `latex_semantics` record on the bundle | +| `run_compile_latex.py` | Minimal directive-only LaTeX source -> `mechdsl.compile_latex` -> Taichi-ready bundle | + +```bash +uv run python examples/run_compile_latex_equation.py +uv run python examples/run_compile_latex.py +``` + +Both go through the single public facade `mechdsl.compile_latex`, so they +cannot drift from the supported API. The equation-bearing script is the +fgram closure headline (P7-1); the directive-only script remains the +minimal entry point referenced from the repository +[`README.md`](../../README.md) Quickstart and from +[`dev/plans/recovery_plan_latex_contract.md`](../plans/recovery_plan_latex_contract.md) +P7-3. See [`dev/reviews/fgram_closure_2026_05.md`](../reviews/fgram_closure_2026_05.md) +for the grammar-coverage map. + +## Programmatic API examples (advanced / testing aids) + +The scripts below construct a `ProblemIR` directly via `build_context()`. +They remain supported (P2-2 mandate) but are demoted to advanced / +testing-aid status: the LaTeX-first script above is the documented +stable story. + +| Script | What it shows | +|--------|---------------| +| `elastic_cantilever.py` | SVK Hex8 cantilever via `build_context()` + `compile()` | +| `plastic_uniaxial.py` | J2 power-law uniaxial bar | +| `cook_membrane.py` | Cook's membrane benchmark (mid-tip displacement) | +| `necking_bar.py` | Necking-bar plasticity benchmark | +| `patch_test.py` | Constant-strain patch test | +| `run_pipeline.py` | SVK + J2 end-to-end, writes emitted Taichi source to disk | +| `gen_meshes.py` | Helper that generates the meshes used above | +| `run_elastic_reference.py` | Reference-kernel comparison harness | + +LaTeX source inputs live alongside the scripts (`elastic_cantilever.tex`, +`plastic_necking.tex`) and are consumed by the canonical +`run_compile_latex.py` flow. + +## LaTeX-math grammar (post_recovery_plan Phase 4, P4-5) + +`svk_latex_math.tex` exercises the `nrpylatex` math-grammar integration +landed by post_recovery_plan Phase 4 (P4-1 / P4-2 / P4-3). The file mixes +canonical `% mechanics` directives with `% declare` directives for the +`nrpylatex` parser and a `$...$` indexed-tensor block; the +`mechdsl.frontend.parse_with_math` entry point routes the math block +through `mechdsl.frontend.math_parser` → `mechdsl.symbolic.bridge` and +attaches the resulting `SymbolicNode` map under `context["math"]`. + +| Source | What it shows | +|--------|---------------| +| `svk_latex_math.tex` | LaTeX-math integration via `parse_with_math`; two-point `F^{iI}` index distinction | + +```bash +uv run python -c "from pathlib import Path; from mechdsl.frontend import parse_with_math; ctx = parse_with_math(Path('examples/svk_latex_math.tex').read_text()); print(list(ctx['math']['tensors'].keys()))" +``` + +The example deliberately uses a rank-2 copy as the SVK PK1 surrogate; +the closed-form expression depends on `\det F` / `\log J` intrinsics +that `nrpylatex` 1.4.0 does not register, deferred to a later phase. + +## `algo2code`-generated PCG seam (opt-in) + +The Newton–Raphson driver consumes any `LinearSolverInterface` adapter. +The default is `ScipyCGSolver`; the `algo2code`-derived `Algo2CodePCGSolver` +(verbatim translation of `algo2code.library.pcg.PCG_ALGORITHM_LATEX`, +landed by recovery-plan Phase 6 / P6-1..P6-3) is opt-in: + +```python +from mechdsl.solver import select_linear_solver +from mechdsl.solver.newton import newton_solve + +solver = select_linear_solver("generated") # algo2code-derived PCG path +# newton_solve(..., linear_solver=solver) # opt-in; default stays ScipyCGSolver +``` + +The examples above keep the default fallback so they remain stable +under CI; swapping in the generated path is a one-line change at the +call site. See +[`dev/design_docs/11-ALGO2CODE.md`](../design_docs/11-ALGO2CODE.md) §1.1 +for the seam description and §2.5 for the canonical PCG algpseudocode. diff --git a/examples/_output/cantilever_svk.py b/examples/_output/cantilever_svk.py new file mode 100644 index 0000000..55c2535 --- /dev/null +++ b/examples/_output/cantilever_svk.py @@ -0,0 +1,532 @@ +"""Auto-generated Taichi FEM solver. DO NOT EDIT. + +Formulation : total_lagrangian +Material : svk +Element : hex8 +Dimension : 3 +""" + +import taichi as ti +import numpy as np + +ti.init(default_fp=ti.f64, arch=ti.cpu) + +# ====================================================================== +# Element constants: Hex8, 2x2x2 Gauss quadrature +# ====================================================================== + +N_NODES = 8 +N_QP = 8 +DIM = 3 +N_DOF_ELEM = N_NODES * DIM + +QUAD_WEIGHTS = [1, 1, 1, 1, 1, 1, 1, 1] + +SHAPE_AT_QUAD = [ + [0.49056261216234409, 0.13144585576580212, 0.035220810900864506, 0.13144585576580212, 0.13144585576580212, 0.035220810900864506, 0.0094373878376559257, 0.035220810900864506], + [0.13144585576580212, 0.035220810900864506, 0.0094373878376559257, 0.035220810900864506, 0.49056261216234409, 0.13144585576580212, 0.035220810900864506, 0.13144585576580212], + [0.13144585576580212, 0.035220810900864506, 0.13144585576580212, 0.49056261216234409, 0.035220810900864506, 0.0094373878376559257, 0.035220810900864506, 0.13144585576580212], + [0.035220810900864506, 0.0094373878376559257, 0.035220810900864506, 0.13144585576580212, 0.13144585576580212, 0.035220810900864506, 0.13144585576580212, 0.49056261216234409], + [0.13144585576580212, 0.49056261216234409, 0.13144585576580212, 0.035220810900864506, 0.035220810900864506, 0.13144585576580212, 0.035220810900864506, 0.0094373878376559257], + [0.035220810900864506, 0.13144585576580212, 0.035220810900864506, 0.0094373878376559257, 0.13144585576580212, 0.49056261216234409, 0.13144585576580212, 0.035220810900864506], + [0.035220810900864506, 0.13144585576580212, 0.49056261216234409, 0.13144585576580212, 0.0094373878376559257, 0.035220810900864506, 0.13144585576580212, 0.035220810900864506], + [0.0094373878376559257, 0.035220810900864506, 0.13144585576580212, 0.035220810900864506, 0.035220810900864506, 0.13144585576580212, 0.49056261216234409, 0.13144585576580212], +] + +GRAD_AT_QUAD = [ + [ + [-0.31100423396407312, -0.31100423396407312, -0.31100423396407312], + [0.31100423396407312, -0.083333333333333315, -0.083333333333333315], + [0.083333333333333315, 0.083333333333333315, -0.022329099369260218], + [-0.083333333333333315, 0.31100423396407312, -0.083333333333333315], + [-0.083333333333333315, -0.083333333333333315, 0.31100423396407312], + [0.083333333333333315, -0.022329099369260218, 0.083333333333333315], + [0.022329099369260218, 0.022329099369260218, 0.022329099369260218], + [-0.022329099369260218, 0.083333333333333315, 0.083333333333333315], + ], + [ + [-0.083333333333333315, -0.083333333333333315, -0.31100423396407312], + [0.083333333333333315, -0.022329099369260218, -0.083333333333333315], + [0.022329099369260218, 0.022329099369260218, -0.022329099369260218], + [-0.022329099369260218, 0.083333333333333315, -0.083333333333333315], + [-0.31100423396407312, -0.31100423396407312, 0.31100423396407312], + [0.31100423396407312, -0.083333333333333315, 0.083333333333333315], + [0.083333333333333315, 0.083333333333333315, 0.022329099369260218], + [-0.083333333333333315, 0.31100423396407312, 0.083333333333333315], + ], + [ + [-0.083333333333333315, -0.31100423396407312, -0.083333333333333315], + [0.083333333333333315, -0.083333333333333315, -0.022329099369260218], + [0.31100423396407312, 0.083333333333333315, -0.083333333333333315], + [-0.31100423396407312, 0.31100423396407312, -0.31100423396407312], + [-0.022329099369260218, -0.083333333333333315, 0.083333333333333315], + [0.022329099369260218, -0.022329099369260218, 0.022329099369260218], + [0.083333333333333315, 0.022329099369260218, 0.083333333333333315], + [-0.083333333333333315, 0.083333333333333315, 0.31100423396407312], + ], + [ + [-0.022329099369260218, -0.083333333333333315, -0.083333333333333315], + [0.022329099369260218, -0.022329099369260218, -0.022329099369260218], + [0.083333333333333315, 0.022329099369260218, -0.083333333333333315], + [-0.083333333333333315, 0.083333333333333315, -0.31100423396407312], + [-0.083333333333333315, -0.31100423396407312, 0.083333333333333315], + [0.083333333333333315, -0.083333333333333315, 0.022329099369260218], + [0.31100423396407312, 0.083333333333333315, 0.083333333333333315], + [-0.31100423396407312, 0.31100423396407312, 0.31100423396407312], + ], + [ + [-0.31100423396407312, -0.083333333333333315, -0.083333333333333315], + [0.31100423396407312, -0.31100423396407312, -0.31100423396407312], + [0.083333333333333315, 0.31100423396407312, -0.083333333333333315], + [-0.083333333333333315, 0.083333333333333315, -0.022329099369260218], + [-0.083333333333333315, -0.022329099369260218, 0.083333333333333315], + [0.083333333333333315, -0.083333333333333315, 0.31100423396407312], + [0.022329099369260218, 0.083333333333333315, 0.083333333333333315], + [-0.022329099369260218, 0.022329099369260218, 0.022329099369260218], + ], + [ + [-0.083333333333333315, -0.022329099369260218, -0.083333333333333315], + [0.083333333333333315, -0.083333333333333315, -0.31100423396407312], + [0.022329099369260218, 0.083333333333333315, -0.083333333333333315], + [-0.022329099369260218, 0.022329099369260218, -0.022329099369260218], + [-0.31100423396407312, -0.083333333333333315, 0.083333333333333315], + [0.31100423396407312, -0.31100423396407312, 0.31100423396407312], + [0.083333333333333315, 0.31100423396407312, 0.083333333333333315], + [-0.083333333333333315, 0.083333333333333315, 0.022329099369260218], + ], + [ + [-0.083333333333333315, -0.083333333333333315, -0.022329099369260218], + [0.083333333333333315, -0.31100423396407312, -0.083333333333333315], + [0.31100423396407312, 0.31100423396407312, -0.31100423396407312], + [-0.31100423396407312, 0.083333333333333315, -0.083333333333333315], + [-0.022329099369260218, -0.022329099369260218, 0.022329099369260218], + [0.022329099369260218, -0.083333333333333315, 0.083333333333333315], + [0.083333333333333315, 0.083333333333333315, 0.31100423396407312], + [-0.083333333333333315, 0.022329099369260218, 0.083333333333333315], + ], + [ + [-0.022329099369260218, -0.022329099369260218, -0.022329099369260218], + [0.022329099369260218, -0.083333333333333315, -0.083333333333333315], + [0.083333333333333315, 0.083333333333333315, -0.31100423396407312], + [-0.083333333333333315, 0.022329099369260218, -0.083333333333333315], + [-0.083333333333333315, -0.083333333333333315, 0.022329099369260218], + [0.083333333333333315, -0.31100423396407312, 0.083333333333333315], + [0.31100423396407312, 0.31100423396407312, 0.31100423396407312], + [-0.31100423396407312, 0.083333333333333315, 0.083333333333333315], + ], +] + + +# ====================================================================== +# Field declarations (dimensions set by mesh loader at runtime) +# ====================================================================== + +n_nodes = 0 +n_elem = 0 + +# Taichi fields -- allocated after mesh is loaded +x_ref = ti.Vector.field(3, dtype=ti.f64) # reference coords +x_cur = ti.Vector.field(3, dtype=ti.f64) # current coords +u = ti.Vector.field(3, dtype=ti.f64) # displacement +f_int = ti.Vector.field(3, dtype=ti.f64) # internal force +f_ext = ti.Vector.field(3, dtype=ti.f64) # external force +residual = ti.Vector.field(3, dtype=ti.f64) # residual = f_int - f_ext +du = ti.Vector.field(3, dtype=ti.f64) # displacement increment +Kv = ti.Vector.field(3, dtype=ti.f64) # tangent matvec result +elem_nodes = ti.field(dtype=ti.i32) # connectivity + + +def allocate_fields(nn: int, ne: int) -> None: + """Allocate Taichi fields after mesh dimensions are known.""" + global n_nodes, n_elem + n_nodes = nn + n_elem = ne + ti.root.dense(ti.i, n_nodes).place(x_ref, x_cur, u, f_int, f_ext, residual, du, Kv) + ti.root.dense(ti.ij, (n_elem, N_NODES)).place(elem_nodes) + + +# ====================================================================== +# Constitutive model: svk +# ====================================================================== + +@ti.func +def constitutive_update(F: ti.types.matrix(3, 3, ti.f64), + lam: ti.f64, mu: ti.f64 + ) -> ti.types.matrix(3, 3, ti.f64): + """SVK constitutive model: S = lam*tr(E)*I + 2*mu*E.""" + # Right Cauchy-Green tensor C = F^T @ F + C = F.transpose() @ F + # Green-Lagrange strain E = 0.5*(C - I) + I3 = ti.Matrix.identity(ti.f64, 3) + E = 0.5 * (C - I3) + # Trace of E (physics index sum -- ti.static range) + tr_E = ti.f64(0.0) + for i in ti.static(range(3)): + tr_E += E[i, i] + # 2nd Piola-Kirchhoff stress: S = lam*tr(E)*I + 2*mu*E + S = lam * tr_E * I3 + 2.0 * mu * E + return S + +# ====================================================================== +# Internal force kernel +# ====================================================================== + +@ti.kernel +def compute_internal_force(lam: ti.f64, mu: ti.f64): + """Compute internal force vector over all elements.""" + # Zero internal force + for i in range(n_nodes): + f_int[i] = ti.Vector([0.0, 0.0, 0.0], dt=ti.f64) + + # Loop over elements (runtime -- mesh index) + for e in range(n_elem): + # Gather element nodal coordinates (reference and current) + X_elem = ti.Matrix.zero(ti.f64, N_NODES, DIM) + x_elem = ti.Matrix.zero(ti.f64, N_NODES, DIM) + for a in range(N_NODES): + nid = elem_nodes[e, a] + for d in ti.static(range(DIM)): + X_elem[a, d] = x_ref[nid][d] + x_elem[a, d] = x_ref[nid][d] + u[nid][d] + + # Quadrature loop (ti.static -- N_QP=8 is element-type constant, + # enables Python list access for GRAD_AT_QUAD and QUAD_WEIGHTS) + for q in ti.static(range(N_QP)): + # Shape function gradients in parametric space + dN_dxi = ti.Matrix.zero(ti.f64, N_NODES, DIM) + for a in ti.static(range(N_NODES)): + for d in ti.static(range(DIM)): + dN_dxi[a, d] = GRAD_AT_QUAD[q][a][d] + + # Reference Jacobian J0 = X^T @ dN/dxi (3x3) + J0 = X_elem.transpose() @ dN_dxi + detJ0 = J0.determinant() + # Guard: degenerate element (07-CONVENTIONS.md §6) -- skip QP if detJ0 <= 1e-15 + if detJ0 > 1e-15: + J0_inv = J0.inverse() + + # dN/dX = dN/dxi @ J0^{-1} (N_NODES x DIM) + dNdX = dN_dxi @ J0_inv + + # Deformation gradient F = I + grad_u + # grad_u_{iI} = sum_a u_{ai} * dN_a/dX_I + F = ti.Matrix.identity(ti.f64, DIM) + for a in range(N_NODES): + nid = elem_nodes[e, a] + for i in ti.static(range(DIM)): + for I in ti.static(range(DIM)): + F[i, I] += u[nid][i] * dNdX[a, I] + + # Constitutive update: S = constitutive_update(F, lam, mu) + S = constitutive_update(F, lam, mu) + + # 1st Piola-Kirchhoff stress P = F @ S + P = F @ S + + # Integrate internal force: f_a_i += w_q * detJ0 * P_{iI} * dNdX_{aI} + w_q = QUAD_WEIGHTS[q] + for a in range(N_NODES): + nid = elem_nodes[e, a] + force_a = ti.Vector([0.0, 0.0, 0.0], dt=ti.f64) + for i in ti.static(range(DIM)): + val = ti.f64(0.0) + for I in ti.static(range(DIM)): + val += P[i, I] * dNdX[a, I] + force_a[i] = val + for i in ti.static(range(DIM)): + f_int[nid][i] += w_q * detJ0 * force_a[i] + +# ====================================================================== +# Tangent matvec (analytical consistent tangent) +# ====================================================================== + +def tangent_matvec(v_flat: np.ndarray, lam: float, mu: float) -> np.ndarray: + """Matrix-free tangent matvec: K(u) @ v via analytical linearisation. + + Parameters + ---------- + v_flat : np.ndarray, shape (n_nodes * 3,) + Direction vector. + lam, mu : float + Lame parameters. + + Returns + ------- + np.ndarray, shape (n_nodes * 3,) + Exact tangent-vector product K @ v. + """ + v = v_flat.reshape((-1, 3)) + Kv = np.zeros_like(v) + + # Snapshot Taichi fields into NumPy for the serial element loop. + u_np = u.to_numpy() + coords_np = x_ref.to_numpy() + conn_np = elem_nodes.to_numpy() + + I3 = np.eye(3, dtype=np.float64) + grad_at_quad_np = np.asarray(GRAD_AT_QUAD, dtype=np.float64) + + for e in range(n_elem): + nodes = conn_np[e] + u_elem = u_np[nodes] + X_elem = coords_np[nodes] + v_elem = v[nodes] + Kv_e = np.zeros((N_NODES, DIM), dtype=np.float64) + + for q in range(N_QP): + dN_dxi = grad_at_quad_np[q] + w_q = QUAD_WEIGHTS[q] + + J0 = X_elem.T @ dN_dxi + detJ0 = float(np.linalg.det(J0)) + if detJ0 <= 1e-15: + # Mirrors the runtime guard in compute_internal_force. + continue + dN_dX = dN_dxi @ np.linalg.inv(J0) + + # Current kinematics at this quadrature point. + grad_u = u_elem.T @ dN_dX + F = I3 + grad_u + E = 0.5 * (F.T @ F - I3) + + # Linearised strain in direction v. + grad_v = v_elem.T @ dN_dX + dE = 0.5 * (F.T @ grad_v + grad_v.T @ F) + + # SVK PK2 stress: S = lambda tr(E) I + 2 mu E. + tr_E = float(np.trace(E)) + S = lam * tr_E * I3 + 2.0 * mu * E + + # SVK material tangent C is constant; the contraction C : dE + # collapses to the same closed form as the stress update. + tr_dE = float(np.trace(dE)) + dS = lam * tr_dE * I3 + 2.0 * mu * dE + + # dP = (geometric term) + (material term) + dP = grad_v @ S + F @ dS + + Kv_e += w_q * detJ0 * (dN_dX @ dP.T) + + # Scatter element contribution to global tangent-vector product. + for a in range(N_NODES): + Kv[nodes[a]] += Kv_e[a] + + return Kv.ravel() + +# ====================================================================== +# Mesh validation +# ====================================================================== + + +def validate_mesh() -> None: + """Check all elements for degenerate Jacobians before solving. + + Raises ValueError if any quadrature point has det(J0) <= 0. + """ + coords_np = x_ref.to_numpy() + conn_np = elem_nodes.to_numpy() + n_elem_val = conn_np.shape[0] + for e in range(n_elem_val): + X_e = coords_np[conn_np[e]] + for q in range(N_QP): + dN = np.array(GRAD_AT_QUAD[q]) + J0 = X_e.T @ dN + detJ0 = np.linalg.det(J0) + if detJ0 <= 0.0: + raise ValueError(f"Degenerate element {e}: det(J0) = {detJ0:.6e} at quadrature point {q}. " + f"Check element connectivity and node coordinates.") + + +# ====================================================================== +# Newton-Raphson driver +# ====================================================================== + + +def newton_solve(lam: float, mu: float, + bc_dofs: np.ndarray | None = None, + bc_values: np.ndarray | None = None, + max_iter: int = 20, + tol_abs: float = 1.0e-10, + tol_rel: float = 1.0e-8) -> int: + """Newton-Raphson nonlinear solver with Dirichlet BC enforcement. + + Convergence is declared when ``res_norm < max(tol_abs, tol_rel * r0_norm)``. + + Parameters + ---------- + lam, mu : float + Lame parameters. + bc_dofs : np.ndarray | None + Flat indices of constrained DOFs. Residual, tangent matvec, + and displacement update are zeroed at these DOFs. + bc_values : np.ndarray | None + Flat array of prescribed displacement values at constrained DOFs. + Must have the same length as bc_dofs. When provided, ``u`` is + seeded with these values before the Newton loop (10-BOUNDARIES.md §6). + max_iter : int + Maximum Newton iterations. + tol_abs : float + Absolute convergence tolerance on residual norm. + tol_rel : float + Relative convergence tolerance (multiplied by initial residual norm). + + Returns + ------- + int + Number of iterations performed. + """ + from mechdsl.solver.import_adapter import CGSolver + + # Pre-flight: reject degenerate elements before solving + validate_mesh() + + n_dof = n_nodes * DIM + res_norm = float('inf') + r0_norm: float | None = None + + # Seed prescribed displacements (10-BOUNDARIES.md §4.2, §6) + if bc_dofs is not None and bc_values is not None: + u_arr = u.to_numpy().reshape(-1) + u_arr[bc_dofs] = bc_values + u.from_numpy(u_arr.reshape((-1, 3))) + + for iteration in range(max_iter): + # Step 1: Compute internal force + compute_internal_force(lam, mu) + + # Step 2: Form residual = f_int - f_ext + r = f_int.to_numpy() - f_ext.to_numpy() + r_flat = r.ravel() + + # Enforce Dirichlet BCs: zero residual at constrained DOFs + if bc_dofs is not None: + r_flat[bc_dofs] = 0.0 + + res_norm = np.linalg.norm(r_flat) + + # Record initial residual for relative tolerance + if r0_norm is None: + r0_norm = res_norm + + if not np.isfinite(res_norm): + raise RuntimeError("NaN or Inf detected in Newton residual. Constitutive model may have failed to converge.") + + print(f" Newton iter {iteration}: ||R|| = {res_norm:.6e}") + + # Converge when residual is below absolute OR relative threshold + conv_threshold = max(tol_abs, tol_rel * r0_norm) + if res_norm < conv_threshold: + print(f" Converged in {iteration} iterations.") + return iteration + + # Step 3: Solve K @ du = -R using CG with tangent matvec + def matvec(v: np.ndarray) -> np.ndarray: + v_bc = v.copy() if bc_dofs is not None else v + if bc_dofs is not None: + v_bc[bc_dofs] = 0.0 + Kv = tangent_matvec(v_bc, lam, mu) + if bc_dofs is not None: + Kv[bc_dofs] = v[bc_dofs] + return Kv + + solver = CGSolver() + du_flat, cg_iters, cg_res = solver.solve( + matvec_fn=matvec, rhs=-r_flat, + x0=np.zeros(n_dof), tol=1.0e-10, max_iter=2000, + ) + + # Enforce Dirichlet BCs on displacement update + if bc_dofs is not None: + du_flat[bc_dofs] = 0.0 + + # Step 4: Update displacement + du_arr = du_flat.reshape((-1, 3)) + u_arr = u.to_numpy() + u.from_numpy(u_arr + du_arr) + + raise RuntimeError(f"Newton did not converge in {max_iter} iterations. Final |R| = {res_norm:.3e}") + +# ====================================================================== +# Postprocessing +# ====================================================================== + + +def save_results(output_path: str = 'results.npz') -> None: + """Save displacement results to .npz file.""" + u_arr = u.to_numpy() + x_ref_arr = x_ref.to_numpy() + np.savez( + output_path, + displacement=u_arr, + reference_coords=x_ref_arr, + ) + print(f"Results saved to {output_path}") + + # Optional VTK export via meshio + try: + import meshio + points = x_ref_arr + u_arr + conn_arr = elem_nodes.to_numpy() + mesh = meshio.Mesh( + points=points, + cells=[("hexahedron", conn_arr)], + point_data={"displacement": u_arr}, + ) + vtk_path = output_path.replace('.npz', '.vtk') + meshio.write(vtk_path, mesh) + print(f"VTK written to {vtk_path}") + except ImportError: + pass # meshio not available + + +# ====================================================================== +# Main entry point +# ====================================================================== + + +if __name__ == "__main__": + import sys + + # Load mesh + mesh_path = sys.argv[1] if len(sys.argv) > 1 else "mesh.npz" + print(f"Loading mesh from {mesh_path}") + mesh_data = np.load(mesh_path) + coords = mesh_data["coords"] + conn = mesh_data["conn"] + + # Allocate Taichi fields and load mesh data + n_nodes_mesh = coords.shape[0] + n_elem_mesh = conn.shape[0] + allocate_fields(n_nodes_mesh, n_elem_mesh) + x_ref.from_numpy(coords) + elem_nodes.from_numpy(conn) + + # Load boundary conditions from mesh file + if "f_ext" in mesh_data: + f_ext.from_numpy(mesh_data['f_ext']) + else: + print("Warning: no f_ext in mesh file; external forces default to zero.") + + bc_dofs = mesh_data["bc_dofs"] if "bc_dofs" in mesh_data else None + + # Normalize bc_values: accept (n_nodes, 3) or flat array matching bc_dofs + bc_values_raw = mesh_data["bc_values"] if "bc_values" in mesh_data else None + bc_values = None + if bc_values_raw is not None and bc_dofs is not None: + if bc_values_raw.ndim == 2: + bc_values = bc_values_raw.ravel()[bc_dofs] + else: + bc_values = bc_values_raw + + # Material parameters + lam_val = 115384.61538461538 + mu_val = 76923.076923076922 + + # Run Newton solver + n_iters = newton_solve(lam_val, mu_val, bc_dofs=bc_dofs, bc_values=bc_values) + + print(f"Newton converged in {n_iters} iterations.") + + # Save results + save_results() + diff --git a/examples/_output/necking_j2.py b/examples/_output/necking_j2.py new file mode 100644 index 0000000..8e86736 --- /dev/null +++ b/examples/_output/necking_j2.py @@ -0,0 +1,623 @@ +"""Auto-generated Taichi FEM solver. DO NOT EDIT. + +Formulation : total_lagrangian +Material : j2_power_law +Element : hex8 +Dimension : 3 +""" + +import taichi as ti +import numpy as np + +ti.init(default_fp=ti.f64, arch=ti.cpu) + +# ====================================================================== +# Element constants: Hex8, 2x2x2 Gauss quadrature +# ====================================================================== + +N_NODES = 8 +N_QP = 8 +DIM = 3 +N_DOF_ELEM = N_NODES * DIM + +QUAD_WEIGHTS = [1, 1, 1, 1, 1, 1, 1, 1] + +SHAPE_AT_QUAD = [ + [0.49056261216234409, 0.13144585576580212, 0.035220810900864506, 0.13144585576580212, 0.13144585576580212, 0.035220810900864506, 0.0094373878376559257, 0.035220810900864506], + [0.13144585576580212, 0.035220810900864506, 0.0094373878376559257, 0.035220810900864506, 0.49056261216234409, 0.13144585576580212, 0.035220810900864506, 0.13144585576580212], + [0.13144585576580212, 0.035220810900864506, 0.13144585576580212, 0.49056261216234409, 0.035220810900864506, 0.0094373878376559257, 0.035220810900864506, 0.13144585576580212], + [0.035220810900864506, 0.0094373878376559257, 0.035220810900864506, 0.13144585576580212, 0.13144585576580212, 0.035220810900864506, 0.13144585576580212, 0.49056261216234409], + [0.13144585576580212, 0.49056261216234409, 0.13144585576580212, 0.035220810900864506, 0.035220810900864506, 0.13144585576580212, 0.035220810900864506, 0.0094373878376559257], + [0.035220810900864506, 0.13144585576580212, 0.035220810900864506, 0.0094373878376559257, 0.13144585576580212, 0.49056261216234409, 0.13144585576580212, 0.035220810900864506], + [0.035220810900864506, 0.13144585576580212, 0.49056261216234409, 0.13144585576580212, 0.0094373878376559257, 0.035220810900864506, 0.13144585576580212, 0.035220810900864506], + [0.0094373878376559257, 0.035220810900864506, 0.13144585576580212, 0.035220810900864506, 0.035220810900864506, 0.13144585576580212, 0.49056261216234409, 0.13144585576580212], +] + +GRAD_AT_QUAD = [ + [ + [-0.31100423396407312, -0.31100423396407312, -0.31100423396407312], + [0.31100423396407312, -0.083333333333333315, -0.083333333333333315], + [0.083333333333333315, 0.083333333333333315, -0.022329099369260218], + [-0.083333333333333315, 0.31100423396407312, -0.083333333333333315], + [-0.083333333333333315, -0.083333333333333315, 0.31100423396407312], + [0.083333333333333315, -0.022329099369260218, 0.083333333333333315], + [0.022329099369260218, 0.022329099369260218, 0.022329099369260218], + [-0.022329099369260218, 0.083333333333333315, 0.083333333333333315], + ], + [ + [-0.083333333333333315, -0.083333333333333315, -0.31100423396407312], + [0.083333333333333315, -0.022329099369260218, -0.083333333333333315], + [0.022329099369260218, 0.022329099369260218, -0.022329099369260218], + [-0.022329099369260218, 0.083333333333333315, -0.083333333333333315], + [-0.31100423396407312, -0.31100423396407312, 0.31100423396407312], + [0.31100423396407312, -0.083333333333333315, 0.083333333333333315], + [0.083333333333333315, 0.083333333333333315, 0.022329099369260218], + [-0.083333333333333315, 0.31100423396407312, 0.083333333333333315], + ], + [ + [-0.083333333333333315, -0.31100423396407312, -0.083333333333333315], + [0.083333333333333315, -0.083333333333333315, -0.022329099369260218], + [0.31100423396407312, 0.083333333333333315, -0.083333333333333315], + [-0.31100423396407312, 0.31100423396407312, -0.31100423396407312], + [-0.022329099369260218, -0.083333333333333315, 0.083333333333333315], + [0.022329099369260218, -0.022329099369260218, 0.022329099369260218], + [0.083333333333333315, 0.022329099369260218, 0.083333333333333315], + [-0.083333333333333315, 0.083333333333333315, 0.31100423396407312], + ], + [ + [-0.022329099369260218, -0.083333333333333315, -0.083333333333333315], + [0.022329099369260218, -0.022329099369260218, -0.022329099369260218], + [0.083333333333333315, 0.022329099369260218, -0.083333333333333315], + [-0.083333333333333315, 0.083333333333333315, -0.31100423396407312], + [-0.083333333333333315, -0.31100423396407312, 0.083333333333333315], + [0.083333333333333315, -0.083333333333333315, 0.022329099369260218], + [0.31100423396407312, 0.083333333333333315, 0.083333333333333315], + [-0.31100423396407312, 0.31100423396407312, 0.31100423396407312], + ], + [ + [-0.31100423396407312, -0.083333333333333315, -0.083333333333333315], + [0.31100423396407312, -0.31100423396407312, -0.31100423396407312], + [0.083333333333333315, 0.31100423396407312, -0.083333333333333315], + [-0.083333333333333315, 0.083333333333333315, -0.022329099369260218], + [-0.083333333333333315, -0.022329099369260218, 0.083333333333333315], + [0.083333333333333315, -0.083333333333333315, 0.31100423396407312], + [0.022329099369260218, 0.083333333333333315, 0.083333333333333315], + [-0.022329099369260218, 0.022329099369260218, 0.022329099369260218], + ], + [ + [-0.083333333333333315, -0.022329099369260218, -0.083333333333333315], + [0.083333333333333315, -0.083333333333333315, -0.31100423396407312], + [0.022329099369260218, 0.083333333333333315, -0.083333333333333315], + [-0.022329099369260218, 0.022329099369260218, -0.022329099369260218], + [-0.31100423396407312, -0.083333333333333315, 0.083333333333333315], + [0.31100423396407312, -0.31100423396407312, 0.31100423396407312], + [0.083333333333333315, 0.31100423396407312, 0.083333333333333315], + [-0.083333333333333315, 0.083333333333333315, 0.022329099369260218], + ], + [ + [-0.083333333333333315, -0.083333333333333315, -0.022329099369260218], + [0.083333333333333315, -0.31100423396407312, -0.083333333333333315], + [0.31100423396407312, 0.31100423396407312, -0.31100423396407312], + [-0.31100423396407312, 0.083333333333333315, -0.083333333333333315], + [-0.022329099369260218, -0.022329099369260218, 0.022329099369260218], + [0.022329099369260218, -0.083333333333333315, 0.083333333333333315], + [0.083333333333333315, 0.083333333333333315, 0.31100423396407312], + [-0.083333333333333315, 0.022329099369260218, 0.083333333333333315], + ], + [ + [-0.022329099369260218, -0.022329099369260218, -0.022329099369260218], + [0.022329099369260218, -0.083333333333333315, -0.083333333333333315], + [0.083333333333333315, 0.083333333333333315, -0.31100423396407312], + [-0.083333333333333315, 0.022329099369260218, -0.083333333333333315], + [-0.083333333333333315, -0.083333333333333315, 0.022329099369260218], + [0.083333333333333315, -0.31100423396407312, 0.083333333333333315], + [0.31100423396407312, 0.31100423396407312, 0.31100423396407312], + [-0.31100423396407312, 0.083333333333333315, 0.083333333333333315], + ], +] + + +# ====================================================================== +# Field declarations (dimensions set by mesh loader at runtime) +# ====================================================================== + +n_nodes = 0 +n_elem = 0 + +# Taichi fields -- allocated after mesh is loaded +x_ref = ti.Vector.field(3, dtype=ti.f64) # reference coords +x_cur = ti.Vector.field(3, dtype=ti.f64) # current coords +u = ti.Vector.field(3, dtype=ti.f64) # displacement +f_int = ti.Vector.field(3, dtype=ti.f64) # internal force +f_ext = ti.Vector.field(3, dtype=ti.f64) # external force +residual = ti.Vector.field(3, dtype=ti.f64) # residual = f_int - f_ext +du = ti.Vector.field(3, dtype=ti.f64) # displacement increment +Kv = ti.Vector.field(3, dtype=ti.f64) # tangent matvec result +elem_nodes = ti.field(dtype=ti.i32) # connectivity +# History fields for J2 plasticity +alpha = ti.field(dtype=ti.f64) # accumulated plastic strain (n_elem x N_QP) + + +def allocate_fields(nn: int, ne: int) -> None: + """Allocate Taichi fields after mesh dimensions are known.""" + global n_nodes, n_elem + n_nodes = nn + n_elem = ne + ti.root.dense(ti.i, n_nodes).place(x_ref, x_cur, u, f_int, f_ext, residual, du, Kv) + ti.root.dense(ti.ij, (n_elem, N_NODES)).place(elem_nodes) + ti.root.dense(ti.ij, (n_elem, N_QP)).place(alpha) + + +# ====================================================================== +# Constitutive model: j2_power_law +# ====================================================================== + +@ti.func +def constitutive_update_plastic( + F: ti.types.matrix(3, 3, ti.f64), + lam: ti.f64, mu: ti.f64, + sigma_y0: ti.f64, K_hard: ti.f64, n_hard: ti.f64, + alpha_old: ti.f64, +): + """J2 power-law plasticity: radial return with Newton iteration. + + Returns (S, alpha_new) -- updated 2nd Piola-Kirchhoff stress + and accumulated plastic strain. + """ + # 1. Kinematics: right Cauchy-Green and Green-Lagrange strain + C = F.transpose() @ F + I3 = ti.Matrix.identity(ti.f64, 3) + E = 0.5 * (C - I3) + + # 2. Elastic trial stress + tr_E = ti.f64(0.0) + for i in ti.static(range(3)): + tr_E += E[i, i] + S_trial = lam * tr_E * I3 + 2.0 * mu * E + + # 3. Deviatoric / volumetric split + tr_S = S_trial[0, 0] + S_trial[1, 1] + S_trial[2, 2] + S_dev = S_trial - (tr_S / 3.0) * I3 + + # 4. Von Mises equivalent stress + s_sq = ti.f64(0.0) + for i in ti.static(range(3)): + for j in ti.static(range(3)): + s_sq += S_dev[i, j] * S_dev[i, j] + sigma_eq = ti.sqrt(1.5 * s_sq) + + # 5. Yield check + sigma_y = sigma_y0 + K_hard * ti.pow(alpha_old, n_hard) + alpha_new = alpha_old + S = S_trial + + if sigma_eq > 1e-12 * sigma_y and sigma_eq > sigma_y: + # 6. Radial return: Newton iteration for delta_lambda + dl = ti.f64(0.0) + for _it in range(20): + alpha_trial = alpha_old + dl + sy = sigma_y0 + K_hard * ti.pow(alpha_trial, n_hard) + f = sigma_eq - 3.0 * mu * dl - sy + if ti.abs(f) < 1e-12: # tol per 07-CONVENTIONS.md §6 + break + H_prime = K_hard * n_hard * ti.pow(alpha_trial, n_hard - 1.0) if alpha_trial > 1e-30 else 0.0 + df = -3.0 * mu - H_prime + dl -= f / df + + # Guard: check Newton convergence for return mapping + f_final = sigma_eq - 3.0 * mu * dl - (sigma_y0 + K_hard * ti.pow(alpha_old + dl, n_hard)) + if ti.abs(f_final) > 1e-8: + # Non-converged: set NaN flag (propagates to Newton driver) + dl = ti.f64(float('nan')) + + # Clamp dl >= 0 (negative plastic multiplier is non-physical) + dl = ti.max(dl, 0.0) + + # 7. Update stress and hardening variable + factor = 1.0 - 3.0 * mu * dl / sigma_eq + S_vol = (tr_S / 3.0) * I3 + S = S_vol + factor * S_dev + alpha_new = alpha_old + dl + + return S, alpha_new + +# ====================================================================== +# Internal force kernel +# ====================================================================== + +@ti.kernel +def compute_internal_force(lam: ti.f64, mu: ti.f64, + sigma_y0: ti.f64, K_hard: ti.f64, + n_hard: ti.f64): + """Compute internal force vector over all elements.""" + # Zero internal force + for i in range(n_nodes): + f_int[i] = ti.Vector([0.0, 0.0, 0.0], dt=ti.f64) + + # Loop over elements (runtime -- mesh index) + for e in range(n_elem): + # Gather element nodal coordinates (reference and current) + X_elem = ti.Matrix.zero(ti.f64, N_NODES, DIM) + x_elem = ti.Matrix.zero(ti.f64, N_NODES, DIM) + for a in range(N_NODES): + nid = elem_nodes[e, a] + for d in ti.static(range(DIM)): + X_elem[a, d] = x_ref[nid][d] + x_elem[a, d] = x_ref[nid][d] + u[nid][d] + + # Quadrature loop (ti.static -- N_QP=8 is element-type constant, + # enables Python list access for GRAD_AT_QUAD and QUAD_WEIGHTS) + for q in ti.static(range(N_QP)): + # Shape function gradients in parametric space + dN_dxi = ti.Matrix.zero(ti.f64, N_NODES, DIM) + for a in ti.static(range(N_NODES)): + for d in ti.static(range(DIM)): + dN_dxi[a, d] = GRAD_AT_QUAD[q][a][d] + + # Reference Jacobian J0 = X^T @ dN/dxi (3x3) + J0 = X_elem.transpose() @ dN_dxi + detJ0 = J0.determinant() + # Guard: degenerate element (07-CONVENTIONS.md §6) -- skip QP if detJ0 <= 1e-15 + if detJ0 > 1e-15: + J0_inv = J0.inverse() + + # dN/dX = dN/dxi @ J0^{-1} (N_NODES x DIM) + dNdX = dN_dxi @ J0_inv + + # Deformation gradient F = I + grad_u + # grad_u_{iI} = sum_a u_{ai} * dN_a/dX_I + F = ti.Matrix.identity(ti.f64, DIM) + for a in range(N_NODES): + nid = elem_nodes[e, a] + for i in ti.static(range(DIM)): + for I in ti.static(range(DIM)): + F[i, I] += u[nid][i] * dNdX[a, I] + + # Constitutive update (J2 plasticity): read alpha, compute, write back + alpha_old = alpha[e, q] + S, alpha_new = constitutive_update_plastic(F, lam, mu, sigma_y0, K_hard, n_hard, alpha_old) + alpha[e, q] = alpha_new + + # 1st Piola-Kirchhoff stress P = F @ S + P = F @ S + + # Integrate internal force: f_a_i += w_q * detJ0 * P_{iI} * dNdX_{aI} + w_q = QUAD_WEIGHTS[q] + for a in range(N_NODES): + nid = elem_nodes[e, a] + force_a = ti.Vector([0.0, 0.0, 0.0], dt=ti.f64) + for i in ti.static(range(DIM)): + val = ti.f64(0.0) + for I in ti.static(range(DIM)): + val += P[i, I] * dNdX[a, I] + force_a[i] = val + for i in ti.static(range(DIM)): + f_int[nid][i] += w_q * detJ0 * force_a[i] + +# ====================================================================== +# Tangent matvec (analytical consistent tangent) +# ====================================================================== + +def tangent_matvec(v_flat: np.ndarray, lam: float, mu: float, + sigma_y0: float, K_hard: float, + n_hard: float) -> np.ndarray: + """Matrix-free tangent matvec: K(u) @ v via analytical linearisation. + + Parameters + ---------- + v_flat : np.ndarray, shape (n_nodes * 3,) + Direction vector. + lam, mu : float + Lame parameters. + sigma_y0, K_hard, n_hard : float + J2 plasticity parameters (used to reconstruct the + algorithmic consistent tangent per quadrature point). + + Returns + ------- + np.ndarray, shape (n_nodes * 3,) + Exact tangent-vector product K @ v. + """ + from mechdsl.symbolic.models.j2_power_law import ( + J2PowerLawMaterial, + radial_return, + ) + + # Reconstruct the material object used by the symbolic return map. + # The J2 material dataclass takes (E, nu) rather than (lam, mu); + # recover them algebraically. + _E = mu * (3.0 * lam + 2.0 * mu) / (lam + mu) + _nu = lam / (2.0 * (lam + mu)) + _j2_mat = J2PowerLawMaterial(E=_E, nu=_nu, sigma_y0=sigma_y0, K=K_hard, n=n_hard) + + v = v_flat.reshape((-1, 3)) + Kv = np.zeros_like(v) + + # Snapshot Taichi fields into NumPy for the serial element loop. + u_np = u.to_numpy() + coords_np = x_ref.to_numpy() + conn_np = elem_nodes.to_numpy() + alpha_np = alpha.to_numpy() + + I3 = np.eye(3, dtype=np.float64) + grad_at_quad_np = np.asarray(GRAD_AT_QUAD, dtype=np.float64) + + for e in range(n_elem): + nodes = conn_np[e] + u_elem = u_np[nodes] + X_elem = coords_np[nodes] + v_elem = v[nodes] + Kv_e = np.zeros((N_NODES, DIM), dtype=np.float64) + + for q in range(N_QP): + dN_dxi = grad_at_quad_np[q] + w_q = QUAD_WEIGHTS[q] + + J0 = X_elem.T @ dN_dxi + detJ0 = float(np.linalg.det(J0)) + if detJ0 <= 1e-15: + # Mirrors the runtime guard in compute_internal_force. + continue + dN_dX = dN_dxi @ np.linalg.inv(J0) + + # Current kinematics at this quadrature point. + grad_u = u_elem.T @ dN_dX + F = I3 + grad_u + E = 0.5 * (F.T @ F - I3) + + # Linearised strain in direction v. + grad_v = v_elem.T @ dN_dX + dE = 0.5 * (F.T @ grad_v + grad_v.T @ F) + + # J2 algorithmic consistent tangent: re-run the return map + # with the stored alpha. The result supplies both the + # current PK2 stress and the 4th-order tangent C_ep. + rm = radial_return(_j2_mat, E, float(alpha_np[e, q])) + S = rm.stress + dS = np.einsum('ijkl,kl->ij', rm.tangent, dE) + + # dP = (geometric term) + (material term) + dP = grad_v @ S + F @ dS + + Kv_e += w_q * detJ0 * (dN_dX @ dP.T) + + # Scatter element contribution to global tangent-vector product. + for a in range(N_NODES): + Kv[nodes[a]] += Kv_e[a] + + return Kv.ravel() + +# ====================================================================== +# Mesh validation +# ====================================================================== + + +def validate_mesh() -> None: + """Check all elements for degenerate Jacobians before solving. + + Raises ValueError if any quadrature point has det(J0) <= 0. + """ + coords_np = x_ref.to_numpy() + conn_np = elem_nodes.to_numpy() + n_elem_val = conn_np.shape[0] + for e in range(n_elem_val): + X_e = coords_np[conn_np[e]] + for q in range(N_QP): + dN = np.array(GRAD_AT_QUAD[q]) + J0 = X_e.T @ dN + detJ0 = np.linalg.det(J0) + if detJ0 <= 0.0: + raise ValueError(f"Degenerate element {e}: det(J0) = {detJ0:.6e} at quadrature point {q}. " + f"Check element connectivity and node coordinates.") + + +# ====================================================================== +# Newton-Raphson driver +# ====================================================================== + + +def newton_solve(lam: float, mu: float, + sigma_y0: float, K_hard: float, n_hard: float, + bc_dofs: np.ndarray | None = None, + bc_values: np.ndarray | None = None, + max_iter: int = 20, + tol_abs: float = 1.0e-10, + tol_rel: float = 1.0e-8) -> int: + """Newton-Raphson nonlinear solver with Dirichlet BC enforcement. + + Convergence is declared when ``res_norm < max(tol_abs, tol_rel * r0_norm)``. + + Parameters + ---------- + lam, mu : float + Lame parameters. + sigma_y0, K_hard, n_hard : float + J2 plasticity parameters. + bc_dofs : np.ndarray | None + Flat indices of constrained DOFs. Residual, tangent matvec, + and displacement update are zeroed at these DOFs. + bc_values : np.ndarray | None + Flat array of prescribed displacement values at constrained DOFs. + Must have the same length as bc_dofs. When provided, ``u`` is + seeded with these values before the Newton loop (10-BOUNDARIES.md §6). + max_iter : int + Maximum Newton iterations. + tol_abs : float + Absolute convergence tolerance on residual norm. + tol_rel : float + Relative convergence tolerance (multiplied by initial residual norm). + + Returns + ------- + int + Number of iterations performed. + """ + from mechdsl.solver.import_adapter import CGSolver + + # Pre-flight: reject degenerate elements before solving + validate_mesh() + + n_dof = n_nodes * DIM + res_norm = float('inf') + r0_norm: float | None = None + + # Seed prescribed displacements (10-BOUNDARIES.md §4.2, §6) + if bc_dofs is not None and bc_values is not None: + u_arr = u.to_numpy().reshape(-1) + u_arr[bc_dofs] = bc_values + u.from_numpy(u_arr.reshape((-1, 3))) + + for iteration in range(max_iter): + # Step 1: Compute internal force + compute_internal_force(lam, mu, sigma_y0, K_hard, n_hard) + + # Step 2: Form residual = f_int - f_ext + r = f_int.to_numpy() - f_ext.to_numpy() + r_flat = r.ravel() + + # Enforce Dirichlet BCs: zero residual at constrained DOFs + if bc_dofs is not None: + r_flat[bc_dofs] = 0.0 + + res_norm = np.linalg.norm(r_flat) + + # Record initial residual for relative tolerance + if r0_norm is None: + r0_norm = res_norm + + if not np.isfinite(res_norm): + raise RuntimeError("NaN or Inf detected in Newton residual. Constitutive model may have failed to converge.") + + print(f" Newton iter {iteration}: ||R|| = {res_norm:.6e}") + + # Converge when residual is below absolute OR relative threshold + conv_threshold = max(tol_abs, tol_rel * r0_norm) + if res_norm < conv_threshold: + print(f" Converged in {iteration} iterations.") + return iteration + + # Step 3: Solve K @ du = -R using CG with tangent matvec + def matvec(v: np.ndarray) -> np.ndarray: + v_bc = v.copy() if bc_dofs is not None else v + if bc_dofs is not None: + v_bc[bc_dofs] = 0.0 + Kv = tangent_matvec(v_bc, lam, mu, sigma_y0, K_hard, n_hard) + if bc_dofs is not None: + Kv[bc_dofs] = v[bc_dofs] + return Kv + + solver = CGSolver() + du_flat, cg_iters, cg_res = solver.solve( + matvec_fn=matvec, rhs=-r_flat, + x0=np.zeros(n_dof), tol=1.0e-10, max_iter=2000, + ) + + # Enforce Dirichlet BCs on displacement update + if bc_dofs is not None: + du_flat[bc_dofs] = 0.0 + + # Step 4: Update displacement + du_arr = du_flat.reshape((-1, 3)) + u_arr = u.to_numpy() + u.from_numpy(u_arr + du_arr) + + raise RuntimeError(f"Newton did not converge in {max_iter} iterations. Final |R| = {res_norm:.3e}") + +# ====================================================================== +# Postprocessing +# ====================================================================== + + +def save_results(output_path: str = 'results.npz') -> None: + """Save displacement results to .npz file.""" + u_arr = u.to_numpy() + x_ref_arr = x_ref.to_numpy() + np.savez( + output_path, + displacement=u_arr, + reference_coords=x_ref_arr, + ) + print(f"Results saved to {output_path}") + + # Optional VTK export via meshio + try: + import meshio + points = x_ref_arr + u_arr + conn_arr = elem_nodes.to_numpy() + mesh = meshio.Mesh( + points=points, + cells=[("hexahedron", conn_arr)], + point_data={"displacement": u_arr}, + ) + vtk_path = output_path.replace('.npz', '.vtk') + meshio.write(vtk_path, mesh) + print(f"VTK written to {vtk_path}") + except ImportError: + pass # meshio not available + + +# ====================================================================== +# Main entry point +# ====================================================================== + + +if __name__ == "__main__": + import sys + + # Load mesh + mesh_path = sys.argv[1] if len(sys.argv) > 1 else "mesh.npz" + print(f"Loading mesh from {mesh_path}") + mesh_data = np.load(mesh_path) + coords = mesh_data["coords"] + conn = mesh_data["conn"] + + # Allocate Taichi fields and load mesh data + n_nodes_mesh = coords.shape[0] + n_elem_mesh = conn.shape[0] + allocate_fields(n_nodes_mesh, n_elem_mesh) + x_ref.from_numpy(coords) + elem_nodes.from_numpy(conn) + + # Load boundary conditions from mesh file + if "f_ext" in mesh_data: + f_ext.from_numpy(mesh_data['f_ext']) + else: + print("Warning: no f_ext in mesh file; external forces default to zero.") + + bc_dofs = mesh_data["bc_dofs"] if "bc_dofs" in mesh_data else None + + # Normalize bc_values: accept (n_nodes, 3) or flat array matching bc_dofs + bc_values_raw = mesh_data["bc_values"] if "bc_values" in mesh_data else None + bc_values = None + if bc_values_raw is not None and bc_dofs is not None: + if bc_values_raw.ndim == 2: + bc_values = bc_values_raw.ravel()[bc_dofs] + else: + bc_values = bc_values_raw + + # Material parameters + lam_val = 115384.61538461538 + mu_val = 76923.076923076922 + sigma_y0_val = 250 + K_hard_val = 1000 + n_hard_val = 1 + + # Run Newton solver + # NOTE: Displacement-controlled plastic loading requires incremental + # load stepping with alpha snapshot/rollback. The standalone __main__ + # path does not implement this; use the newton_solve() API directly + # with your own load-stepping driver (see test_e2e_plastic.py for an + # example). bc_values is intentionally NOT forwarded here. + if bc_values is not None: + print("WARNING: bc_values ignored for plastic __main__ path. " + "Displacement-controlled J2 requires load stepping with alpha " + "management. Use newton_solve() API with a custom driver.") + n_iters = newton_solve(lam_val, mu_val, + sigma_y0_val, K_hard_val, n_hard_val, + bc_dofs=bc_dofs) + + print(f"Newton converged in {n_iters} iterations.") + + # Save results + save_results() + diff --git a/examples/cook_membrane.py b/examples/cook_membrane.py new file mode 100644 index 0000000..d9018db --- /dev/null +++ b/examples/cook_membrane.py @@ -0,0 +1,68 @@ +"""Compile the Cook's membrane MVP example via the programmatic API.""" + +from __future__ import annotations + +from mechdsl import compile +from mechdsl.frontend import build_context +from mechdsl.ir import ( + BCType, + BoundaryCondition, + ElementType, + Formulation, + MaterialSpec, + ProblemIR, +) + + +def problem_ir_from_context(ctx: dict) -> ProblemIR: + """Adapt a Layer 1 frontend context into the immutable Mechanics IR.""" + boundaries = tuple( + BoundaryCondition( + name=raw.get("name", raw.get("face", f"bc_{index}")), + bc_type=BCType(raw["type"]), + components=tuple(raw.get("dofs", (0, 1, 2))), + value=raw.get("value", 0.0), + traction=raw.get("traction"), + ) + for index, raw in enumerate(ctx["boundaries"]) + ) + return ProblemIR( + dim=ctx["dim"], + formulation=Formulation(ctx["formulation"]), + element_type=ElementType(ctx["cell_type"]), + material=MaterialSpec(model=ctx["material_type"], params=ctx["params"]), + boundaries=boundaries, + ) + + +def build_example_context() -> dict: + """Construct the Cook's membrane frontend context.""" + return build_context( + dim=3, + cell_type="hex8", + formulation="total_lagrangian", + material_type="j2_power_law", + params={"E": 240.565, "nu": 0.3, "sigma_y0": 243.0, "K": 300.0, "n": 0.4}, + boundaries=[ + {"name": "clamp", "face": "x0", "type": "dirichlet", "dofs": [0, 1, 2], "value": 0.0}, + {"name": "shear", "face": "x1", "type": "neumann", "traction": "cook_shear_y"}, + ], + ) + + +def main() -> None: + ctx = build_example_context() + problem_ir = problem_ir_from_context(ctx) + bundle = compile(problem_ir) + + print("Compilation summary") + print("example: cook_membrane") + print(f"material: {problem_ir.material.model}") + print(f"boundaries: {[bc.name for bc in problem_ir.boundaries]}") + print(f"contraction_plans: {len(bundle.contraction_plans)}") + print(f"emitted_lines: {bundle.emitted_source.count(chr(10)) + 1}") + print(f"content_hash: {bundle.content_hash()}") + + +if __name__ == "__main__": + main() diff --git a/examples/elastic_cantilever.py b/examples/elastic_cantilever.py new file mode 100644 index 0000000..4efac88 --- /dev/null +++ b/examples/elastic_cantilever.py @@ -0,0 +1,68 @@ +"""Compile the elastic cantilever MVP example via the programmatic API.""" + +from __future__ import annotations + +from mechdsl import compile +from mechdsl.frontend import build_context +from mechdsl.ir import ( + BCType, + BoundaryCondition, + ElementType, + Formulation, + MaterialSpec, + ProblemIR, +) + + +def problem_ir_from_context(ctx: dict) -> ProblemIR: + """Adapt a Layer 1 frontend context into the immutable Mechanics IR.""" + boundaries = tuple( + BoundaryCondition( + name=raw.get("name", raw.get("face", f"bc_{index}")), + bc_type=BCType(raw["type"]), + components=tuple(raw.get("dofs", (0, 1, 2))), + value=raw.get("value", 0.0), + traction=raw.get("traction"), + ) + for index, raw in enumerate(ctx["boundaries"]) + ) + return ProblemIR( + dim=ctx["dim"], + formulation=Formulation(ctx["formulation"]), + element_type=ElementType(ctx["cell_type"]), + material=MaterialSpec(model=ctx["material_type"], params=ctx["params"]), + boundaries=boundaries, + ) + + +def build_example_context() -> dict: + """Construct the elastic cantilever frontend context.""" + return build_context( + dim=3, + cell_type="hex8", + formulation="total_lagrangian", + material_type="svk", + params={"E": 200e3, "nu": 0.3}, + boundaries=[ + {"name": "fix", "face": "x0", "type": "dirichlet", "dofs": [0, 1, 2], "value": 0.0}, + {"name": "load", "face": "x1", "type": "neumann", "traction": "t_bar"}, + ], + ) + + +def main() -> None: + ctx = build_example_context() + problem_ir = problem_ir_from_context(ctx) + bundle = compile(problem_ir) + + print("Compilation summary") + print("example: elastic_cantilever") + print(f"material: {problem_ir.material.model}") + print(f"boundaries: {[bc.name for bc in problem_ir.boundaries]}") + print(f"contraction_plans: {len(bundle.contraction_plans)}") + print(f"emitted_lines: {bundle.emitted_source.count(chr(10)) + 1}") + print(f"content_hash: {bundle.content_hash()}") + + +if __name__ == "__main__": + main() diff --git a/examples/elastic_cantilever.tex b/examples/elastic_cantilever.tex new file mode 100644 index 0000000..7e4cf84 --- /dev/null +++ b/examples/elastic_cantilever.tex @@ -0,0 +1,23 @@ +% MechDSL -- Elastic cantilever beam (St. Venant-Kirchhoff) +% +% This is an example LaTeX input file for the MechDSL compiler. +% The parser (Phase 2) is not yet implemented; these directives +% document the intended input syntax for the MVP. +% +% Problem: 3D cantilever beam with fixed left face, downward traction on +% the right face. Material: SVK with E = 200 GPa, nu = 0.3. + +% mechanics dim 3 +% mechanics cell hex8 +% mechanics coord spatial x y z +% mechanics coord material X Y Z +% mechanics material svk --E 200e3 --nu 0.3 +% mechanics formulation total_lagrangian +% mechanics boundary fix --type dirichlet --field u --components 0 1 2 --value 0 +% mechanics boundary load --type neumann --traction "0 0 -1000" + +% After compilation, the generated Taichi solver will: +% 1. Initialise a structured Hex8 mesh +% 2. Apply Dirichlet BC (fix) on face x = 0 +% 3. Apply Neumann BC (load) on face x = L +% 4. Solve via Newton-Raphson with CG linear solver diff --git a/examples/gen_meshes.py b/examples/gen_meshes.py new file mode 100644 index 0000000..2766aa0 --- /dev/null +++ b/examples/gen_meshes.py @@ -0,0 +1,249 @@ +"""Generate mesh.npz input files for all examples. + +Run once to produce the mesh files consumed by the generated Taichi solvers: + + uv run python examples/gen_meshes.py + +Outputs (in examples/): + mesh_cantilever.npz -- elastic cantilever (SVK) + mesh_cook.npz -- Cook's membrane (J2) + mesh_necking.npz -- necking bar 1/8 model (J2) + mesh_patch.npz -- patch test (SVK) + mesh_uniaxial.npz -- uniaxial tension (J2) + +Each file contains: + coords float64 (n_nodes, 3) -- reference nodal coordinates + conn int64 (n_elem, 8) -- Hex8 connectivity (0-based) + f_ext float64 (n_nodes, 3) -- external nodal forces + bc_dofs int64 (n_bc,) -- constrained flat DOF indices (node*3 + c) + bc_values float64 (n_bc,) -- prescribed values at bc_dofs +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).parent + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def hex8_mesh( + nx: int, ny: int, nz: int, Lx: float, Ly: float, Lz: float +) -> tuple[np.ndarray, np.ndarray]: + """Structured Hex8 mesh on [0,Lx]×[0,Ly]×[0,Lz]. + + Node ordering: bottom face (-z) CCW then top face (+z) CCW, + matching the element_ir _HEX8_NODES convention. + """ + n_nodes = (nx + 1) * (ny + 1) * (nz + 1) + coords = np.empty((n_nodes, 3), dtype=np.float64) + idx = 0 + for k in range(nz + 1): + for j in range(ny + 1): + for i in range(nx + 1): + coords[idx] = [i * Lx / nx, j * Ly / ny, k * Lz / nz] + idx += 1 + + def nid(i: int, j: int, k: int) -> int: + return k * (ny + 1) * (nx + 1) + j * (nx + 1) + i + + n_elem = nx * ny * nz + conn = np.empty((n_elem, 8), dtype=np.int64) + eidx = 0 + for k in range(nz): + for j in range(ny): + for i in range(nx): + conn[eidx] = [ + nid(i, j, k), + nid(i + 1, j, k), + nid(i + 1, j + 1, k), + nid(i, j + 1, k), + nid(i, j, k + 1), + nid(i + 1, j, k + 1), + nid(i + 1, j + 1, k + 1), + nid(i, j + 1, k + 1), + ] + eidx += 1 + + return coords, conn + + +def bc_entries( + node_ids: np.ndarray, components: list[int], values: list[float] +) -> tuple[np.ndarray, np.ndarray]: + """Return (dofs, vals) for a set of nodes and prescribed component values.""" + dofs, vals = [], [] + for n in node_ids: + for c, v in zip(components, values, strict=True): + dofs.append(int(n) * 3 + c) + vals.append(v) + return np.array(dofs, dtype=np.int64), np.array(vals, dtype=np.float64) + + +def merge_bcs( + *parts: tuple[np.ndarray, np.ndarray], +) -> tuple[np.ndarray, np.ndarray]: + """Merge multiple (dofs, vals) pairs, keeping first occurrence per DOF.""" + seen: dict[int, float] = {} + for dofs, vals in parts: + for d, v in zip(dofs.tolist(), vals.tolist(), strict=True): + if d not in seen: + seen[d] = v + sorted_items = sorted(seen.items()) + if not sorted_items: + return np.array([], dtype=np.int64), np.array([], dtype=np.float64) + d_arr, v_arr = zip(*sorted_items, strict=True) + return np.array(d_arr, dtype=np.int64), np.array(v_arr, dtype=np.float64) + + +def face_nodes(coords: np.ndarray, axis: int, at_max: bool, tol: float = 1e-10) -> np.ndarray: + vals = coords[:, axis] + ref = vals.max() if at_max else vals.min() + return np.where(np.abs(vals - ref) < tol)[0] + + +def save( + name: str, + coords: np.ndarray, + conn: np.ndarray, + f_ext: np.ndarray, + bc_dofs: np.ndarray, + bc_values: np.ndarray, +) -> None: + path = HERE / name + np.savez(path, coords=coords, conn=conn, f_ext=f_ext, bc_dofs=bc_dofs, bc_values=bc_values) + n_nodes, n_elem = len(coords), len(conn) + print(f" {name}: {n_elem} elem, {n_nodes} nodes, {len(bc_dofs)} bc DOFs") + + +# --------------------------------------------------------------------------- +# 1. Elastic cantilever (SVK, elastic_cantilever.py) +# Domain: [0,10]×[0,2]×[0,2], 4×2×2 Hex8 +# BCs: fix x0 (all), Neumann z-traction on x1 +# --------------------------------------------------------------------------- +def gen_cantilever() -> None: + nx, ny, nz = 4, 2, 2 + Lx, Ly, Lz = 10.0, 2.0, 2.0 + coords, conn = hex8_mesh(nx, ny, nz, Lx, Ly, Lz) + + x0 = face_nodes(coords, 0, at_max=False) + x1 = face_nodes(coords, 0, at_max=True) + + bc_dofs, bc_values = bc_entries(x0, [0, 1, 2], [0.0, 0.0, 0.0]) + + f_ext = np.zeros((len(coords), 3), dtype=np.float64) + f_ext[x1, 2] = -1000.0 / len(x1) # total downward z-force = -1 kN + + save("mesh_cantilever.npz", coords, conn, f_ext, bc_dofs, bc_values) + + +# --------------------------------------------------------------------------- +# 2. Cook's membrane (J2, cook_membrane.py) +# Domain: 4×4×1 box [0,48]×[0,44]×[0,1] (rectangular approximation) +# BCs: clamp x0 (all), Neumann y-shear on x1 +# --------------------------------------------------------------------------- +def gen_cook() -> None: + nx, ny, nz = 4, 4, 1 + Lx, Ly, Lz = 48.0, 44.0, 1.0 + coords, conn = hex8_mesh(nx, ny, nz, Lx, Ly, Lz) + + x0 = face_nodes(coords, 0, at_max=False) + x1 = face_nodes(coords, 0, at_max=True) + + bc_dofs, bc_values = bc_entries(x0, [0, 1, 2], [0.0, 0.0, 0.0]) + + f_ext = np.zeros((len(coords), 3), dtype=np.float64) + f_ext[x1, 1] = 100.0 / len(x1) # total y-shear = 100 N + + save("mesh_cook.npz", coords, conn, f_ext, bc_dofs, bc_values) + + +# --------------------------------------------------------------------------- +# 3. Necking bar (J2, necking_bar.py) +# Domain: 1/8 symmetry model [0,5]×[0,5]×[0,10], 2×2×4 Hex8 +# BCs: x_sym (u_x=0 on x0), y_sym (u_y=0 on y0), z_fix (u_z=0 on z0), +# pull (u_z=1.0 on z1) +# --------------------------------------------------------------------------- +def gen_necking() -> None: + nx, ny, nz = 2, 2, 4 + Lx, Ly, Lz = 5.0, 5.0, 10.0 + coords, conn = hex8_mesh(nx, ny, nz, Lx, Ly, Lz) + + x0 = face_nodes(coords, 0, at_max=False) + y0 = face_nodes(coords, 1, at_max=False) + z0 = face_nodes(coords, 2, at_max=False) + z1 = face_nodes(coords, 2, at_max=True) + + bc_dofs, bc_values = merge_bcs( + bc_entries(x0, [0], [0.0]), # x-symmetry + bc_entries(y0, [1], [0.0]), # y-symmetry + bc_entries(z0, [2], [0.0]), # bottom fixed in z + bc_entries(z1, [2], [1.0]), # 1 mm axial pull + ) + + f_ext = np.zeros((len(coords), 3), dtype=np.float64) + + save("mesh_necking.npz", coords, conn, f_ext, bc_dofs, bc_values) + + +# --------------------------------------------------------------------------- +# 4. Patch test (SVK, patch_test.py) +# Domain: [0,1]³, 2×2×2 Hex8 +# BCs: anchor x0 (all), prescribed x-stretch on x1 +# --------------------------------------------------------------------------- +def gen_patch() -> None: + nx, ny, nz = 2, 2, 2 + Lx, Ly, Lz = 1.0, 1.0, 1.0 + coords, conn = hex8_mesh(nx, ny, nz, Lx, Ly, Lz) + + x0 = face_nodes(coords, 0, at_max=False) + x1 = face_nodes(coords, 0, at_max=True) + + bc_dofs, bc_values = merge_bcs( + bc_entries(x0, [0, 1, 2], [0.0, 0.0, 0.0]), # anchor + bc_entries(x1, [0], [0.01]), # 1% stretch + ) + + f_ext = np.zeros((len(coords), 3), dtype=np.float64) + + save("mesh_patch.npz", coords, conn, f_ext, bc_dofs, bc_values) + + +# --------------------------------------------------------------------------- +# 5. Plastic uniaxial (J2, plastic_uniaxial.py) +# Domain: [0,10]×[0,2]×[0,2], 4×2×2 Hex8 +# BCs: fix x0 (all), prescribed x-displacement on x1 +# --------------------------------------------------------------------------- +def gen_uniaxial() -> None: + nx, ny, nz = 4, 2, 2 + Lx, Ly, Lz = 10.0, 2.0, 2.0 + coords, conn = hex8_mesh(nx, ny, nz, Lx, Ly, Lz) + + x0 = face_nodes(coords, 0, at_max=False) + x1 = face_nodes(coords, 0, at_max=True) + + bc_dofs, bc_values = merge_bcs( + bc_entries(x0, [0, 1, 2], [0.0, 0.0, 0.0]), # fully fixed + bc_entries(x1, [0], [0.1]), # 0.1 mm pull (1%) + ) + + f_ext = np.zeros((len(coords), 3), dtype=np.float64) + + save("mesh_uniaxial.npz", coords, conn, f_ext, bc_dofs, bc_values) + + + +if __name__ == "__main__": + print("Generating mesh files in", HERE) + gen_cantilever() + gen_cook() + gen_necking() + gen_patch() + gen_uniaxial() + print("Done.") diff --git a/examples/hgo_energy.tex b/examples/hgo_energy.tex new file mode 100644 index 0000000..e006f21 --- /dev/null +++ b/examples/hgo_energy.tex @@ -0,0 +1,40 @@ +% Holzapfel-Gasser-Ogden (HGO) anisotropic hyperelastic strain-energy density, +% authored as LaTeX for the LaTeX-to-code constitutive pipeline +% (symbolic/anisotropic_energy.py — the FIBER / I4 derivation path). +% +% Psi = (mu/2)(Ibar1 - 3) + (kappa/2)(Jdet - 1)^2 +% + (k1 / (2 k2)) (exp(k2 (Ibar4 - 1)^2) - 1) +% +% where +% Ibar1 = I1 * I3^{-1/3} (isochoric first invariant, I1 = tr C) +% Jdet = sqrt(det C) (Jacobian, det F) +% Ibar4 = I3^{-1/3} * (a . C . a) (isochoric fiber pseudo-invariant for one +% fiber family with unit direction a) +% C = 2 E + I (right Cauchy-Green from Green-Lagrange E) +% +% Fiber authoring contract (symbolic/anisotropic_energy.py). Ibar1 and Jdet are +% bound as usual; the fiber pseudo-invariant \mathrm{Ibar4} is bound to +% I3^{-1/3} (a . C . a) with the fiber direction a = (a0, a1, a2) introduced as +% symbolic components. The energy is authored once for a SINGLE (generic) fiber +% family; the model applies the derived fiber-term stress to each fiber +% direction declared via the `% mechanics fiber` directive (P5-1), gating each +% family by the Macaulay bracket (the fiber term is active only in +% tension, Ibar4 > 1) — matching models/hgo.py's `if E_fi <= 0: return 0`. +% +% This is the kappa_disp = 0 (perfectly-aligned fibers) special case of the +% Gasser-Ogden-Holzapfel oracle in models/hgo.py, which the differential test +% targets with HGOMaterial(..., fiber_dispersion=0.0). +% +% The exponential emits math.exp; the constitutive emitter must register +% math.exp -> ti.exp (energy_emitter._MATH_TO_TAICHI) for any Taichi run. +% +% Parameter naming. mu, kappa are greek --const tokens. The fiber parameters +% k1, k2 are authored as bare scalar symbols \mathrm{k1}, \mathrm{k2} (the same +% \mathrm{..} escape the invariant names use), which nrpylatex emits as the +% scalar symbols k1, k2. nrpylatex parses the exponential as \exp{...} (brace +% form; the paren form \exp(...) is not in its grammar). + +% declare metric gDD --dim 3 +% declare EDD --dim 3 +% declare \mu \kappa --const +\Psi = \frac{\mu}{2} (\mathrm{Ibar1} - 3) + \frac{\kappa}{2} (\mathrm{Jdet} - 1)^{2} + \frac{\mathrm{k1}}{2 \mathrm{k2}} (\exp{\mathrm{k2} (\mathrm{Ibar4} - 1)^{2}} - 1) diff --git a/examples/mooney_rivlin_energy.tex b/examples/mooney_rivlin_energy.tex new file mode 100644 index 0000000..a3105fd --- /dev/null +++ b/examples/mooney_rivlin_energy.tex @@ -0,0 +1,36 @@ +% Mooney-Rivlin (compressible, two-invariant isochoric-volumetric split) +% strain-energy density, authored as LaTeX for the LaTeX-to-code constitutive +% pipeline (symbolic/energy.py). +% +% Psi = C1 * (Ibar1 - 3) + C2 * (Ibar2 - 3) + (kappa/2) * (Jdet - 1)^2 +% +% where +% Ibar1 = I1 * I3^{-1/3} (isochoric first invariant, I1 = tr C) +% Ibar2 = I2 * I3^{-2/3} (isochoric second invariant, I2 = 1/2[(tr C)^2 - tr C^2]) +% Jdet = sqrt(det C) (Jacobian, det F) +% C = 2*E + I (right Cauchy-Green from Green-Lagrange strain E) +% +% Authored in named invariants so nrpylatex never has to parse \det / \log. +% All three invariant symbols (\mathrm{Ibar1}, \mathrm{Ibar2}, \mathrm{Jdet}) +% are substituted with their C = 2E + I definitions by symbolic/energy.py +% before differentiation (Ibar2 was registered in P2-1; first real use here). +% +% Parameter naming. nrpylatex only scans known (greek) command tokens, so the +% Mooney coefficients are authored as greek letters and documented here: +% \alpha == C1 (first Mooney-Rivlin coefficient; > 0) +% \beta == C2 (second Mooney-Rivlin coefficient; >= 0) +% \kappa == bulk modulus (> 0) +% \beta collides with sympy.beta and is sanitised to the Hebrew placeholder +% \aleph internally (same mechanism SVK uses for \lambda -> \aleph); the +% original display name is preserved in EnergyModel.parameters. +% +% Matches the oracle in models/mooney_rivlin.py exactly: +% S = 2*C1*J^{-2/3} (I - (1/3) I1 Cinv) +% + 2*C2*J^{-4/3} (I1 I - C - (2/3) I2 Cinv) +% + kappa*J*(J-1) Cinv +% and reduces to Neo-Hookean (mu = 2*C1) when C2 = 0. + +% declare metric gDD --dim 3 +% declare EDD --dim 3 +% declare \alpha \beta \kappa --const +\Psi = \alpha \left( \mathrm{Ibar1} - 3 \right) + \beta \left( \mathrm{Ibar2} - 3 \right) + \frac{\kappa}{2} \left( \mathrm{Jdet} - 1 \right)^{2} diff --git a/examples/necking_bar.py b/examples/necking_bar.py new file mode 100644 index 0000000..cc2aee9 --- /dev/null +++ b/examples/necking_bar.py @@ -0,0 +1,70 @@ +"""Compile the necking-bar MVP example via the programmatic API.""" + +from __future__ import annotations + +from mechdsl import compile +from mechdsl.frontend import build_context +from mechdsl.ir import ( + BCType, + BoundaryCondition, + ElementType, + Formulation, + MaterialSpec, + ProblemIR, +) + + +def problem_ir_from_context(ctx: dict) -> ProblemIR: + """Adapt a Layer 1 frontend context into the immutable Mechanics IR.""" + boundaries = tuple( + BoundaryCondition( + name=raw.get("name", raw.get("face", f"bc_{index}")), + bc_type=BCType(raw["type"]), + components=tuple(raw.get("dofs", (0, 1, 2))), + value=raw.get("value", 0.0), + traction=raw.get("traction"), + ) + for index, raw in enumerate(ctx["boundaries"]) + ) + return ProblemIR( + dim=ctx["dim"], + formulation=Formulation(ctx["formulation"]), + element_type=ElementType(ctx["cell_type"]), + material=MaterialSpec(model=ctx["material_type"], params=ctx["params"]), + boundaries=boundaries, + ) + + +def build_example_context() -> dict: + """Construct the necking-bar frontend context.""" + return build_context( + dim=3, + cell_type="hex8", + formulation="total_lagrangian", + material_type="j2_power_law", + params={"E": 200e3, "nu": 0.3, "sigma_y0": 450.0, "K": 1200.0, "n": 0.2}, + boundaries=[ + {"name": "x_sym", "face": "x0", "type": "dirichlet", "dofs": [0], "value": 0.0}, + {"name": "y_sym", "face": "y0", "type": "dirichlet", "dofs": [1], "value": 0.0}, + {"name": "z_fix", "face": "z0", "type": "dirichlet", "dofs": [2], "value": 0.0}, + {"name": "pull", "face": "z1", "type": "dirichlet", "dofs": [2], "value": "u_bar"}, + ], + ) + + +def main() -> None: + ctx = build_example_context() + problem_ir = problem_ir_from_context(ctx) + bundle = compile(problem_ir) + + print("Compilation summary") + print("example: necking_bar") + print(f"material: {problem_ir.material.model}") + print(f"boundaries: {[bc.name for bc in problem_ir.boundaries]}") + print(f"contraction_plans: {len(bundle.contraction_plans)}") + print(f"emitted_lines: {bundle.emitted_source.count(chr(10)) + 1}") + print(f"content_hash: {bundle.content_hash()}") + + +if __name__ == "__main__": + main() diff --git a/examples/neo_hookean_energy.tex b/examples/neo_hookean_energy.tex new file mode 100644 index 0000000..f9a10a9 --- /dev/null +++ b/examples/neo_hookean_energy.tex @@ -0,0 +1,24 @@ +% Neo-Hookean (compressible, isochoric-volumetric split) strain-energy density, +% authored as LaTeX for the LaTeX-to-code constitutive pipeline (symbolic/energy.py). +% +% Psi = (mu/2) * (Ibar1 - 3) + (kappa/2) * (Jdet - 1)^2 +% +% where +% Ibar1 = I1 * I3^{-1/3} (isochoric first invariant, I1 = tr C, I3 = det C) +% Jdet = sqrt(det C) (Jacobian, det F) +% C = 2*E + I (right Cauchy-Green from Green-Lagrange strain E) +% +% Authored in named invariants so nrpylatex never has to parse \det / \log. +% Invariant symbols (\mathrm{Ibar1}, \mathrm{Jdet}) are substituted with their +% C = 2E + I definitions by symbolic/energy.py before differentiation. +% +% mu and kappa are safe parameter names (no collision with Python keywords, +% builtins, or SymPy symbols) so no sanitisation placeholder is needed. +% +% Matches the oracle in models/neo_hookean.py exactly: +% S = mu * J^{-2/3} * (I - (1/3) * I1 * Cinv) + kappa * J * (J - 1) * Cinv + +% declare metric gDD --dim 3 +% declare EDD --dim 3 +% declare \mu \kappa --const +\Psi = \frac{\mu}{2} \left( \mathrm{Ibar1} - 3 \right) + \frac{\kappa}{2} \left( \mathrm{Jdet} - 1 \right)^{2} diff --git a/examples/ogden_energy.tex b/examples/ogden_energy.tex new file mode 100644 index 0000000..83e7a86 --- /dev/null +++ b/examples/ogden_energy.tex @@ -0,0 +1,42 @@ +% Ogden (compressible, spectral / principal-stretch, isochoric-volumetric split) +% strain-energy density, authored as LaTeX for the LaTeX-to-code constitutive +% pipeline (symbolic/spectral_energy.py — the SPECTRAL derivation path). +% +% Two-term Ogden: +% Psi = (mu1/alpha1)(lbar1^{alpha1} + lbar2^{alpha1} + lbar3^{alpha1} - 3) +% + (mu2/alpha2)(lbar1^{alpha2} + lbar2^{alpha2} + lbar3^{alpha2} - 3) +% + (kappa/2)(Jdet - 1)^2 +% +% where +% lbar_i = J^{-1/3} * lambda_i (isochoric principal stretches) +% lambda_i = sqrt(eig_i(C)) (principal stretches; C = 2E + I) +% J = lambda_1 * lambda_2 * lambda_3 = det F +% +% Spectral authoring contract (symbolic/spectral_energy.py). The barred +% principal stretches are authored as bare scalar symbols \mathrm{lbar1}, +% \mathrm{lbar2}, \mathrm{lbar3} and the Jacobian as \mathrm{Jdet} — the same +% \mathrm{..} escape the named-invariant path uses, so nrpylatex emits them as +% scalar symbols with no index contraction. The derivation substitutes +% lbar_i -> J^{-1/3} lambda_i, Jdet -> lambda_1 lambda_2 lambda_3 +% (lambda_i three independent stretch symbols), differentiates Psi w.r.t. the +% lambda_i, and forms the principal PK2 stresses S_i = (1/lambda_i) dPsi/dlambda_i. +% At evaluation the stretches come from a numerical eigendecomposition of C and +% S = sum_i S_i N_i (x) N_i is reassembled — robust at repeated eigenvalues +% (no eigenvalue-difference denominators). The tangent uses central-difference +% FD of that stress, matching models/ogden.py's own method. +% +% Parameter naming (nrpylatex scans only known greek command tokens): +% \mu == mu1 (first Ogden shear coefficient) +% \alpha == alpha1 (first Ogden exponent) +% \nu == mu2 (second Ogden shear coefficient) +% \eta == alpha2 (second Ogden exponent) +% \kappa == bulk modulus +% All five are sympy-safe (no sanitisation placeholder needed). +% +% Matches the oracle in models/ogden.py (mus=(mu1,mu2), alphas=(alpha1,alpha2), +% kappa) to < 1e-8 (stress). + +% declare metric gDD --dim 3 +% declare EDD --dim 3 +% declare \mu \alpha \nu \eta \kappa --const +\Psi = \frac{\mu}{\alpha}\left(\mathrm{lbar1}^{\alpha} + \mathrm{lbar2}^{\alpha} + \mathrm{lbar3}^{\alpha} - 3\right) + \frac{\nu}{\eta}\left(\mathrm{lbar1}^{\eta} + \mathrm{lbar2}^{\eta} + \mathrm{lbar3}^{\eta} - 3\right) + \frac{\kappa}{2}\left(\mathrm{Jdet} - 1\right)^{2} diff --git a/examples/patch_test.py b/examples/patch_test.py new file mode 100644 index 0000000..8407121 --- /dev/null +++ b/examples/patch_test.py @@ -0,0 +1,68 @@ +"""Compile the patch-test MVP example via the programmatic API.""" + +from __future__ import annotations + +from mechdsl import compile +from mechdsl.frontend import build_context +from mechdsl.ir import ( + BCType, + BoundaryCondition, + ElementType, + Formulation, + MaterialSpec, + ProblemIR, +) + + +def problem_ir_from_context(ctx: dict) -> ProblemIR: + """Adapt a Layer 1 frontend context into the immutable Mechanics IR.""" + boundaries = tuple( + BoundaryCondition( + name=raw.get("name", raw.get("face", f"bc_{index}")), + bc_type=BCType(raw["type"]), + components=tuple(raw.get("dofs", (0, 1, 2))), + value=raw.get("value", 0.0), + traction=raw.get("traction"), + ) + for index, raw in enumerate(ctx["boundaries"]) + ) + return ProblemIR( + dim=ctx["dim"], + formulation=Formulation(ctx["formulation"]), + element_type=ElementType(ctx["cell_type"]), + material=MaterialSpec(model=ctx["material_type"], params=ctx["params"]), + boundaries=boundaries, + ) + + +def build_example_context() -> dict: + """Construct the patch-test frontend context.""" + return build_context( + dim=3, + cell_type="hex8", + formulation="total_lagrangian", + material_type="svk", + params={"E": 200e3, "nu": 0.3}, + boundaries=[ + {"name": "anchor", "face": "x0", "type": "dirichlet", "dofs": [0, 1, 2], "value": 0.0}, + {"name": "stretch", "face": "x1", "type": "dirichlet", "dofs": [0], "value": "u_bar"}, + ], + ) + + +def main() -> None: + ctx = build_example_context() + problem_ir = problem_ir_from_context(ctx) + bundle = compile(problem_ir) + + print("Compilation summary") + print("example: patch_test") + print(f"material: {problem_ir.material.model}") + print(f"boundaries: {[bc.name for bc in problem_ir.boundaries]}") + print(f"contraction_plans: {len(bundle.contraction_plans)}") + print(f"emitted_lines: {bundle.emitted_source.count(chr(10)) + 1}") + print(f"content_hash: {bundle.content_hash()}") + + +if __name__ == "__main__": + main() diff --git a/examples/plastic_necking.tex b/examples/plastic_necking.tex new file mode 100644 index 0000000..61cbafd --- /dev/null +++ b/examples/plastic_necking.tex @@ -0,0 +1,27 @@ +% MechDSL -- Tensile necking bar (J2 plasticity with power-law hardening) +% +% This is an example LaTeX input file for the MechDSL compiler. +% The parser (Phase 2) is not yet implemented; these directives +% document the intended input syntax for the MVP. +% +% Problem: 3D bar under uniaxial tension. Both ends are displacement- +% controlled: left face fixed, right face pulled. Material: J2 +% von Mises plasticity with isotropic power-law hardening. +% sigma_y(alpha) = sigma_y0 + K * alpha^n +% E = 200 GPa, nu = 0.3, sigma_y0 = 250 MPa, K = 1000, n = 1.0 + +% mechanics dim 3 +% mechanics cell hex8 +% mechanics coord spatial x y z +% mechanics coord material X Y Z +% mechanics material hooke_power_law --E 200e3 --nu 0.3 --sigma_y0 250 --K 1000 --n 1.0 +% mechanics formulation total_lagrangian +% mechanics boundary fix --type dirichlet --field u --components 0 1 2 --value 0 +% mechanics boundary pull --type dirichlet --field u --components 2 --value 0.1 + +% After compilation, the generated Taichi solver will: +% 1. Initialise a structured Hex8 mesh +% 2. Apply Dirichlet BCs: fix on z = 0, pull on z = L +% 3. Use adaptive load stepping to reach full prescribed displacement +% 4. At each load step, solve via Newton-Raphson with CG linear solver +% 5. Track history fields (accumulated plastic strain alpha) diff --git a/examples/plastic_uniaxial.py b/examples/plastic_uniaxial.py new file mode 100644 index 0000000..7cbd323 --- /dev/null +++ b/examples/plastic_uniaxial.py @@ -0,0 +1,68 @@ +"""Compile the plastic uniaxial MVP example via the programmatic API.""" + +from __future__ import annotations + +from mechdsl import compile +from mechdsl.frontend import build_context +from mechdsl.ir import ( + BCType, + BoundaryCondition, + ElementType, + Formulation, + MaterialSpec, + ProblemIR, +) + + +def problem_ir_from_context(ctx: dict) -> ProblemIR: + """Adapt a Layer 1 frontend context into the immutable Mechanics IR.""" + boundaries = tuple( + BoundaryCondition( + name=raw.get("name", raw.get("face", f"bc_{index}")), + bc_type=BCType(raw["type"]), + components=tuple(raw.get("dofs", (0, 1, 2))), + value=raw.get("value", 0.0), + traction=raw.get("traction"), + ) + for index, raw in enumerate(ctx["boundaries"]) + ) + return ProblemIR( + dim=ctx["dim"], + formulation=Formulation(ctx["formulation"]), + element_type=ElementType(ctx["cell_type"]), + material=MaterialSpec(model=ctx["material_type"], params=ctx["params"]), + boundaries=boundaries, + ) + + +def build_example_context() -> dict: + """Construct the plastic uniaxial frontend context.""" + return build_context( + dim=3, + cell_type="hex8", + formulation="total_lagrangian", + material_type="j2_power_law", + params={"E": 200e3, "nu": 0.3, "sigma_y0": 250.0, "K": 1000.0, "n": 1.0}, + boundaries=[ + {"name": "fix", "face": "x0", "type": "dirichlet", "dofs": [0, 1, 2], "value": 0.0}, + {"name": "pull", "face": "x1", "type": "dirichlet", "dofs": [0], "value": "u_bar"}, + ], + ) + + +def main() -> None: + ctx = build_example_context() + problem_ir = problem_ir_from_context(ctx) + bundle = compile(problem_ir) + + print("Compilation summary") + print("example: plastic_uniaxial") + print(f"material: {problem_ir.material.model}") + print(f"boundaries: {[bc.name for bc in problem_ir.boundaries]}") + print(f"contraction_plans: {len(bundle.contraction_plans)}") + print(f"emitted_lines: {bundle.emitted_source.count(chr(10)) + 1}") + print(f"content_hash: {bundle.content_hash()}") + + +if __name__ == "__main__": + main() diff --git a/examples/run_compile_latex.py b/examples/run_compile_latex.py new file mode 100644 index 0000000..4fa9338 --- /dev/null +++ b/examples/run_compile_latex.py @@ -0,0 +1,41 @@ +"""Canonical first-run example: LaTeX source -> Taichi via compile_latex. + +This is the MVP-stable entry point recommended for new users. The script +embeds a small ``% mechanics`` LaTeX source string and forwards it to the +canonical ``mechdsl.compile_latex`` facade, which parses the directives, +adapts them to a ``ProblemIR``, and runs localisation, einsum planning, +and Taichi emission. + +Run with:: + + uv run python examples/run_compile_latex.py + +The programmatic ``build_context()`` / direct ``ProblemIR`` examples in +this directory remain available as advanced/testing aids; LaTeX-first is +the documented stable story (recovery-plan P7-3). +""" + +from __future__ import annotations + +from mechdsl import compile_latex + +LATEX_SOURCE = r""" +% MechDSL canonical first-run example -- elastic cantilever (SVK Hex8). +% mechanics dim 3 +% mechanics cell hex8 +% mechanics formulation total_lagrangian +% mechanics material svk --E 200e3 --nu 0.3 +% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 +% mechanics boundary load --type neumann --traction "0 0 -1000" +""" + + +def main() -> None: + bundle = compile_latex(LATEX_SOURCE, profile="mvp") + print("compile_latex bundle summary:") + print(f" element_ir_summary: {bundle.element_ir_summary}") + print(f" content_hash: {bundle.content_hash()}") + + +if __name__ == "__main__": + main() diff --git a/examples/run_compile_latex_equation.py b/examples/run_compile_latex_equation.py new file mode 100644 index 0000000..2d45be4 --- /dev/null +++ b/examples/run_compile_latex_equation.py @@ -0,0 +1,69 @@ +"""Headline product story: equation-bearing LaTeX -> Taichi via compile_latex. + +This is the canonical fgram first-run example (closure task P7-1). Unlike the +directive-only ``run_compile_latex.py``, the source here is *equation-bearing*: +beyond the mesh/material/boundary directives it declares the physics fields, +the constitutive role of each tensor (``Psi`` = strain energy, ``S`` = PK2 +stress), and the weak-form residual. ``compile_latex`` parses those equation +declarations, threads them onto the artifact bundle as a ``latex_semantics`` +record, and emits Taichi from the LaTeX-derived ``ProblemIR``. + +Everything here goes through the single public facade ``mechdsl.compile_latex`` +— no internal constructors — so the example cannot drift from the supported API. + +Run with:: + + uv run python examples/run_compile_latex_equation.py + +See ``dev/reviews/fgram_closure_2026_05.md`` for the grammar-coverage map and +the list of remaining unsupported / deferred constructs. +""" + +from __future__ import annotations + +from mechdsl import compile_latex + +# Equation-bearing LaTeX: directive core PLUS field / constitutive-role / +# weak-form declarations. The compiler understands the equation roles, +# not just a material name. +EQUATION_SOURCE = r""" +% MechDSL headline example -- equation-bearing SVK Hex8 cantilever. +% mechanics dim 3 +% mechanics cell hex8 +% mechanics formulation total_lagrangian +% mechanics material svk --E 200e3 --nu 0.3 +% mechanics field u --type vector --space H1 --order 1 +% mechanics constitutive Psi --strain_energy +% mechanics constitutive S --pk2 +% mechanics weak_form internal_residual --residual +% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 +% mechanics boundary load --type neumann --traction "1 0 0" --surface x1 +""" + + +def main() -> None: + bundle = compile_latex(EQUATION_SOURCE, profile="mvp") + + print("compile_latex bundle summary (equation-bearing source):") + print(f" element_ir_summary: {bundle.element_ir_summary}") + print(f" content_hash: {bundle.content_hash()}") + + # The LaTeX-derived equation semantics ride on the bundle so emitted + # Taichi sections are traceable back to the source equation roles. + semantics = bundle.problem_ir_dict.get("latex_semantics") + assert semantics is not None, "equation-bearing source must attach latex_semantics" + roles = {entry["symbol"]: entry["role"] for entry in semantics["constitutive"]} + print(" LaTeX-derived equation semantics:") + print(f" fields: {semantics['fields']}") + print(f" constitutive: {roles}") + print(f" weak_form_label: {semantics['weak_form_label']}") + + # Proof of generated Taichi: a kernel-bearing module emitted from LaTeX. + assert "import taichi as ti" in bundle.emitted_source + assert "@ti.kernel" in bundle.emitted_source + n_lines = bundle.emitted_source.count("\n") + 1 + print(f" emitted Taichi: {n_lines} lines (contains @ti.kernel)") + + +if __name__ == "__main__": + main() diff --git a/examples/run_elastic_reference.py b/examples/run_elastic_reference.py new file mode 100644 index 0000000..a43557a --- /dev/null +++ b/examples/run_elastic_reference.py @@ -0,0 +1,92 @@ +"""Example: Run the elastic reference solver on a cantilever beam. + +This script demonstrates how to use the handwritten reference solver +(ref_hex8_elastic) to solve a 3D elastic cantilever beam with SVK +material and Hex8 elements. The reference solver is the ground truth +for verifying generated code. + +Usage: + uv run python examples/run_elastic_reference.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np + +# The reference solver lives in the test suite; add its parent to sys.path +# so that ``from tests.ref.ref_hex8_elastic import ...`` resolves correctly. +_TESTS_PARENT = str(Path(__file__).resolve().parents[2] / "packages" / "mechdsl-core") +if _TESTS_PARENT not in sys.path: + sys.path.insert(0, _TESTS_PARENT) + +from tests.ref.ref_hex8_elastic import generate_hex8_mesh, solve_elastic # noqa: E402 + +from mechdsl.symbolic.models.svk import SVKMaterial # noqa: E402 + + +def main() -> None: + # ---- Material parameters (steel-like) ---- + E = 200e3 # Young's modulus [MPa] + nu = 0.3 # Poisson's ratio + mat = SVKMaterial.from_E_nu(E, nu) + lam, mu = mat.lam, mat.mu + + # ---- Mesh: 4x2x2 cantilever beam ---- + nx, ny, nz = 4, 2, 2 + Lx, Ly, Lz = 10.0, 2.0, 2.0 + coords, conn = generate_hex8_mesh(nx, ny, nz, Lx, Ly, Lz) + n_nodes = coords.shape[0] + + print(f"Mesh: {nx}x{ny}x{nz} = {conn.shape[0]} elements, {n_nodes} nodes") + + # ---- Boundary conditions ---- + # Fix left face (x = 0): all components + bc_mask = np.zeros((n_nodes, 3), dtype=bool) + bc_values = np.zeros((n_nodes, 3), dtype=np.float64) + + left_nodes = np.where(np.abs(coords[:, 0]) < 1e-12)[0] + bc_mask[left_nodes, :] = True + + # External force: downward traction on right face (x = Lx) + f_ext = np.zeros((n_nodes, 3), dtype=np.float64) + right_nodes = np.where(np.abs(coords[:, 0] - Lx) < 1e-12)[0] + traction_per_node = -1000.0 / len(right_nodes) # total force / number of face nodes + f_ext[right_nodes, 2] = traction_per_node + + # ---- Solve ---- + print(f"Material: SVK (E={E}, nu={nu}, lam={lam:.1f}, mu={mu:.1f})") + print("Solving...") + u, residual_history = solve_elastic( + coords, + conn, + lam, + mu, + bc_mask, + bc_values, + f_ext, + tol=1e-8, + max_iter=50, + ) + + # ---- Report ---- + print(f"Newton converged in {len(residual_history)} iterations") + for i, r in enumerate(residual_history): + print(f" iter {i}: ||R|| = {r:.6e}") + + tip_disp = u[right_nodes, 2] + print(f"\nTip displacement (z): min={tip_disp.min():.6e}, max={tip_disp.max():.6e}") + print(f"Max displacement magnitude: {np.linalg.norm(u, axis=1).max():.6e}") + + # Verify fixed face + fixed_disp = np.linalg.norm(u[left_nodes], axis=1).max() + print(f"Fixed face max displacement: {fixed_disp:.2e} (should be 0)") + + return None + + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/examples/run_pipeline.py b/examples/run_pipeline.py new file mode 100644 index 0000000..209e5b6 --- /dev/null +++ b/examples/run_pipeline.py @@ -0,0 +1,155 @@ +"""Example: Run the MechDSL compilation pipeline (ProblemIR -> Taichi source). + +This script demonstrates the full compiler pipeline from a manually +constructed ProblemIR through lowering, einsum optimisation, and Taichi +code emission. This is the workflow that the LaTeX parser (Phase 2) will +automate once implemented. + +Usage: + uv run python examples/run_pipeline.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from mechdsl.codegen.artifact import ArtifactBundle +from mechdsl.codegen.taichi_printer import emit +from mechdsl.ir.mechanics_ir import ( + BCType, + BoundaryCondition, + ElementType, + Formulation, + MaterialSpec, + ProblemIR, +) +from mechdsl.lowering.fe_localise import localise_and_optimize + + +def build_elastic_problem() -> ProblemIR: + """Construct ProblemIR for a 3D SVK cantilever beam.""" + return ProblemIR( + dim=3, + formulation=Formulation.TOTAL_LAGRANGIAN, + element_type=ElementType.HEX8, + material=MaterialSpec( + model="svk", + params={"E": 200e3, "nu": 0.3}, + ), + boundaries=( + BoundaryCondition( + name="fix", + bc_type=BCType.DIRICHLET, + field_name="u", + components=(0, 1, 2), + value=0.0, + ), + BoundaryCondition( + name="load", + bc_type=BCType.NEUMANN, + field_name="u", + traction="0 0 -1000", + ), + ), + ) + + +def build_plastic_problem() -> ProblemIR: + """Construct ProblemIR for a 3D J2 necking bar.""" + return ProblemIR( + dim=3, + formulation=Formulation.TOTAL_LAGRANGIAN, + element_type=ElementType.HEX8, + material=MaterialSpec( + model="j2_power_law", + params={ + "E": 200e3, + "nu": 0.3, + "sigma_y0": 250.0, + "K": 1000.0, + "n": 1.0, + }, + ), + boundaries=( + BoundaryCondition( + name="fix", + bc_type=BCType.DIRICHLET, + field_name="u", + components=(0, 1, 2), + value=0.0, + ), + BoundaryCondition( + name="pull", + bc_type=BCType.DIRICHLET, + field_name="u", + components=(2,), + value=0.1, + ), + ), + ) + + +def run_pipeline(problem_ir: ProblemIR, label: str) -> str: + """Run the full compilation pipeline and return emitted source.""" + + # Step 1: Lower ProblemIR -> ElementIR + einsum specs + contraction plans + loc_result, plans = localise_and_optimize(problem_ir) + + print(f"[{label}] Localisation complete:") + print(f" Element type: {loc_result.element_ir.element_type}") + print(f" Quadrature points: {loc_result.element_ir.quadrature.n_points}") + print(f" Einsum specs: {len(loc_result.einsum_specs)}") + print(f" Contraction plans: {len(plans)}") + + for plan in plans: + print(f" {plan.einsum_string}: tier {plan.tier}, ~{plan.estimated_flops} flops") + + # Step 2: Build artifact bundle + bundle = ArtifactBundle.from_pipeline( + problem_ir=problem_ir, + localisation=loc_result, + contraction_plans=plans, + ) + + # Step 3: Emit Taichi source code + source = emit(bundle) + print(f" Emitted source: {len(source)} chars, {source.count(chr(10))} lines") + + return source + + +def main() -> None: + # --- Elastic problem --- + elastic_ir = build_elastic_problem() + elastic_source = run_pipeline(elastic_ir, "SVK Elastic") + + # --- Plastic problem --- + plastic_ir = build_plastic_problem() + plastic_source = run_pipeline(plastic_ir, "J2 Plastic") + + # --- Optionally write to files --- + out_dir = Path("examples/_output") + out_dir.mkdir(parents=True, exist_ok=True) + + elastic_path = out_dir / "cantilever_svk.py" + elastic_path.write_text(elastic_source, encoding="utf-8") + print(f"\nElastic source written to: {elastic_path}") + + plastic_path = out_dir / "necking_j2.py" + plastic_path.write_text(plastic_source, encoding="utf-8") + print(f"Plastic source written to: {plastic_path}") + + # --- Verify emitted source is valid Python --- + import ast + + ast.parse(elastic_source) + ast.parse(plastic_source) + print("\nBoth emitted sources parse as valid Python.") + + return None + + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/examples/svk_energy.tex b/examples/svk_energy.tex new file mode 100644 index 0000000..45c6925 --- /dev/null +++ b/examples/svk_energy.tex @@ -0,0 +1,18 @@ +% St. Venant-Kirchhoff strain-energy density, authored as LaTeX for the +% LaTeX-to-code constitutive pipeline (symbolic/energy.py). +% +% Psi = (lambda/2) (tr E)^2 + mu (E : E) +% +% Written with strict upper/lower Einstein contractions (nrpylatex +% requirement): the reference metric gDD raises indices so that +% tr E = E^I_I (mixed contraction via the metric) +% E : E = E^{IJ} E_{IJ} (one raised, one lowered) +% For the Cartesian MVP the metric is pinned to the identity downstream. +% +% \lambda is sanitised to \aleph before nrpylatex codegen (its bare name +% collides with the Python keyword `lambda`); see symbolic/energy.py. + +% declare metric gDD --dim 3 +% declare EDD --dim 3 +% declare \lambda \mu --const +\Psi = \frac{\lambda}{2} E^{I}_{I} E^{J}_{J} + \mu E^{I J} E_{I J} diff --git a/examples/svk_latex_math.tex b/examples/svk_latex_math.tex new file mode 100644 index 0000000..a5bb1ac --- /dev/null +++ b/examples/svk_latex_math.tex @@ -0,0 +1,35 @@ +% post_recovery_plan Phase 4 (P4-5) example. +% +% Demonstrates the nrpylatex math-grammar integration: % mechanics +% directives describe the FE problem (mesh, formulation, material, +% boundary), and a $...$ math block carries an indexed-tensor +% expression that the new frontend.math_parser routes through +% mechdsl.symbolic.bridge into a SymbolicNode map. +% +% Compile path: +% from pathlib import Path +% from mechdsl.frontend import parse_with_math +% src = Path("examples/svk_latex_math.tex").read_text() +% context = parse_with_math(src) +% # context["math"]["tensors"] now carries SymbolicNode entries. +% +% Note (deferral): the closed-form SVK first Piola-Kirchhoff stress +% requires \det F and \log J intrinsics that nrpylatex 1.4.0 does +% not register. The $...$ block below uses the rank-2 copy +% A^{i I} = F^{i I} as the import-chain surrogate; the bridge +% nonetheless preserves the spatial/material index distinction +% (07-CONVENTIONS) so downstream layers see a true two-point tensor. + +% mechanics dim 3 +% mechanics cell hex8 +% mechanics formulation total_lagrangian +% mechanics material svk --E 200e3 --nu 0.3 +% mechanics boundary Gamma_u --type dirichlet --value 0 +% mechanics boundary Gamma_t --type neumann --traction "t_bar" + +% nrpylatex declarations for the math block: +% declare FUU --dim 3 +% declare AUU --dim 3 + +% First-Piola-flavoured surrogate (see header note): +$A^{i I} = F^{i I}$ diff --git a/laws/plasticity/swift_voce.yaml b/laws/plasticity/swift_voce.yaml index d0d0774..01972c9 100644 --- a/laws/plasticity/swift_voce.yaml +++ b/laws/plasticity/swift_voce.yaml @@ -1,6 +1,6 @@ # Swift-Voce isotropic hardening law — authoritative MechDSL source. # -# MFront-mimic Cycle M0, Phase 4 (dev/plans/mfront_cycleM0.md lines 114-116). +# MechDSL lawgen example law spec (Swift-Voce isotropic hardening). # # This YAML is the single source of truth for the SwiftVoce hardening carrier. # ``mechdsl-lawgen compile laws/plasticity/swift_voce.yaml --target ticonstit diff --git a/packages/algo2code/prototypes/algo_parser.py b/packages/algo2code/prototypes/algo_parser.py index 9d1b9a8..c940a11 100644 --- a/packages/algo2code/prototypes/algo_parser.py +++ b/packages/algo2code/prototypes/algo_parser.py @@ -303,7 +303,6 @@ def _extract_brace_arg(self, text: str, command: str) -> str: def _parse_for(self, line: str) -> ForLoop: """Parse \\For{$k = 0, 1, \\ldots, N$} body \\EndFor""" arg = self._extract_brace_arg(line, '\\For') - # Remove $ delimiters if present arg = arg.strip('$ ') var, start, end_expr = self._parse_for_range(arg) @@ -449,7 +448,6 @@ def _split_top_level(self, text: str, sep: str) -> list[str]: def _parse_state(self, line: str, comment: str) -> Stmt | None: """Parse \\State $lhs = rhs$""" stripped = self._strip_comment(line) - # Remove \State prefix stripped = re.sub(r'^\\State\s*', '', stripped).strip() # Extract math content math = self._extract_math(stripped) diff --git a/packages/algo2code/prototypes/ast_nodes.py b/packages/algo2code/prototypes/ast_nodes.py index 1188df2..64817c0 100644 --- a/packages/algo2code/prototypes/ast_nodes.py +++ b/packages/algo2code/prototypes/ast_nodes.py @@ -148,6 +148,6 @@ class Algorithm: """A complete parsed algorithm.""" name: str = "" backend: str = "taichi" - args: list[tuple[str, VarType]] = field(default_factory=list) # (name, type) + args: list[tuple[str, VarType]] = field(default_factory=list) body: list[Stmt] = field(default_factory=list) type_annotations: dict[str, VarType] = field(default_factory=dict) # var_name -> type diff --git a/packages/algo2code/prototypes/taichi_codegen.py b/packages/algo2code/prototypes/taichi_codegen.py index aa069dd..bdc6af5 100644 --- a/packages/algo2code/prototypes/taichi_codegen.py +++ b/packages/algo2code/prototypes/taichi_codegen.py @@ -197,7 +197,6 @@ def emit(self) -> str: collector = KernelCollector() collector.scan(self.algo.body) - # Pre-scan for temp count self._pre_scan_temps(self.algo.body) parts = [] diff --git a/packages/algo2code/prototypes/test_algo2code.py b/packages/algo2code/prototypes/test_algo2code.py index 3943fb6..1621202 100644 --- a/packages/algo2code/prototypes/test_algo2code.py +++ b/packages/algo2code/prototypes/test_algo2code.py @@ -218,10 +218,10 @@ def test_body_structure(self): algo = parse_algorithm(PCG_LATEX) # Should have: 4 assignments, 1 for loop, 1 return assert len(algo.body) == 6 - assert isinstance(algo.body[0], Assign) # r = ... - assert isinstance(algo.body[1], Assign) # z = ... - assert isinstance(algo.body[2], Assign) # p = ... - assert isinstance(algo.body[3], Assign) # rho = ... + assert isinstance(algo.body[0], Assign) + assert isinstance(algo.body[1], Assign) + assert isinstance(algo.body[2], Assign) + assert isinstance(algo.body[3], Assign) assert isinstance(algo.body[4], ForLoop) assert isinstance(algo.body[5], Return) @@ -268,7 +268,6 @@ def test_matvec(self): infer_types(algo) # r = b - A·x : the A·x part should be matvec r_assign = algo.body[0] - # RHS is b - A·x rhs = r_assign.value assert rhs.inferred_type == VarType.VECTOR diff --git a/packages/algo2code/pyproject.toml b/packages/algo2code/pyproject.toml index acbf801..2597d6d 100644 --- a/packages/algo2code/pyproject.toml +++ b/packages/algo2code/pyproject.toml @@ -30,7 +30,7 @@ exclude-newer = "2026-02-20T00:00:00Z" dependencies = [] [project.urls] -Repository = "https://github.com/SOSOVSKI/MechDSL" +Repository = "https://github.com/CEmM2/MechDSL" [tool.hatch.build.targets.wheel] packages = ["src/algo2code"] diff --git a/packages/algo2code/src/algo2code/ast_nodes.py b/packages/algo2code/src/algo2code/ast_nodes.py index 169b4dd..f912612 100644 --- a/packages/algo2code/src/algo2code/ast_nodes.py +++ b/packages/algo2code/src/algo2code/ast_nodes.py @@ -166,6 +166,6 @@ class Algorithm: name: str = "" backend: str = "taichi" - args: list[tuple[str, VarType]] = field(default_factory=list) # (name, type) + args: list[tuple[str, VarType]] = field(default_factory=list) body: list[Stmt] = field(default_factory=list) type_annotations: dict[str, VarType] = field(default_factory=dict) # var_name -> type diff --git a/packages/algo2code/src/algo2code/backends/taichi_codegen.py b/packages/algo2code/src/algo2code/backends/taichi_codegen.py index 5a1f660..9b0be82 100644 --- a/packages/algo2code/src/algo2code/backends/taichi_codegen.py +++ b/packages/algo2code/src/algo2code/backends/taichi_codegen.py @@ -285,7 +285,6 @@ def emit(self) -> str: collector = KernelCollector() collector.scan(self.algo.body) - # Pre-scan for temp count self._pre_scan_temps(self.algo.body) if self.runtime == RUNTIME_TI_RUNTIME: @@ -359,10 +358,8 @@ def _emit_driver(self) -> str: 'argument is available to determine n.")' ) else: - # post_recovery_plan Phase 5 codegen fix: previously - # emitted ``n = b.shape[0]`` unconditionally, which - # broke scalar/matrix-only algorithms with no vector - # argument. Keep a harmless marker only when no + # Emitting ``n = b.shape[0]`` unconditionally would break scalar/matrix-only + # algorithms with no vector argument; keep a harmless marker only when no # vector-sized allocation depends on n. self._write("n = 0 # no vector arg present; scalar/matrix-only algorithm") @@ -552,9 +549,9 @@ def _emit_binop(self, expr: BinOp, target_var: str | None) -> str | None: return self._emit_dot(expr) # All vector-valued binary ops (matvec, scale, +, -) lower through the # SSA pass: every emitted op is a single kernel call writing a field, - # so arbitrarily nested RHS like `r + beta*(p - omega*v)` is faithful - # (issue #307 F1). When a target field is given the result is written - # there and None is returned; otherwise a fresh temp field name is. + # so arbitrarily nested RHS like `r + beta*(p - omega*v)` is faithful. + # When a target field is given the result is written there and None is + # returned; otherwise a fresh temp field name is. if expr.inferred_type == VarType.VECTOR and expr.op in ( "matvec", "scale", @@ -592,12 +589,12 @@ def _emit_dot(self, expr: BinOp) -> str: return f"_v.dot({self._emit_expr(left)}, {self._emit_expr(expr.right)})" return f"_dot({self._emit_expr(left)}, {self._emit_expr(expr.right)})" - # ── SSA vector lowering (issue #307 F1) ────────────────────────────── + # ── SSA vector lowering ────────────────────────────────────────────── # # Any vector-valued expression is lowered to a chain of single-kernel-call # operations, each writing a fresh temporary field, with the final op - # writing the destination. This replaces the old one-level decomposition - # that emitted invalid ``ti.field`` Python arithmetic for nested RHS such as + # writing the destination. One-level decomposition would emit invalid + # ``ti.field`` Python arithmetic for nested RHS such as # ``r + beta*(p - omega*v)``. ``scalar * vector`` factors fuse into the # parent axpy coefficient, so the common solver updates stay a single # ``_vec_add`` and only genuinely nested sub-expressions cost a temp. @@ -781,10 +778,9 @@ def _emit_func_call_name(self, expr: FuncCall) -> str: def _emit_for(self, stmt: ForLoop): # Explicit terminal `\ldots, N` is inclusive (k = 1, 2, ..., N), so the - # Python range upper bound is N + 1 — without the +1 the loop ran one - # fewer iteration than written (issue #307 for-loop off-by-one). An - # open-ended `0, 1, 2, ...` uses `maxiter` as a safety cap (exactly - # `maxiter` iterations), not an inclusive terminal. + # Python range upper bound is N + 1 — without the +1 the loop would run one + # fewer iteration than written. An open-ended `0, 1, 2, ...` uses `maxiter` + # as a safety cap (exactly `maxiter` iterations), not an inclusive terminal. end = f"{_sanitize(stmt.end_expr)} + 1" if stmt.end_expr else "maxiter" self._write(f"for {stmt.var} in range({stmt.start}, {end}):") self._indent += 1 diff --git a/packages/algo2code/src/algo2code/expr_parser.py b/packages/algo2code/src/algo2code/expr_parser.py index 5dff0f3..718e44a 100644 --- a/packages/algo2code/src/algo2code/expr_parser.py +++ b/packages/algo2code/src/algo2code/expr_parser.py @@ -67,9 +67,6 @@ # algorithm scratch identifiers like ``pq``, ``sn``, ``rho_new`` can # tokenise as a single Var instead of an implicit product. Single # letters still tokenise to LETTER for back-compat (``x``, ``a``). - # post_recovery_plan Phase 5: parser fix landed alongside the - # algo2code radial-return substitution so ``algo2code.transpile`` - # can consume the algpseudocode without manual rewriting. (r"[a-zA-Z][a-zA-Z0-9]*", "LETTER"), # Whitespace (r"\s+", "WS"), @@ -361,8 +358,8 @@ def parse_superscript(self, base: Expr) -> Expr: self.advance() return UnaryOp(op="transpose", operand=base) - # Bare ^T transpose alias (issue #307 F7): a lone uppercase T after ^ - # means transpose in linear-algebra notation, same as ^\top and ^{T}. + # Bare ^T transpose alias: a lone uppercase T after ^ means transpose + # in linear-algebra notation, same as ^\top and ^{T}. tok = self.peek() if tok is not None and tok.kind == "LETTER" and tok.value == "T": self.advance() @@ -397,7 +394,7 @@ def parse_base(self) -> Expr: # A norm may carry an order subscript: ||r||_2, ||r||_1, ||r||_\infty. # The generated _norm kernel computes the Euclidean (2-)norm, so the # default and ``_2`` are fine; any other order must fail loud rather - # than silently compute the wrong norm (F6). + # than silently compute the wrong norm. if self.at("UNDERSCORE"): self.advance() order = self._parse_subscript_text() @@ -521,7 +518,7 @@ def parse_latex_expr(latex: str) -> Expr: raise SyntaxError(f"Empty expression: {latex!r}") parser = ExprParser(tokens) result = parser.parse() - # Fail-loud (F6): the parser must consume the whole expression. A leftover + # Fail-loud: the parser must consume the whole expression. A leftover # token means a prefix parsed and the rest was silently dropped (e.g. an # unhandled operator). Surface it instead of emitting a truncated AST. if parser.pos != len(tokens): diff --git a/packages/algo2code/src/algo2code/library/pcg.py b/packages/algo2code/src/algo2code/library/pcg.py index ee0d456..9bb4eb8 100644 --- a/packages/algo2code/src/algo2code/library/pcg.py +++ b/packages/algo2code/src/algo2code/library/pcg.py @@ -54,9 +54,6 @@ from __future__ import annotations # ── Canonical algorithm source ─────────────────────────────────────────────── -# Verbatim copy of `pcg_algorithm_latex.latex` in -# dev/tasks/recovery_plan_latex_contract/json/P6-1.json. Do NOT edit without -# updating the task JSON in the same commit. PCG_ALGORITHM_LATEX: str = r"""% algorithm pcg % backend taichi % args A:matrix, b:vector, x:vector, apply_M_inv:callable, tol:scalar, maxiter:scalar diff --git a/packages/algo2code/tests/plan_tests/test_p2_1.py b/packages/algo2code/tests/plan_tests/test_p2_1.py deleted file mode 100644 index 382b709..0000000 --- a/packages/algo2code/tests/plan_tests/test_p2_1.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Tests for Task P2-1 (PlanJune14 Phase 2). - -emit `from ti_runtime import …` + calls to shared primitives (import-mode flag). - -Acceptance criteria covered: - AC-1 runtime-mode preamble: generated source starts with `from ti_runtime import …` - AC-2 runtime-mode has no inlined private dot/norm/copy kernel defs - AC-3 inline-mode (default) output is byte-for-byte unchanged (backward compat) - AC-4 algo2code the compiler imports only stdlib — ti_runtime never pulled in - -Design notes (evidence from empirical check — see module docstring in taichi_codegen.py): - - `ti_runtime.vector_ops.dot` uses `.dot()` which requires `ti.Vector.field`; - algo2code emits `ti.field(ti.f64, shape=n)` scalar fields → the `_v.dot`/ - `_v.norm2` calls in runtime mode will only work correctly when the consumer - passes vector fields (e.g. mechdsl FEM drivers). This is documented as a - known field-model mismatch, out of scope for P2-1. - - In runtime mode `_dot`/`_norm`/`_copy`/`_vec_add` are all routed to ti_runtime - primitives (`_vec_add` → the `vec_add` AXPBY primitive). Only `_matvec` (the - dense matrix-vector product) has no ti_runtime equivalent and stays inlined. -""" - -import sys -import types - -import pytest - -# ── Fixtures ───────────────────────────────────────────────────────────────── - -# A minimal algorithm that uses dot, norm, and copy — exercises all three -# routed primitives in a self-contained snippet. -_MINI_DOT_NORM_COPY = r""" -% algorithm mini -% backend taichi -% args x:vector, y:vector, z:vector - -% type x vector -% type y vector -% type z vector -% type alpha scalar - -\begin{algorithmic} -\State $\alpha = x^\top y$ -\State $z = x$ -\If{$\|z\| < 1.0$} - \Return $\alpha$ -\EndIf -\Return $\alpha$ -\end{algorithmic} -""" - -# A matrix-free PCG (callable operator A) — the valid shape for runtime mode, -# which requires a callable operator + a vector arg (see _check_runtime_mode_supported). -_PCG_LATEX = r""" -% algorithm pcg -% backend taichi -% args A:callable, b:vector, x:vector, M_inv:callable, tol:scalar, maxiter:scalar - -% type r vector -% type z vector -% type p vector -% type q vector -% type rho scalar -% type alpha scalar -% type beta scalar - -\begin{algorithmic} -\State $r = b - A \cdot x$ % vector -\State $z = M^{-1}(r)$ % vector -\State $p = z$ % vector -\State $\rho = r^\top z$ % scalar -\For{$k = 0, 1, \ldots, \text{maxiter}$} - \State $q = A \cdot p$ % vector - \State $\alpha = \frac{\rho}{p^\top q}$ % scalar - \State $x = x + \alpha \, p$ % vector - \State $r = r - \alpha \, q$ % vector - \If{$\|r\| < \text{tol}$} - \Return $x, k$ - \EndIf - \State $z = M^{-1}(r)$ % vector - \State $\rho_{\text{new}} = r^\top z$ % scalar - \State $\beta = \frac{\rho_{\text{new}}}{\rho}$ - \State $p = z + \beta \, p$ % vector - \State $\rho = \rho_{\text{new}}$ -\EndFor -\Return $x, \text{maxiter}$ -\end{algorithmic} -""" - - -# ── Tests ───────────────────────────────────────────────────────────────────── - - -class TestTaskP2_1: - """Tests for Task P2-1: algo2code ti_runtime import-mode. AC covered: 1-4.""" - - @pytest.mark.unit - def test_runtime_mode_emits_ti_runtime_import(self): - """AC-1: runtime-mode preamble contains `from ti_runtime import vector_ops as _v`.""" - from algo2code import transpile - - code = transpile(_PCG_LATEX, backend="taichi", runtime="ti_runtime") - assert "from ti_runtime import vector_ops as _v" in code, ( - "runtime-mode must emit `from ti_runtime import vector_ops as _v`; got:\n" + code[:500] - ) - - @pytest.mark.unit - def test_runtime_mode_has_no_inlined_vector_kernels(self): - """AC-2: runtime-mode generated source contains no private inlined dot/norm/copy/vec_add defs. - - _matvec is excluded from this check because ti_runtime has no equivalent - combiner — it remains inlined until P2-2 adds the matrix-free operator seam. - """ - from algo2code import transpile - - code = transpile(_MINI_DOT_NORM_COPY, backend="taichi", runtime="ti_runtime") - - # Must NOT have inlined private kernel definitions for the routed ops - assert "def _dot(" not in code, "runtime-mode must not inline _dot kernel" - assert "def _norm(" not in code, "runtime-mode must not inline _norm kernel" - assert "def _copy(" not in code, "runtime-mode must not inline _copy kernel" - assert "def _vec_add(" not in code, "runtime-mode must not inline _vec_add kernel" - - # Must CALL through ti_runtime instead - assert "_v.dot(" in code or "_v.norm2(" in code or "_v.copy(" in code, ( - "runtime-mode must call at least one ti_runtime primitive (_v.dot / " - "_v.norm2 / _v.copy); got:\n" + code - ) - - @pytest.mark.unit - def test_inline_mode_output_unchanged(self, pcg_latex): - """AC-3: inline-mode (default) golden is unchanged — backward compatibility. - - Verify that: - (a) default mode == explicit inline mode - (b) default mode still has the private @ti.kernel defs - (c) default mode does NOT have the ti_runtime import - """ - from algo2code import transpile - - default_code = transpile(pcg_latex, backend="taichi") - explicit_inline = transpile(pcg_latex, backend="taichi", runtime="inline") - - assert default_code == explicit_inline, ( - "default runtime must produce identical output to runtime='inline'" - ) - - # Spot-check that key inline-mode signatures are still present - assert "def _dot(" in default_code, "inline-mode must still define _dot kernel" - assert "@ti.kernel" in default_code, "inline-mode must still have @ti.kernel defs" - assert "from ti_runtime" not in default_code, ( - "inline-mode must NOT emit a ti_runtime import" - ) - - @pytest.mark.unit - def test_algo2code_import_stays_stdlib_only(self): - """AC-4: importing algo2code pulls in no ti_runtime, taichi, or mechdsl modules. - - algo2code-the-compiler is stdlib-only. It *emits* a ti_runtime import - line but never executes it. - """ - # Collect module names before import - before = set(sys.modules.keys()) - - # Fresh import via a sub-interpreter snapshot is not easy in pytest, so - # instead we assert that none of the forbidden packages were pulled in - # *by* the algo2code import itself. We check for their absence in the - # module graph anchored at `algo2code`. - import algo2code # noqa: F401 — side effect: registers submodules - - after = set(sys.modules.keys()) - new_modules = after - before - - forbidden = {"taichi", "ti_runtime", "mechdsl"} - pulled_in = {m.split(".")[0] for m in new_modules} & forbidden - assert not pulled_in, ( - f"algo2code must not import {forbidden} at compile time; found: {pulled_in}" - ) - - # Also verify that the algo2code package object itself has no reference - # to ti_runtime or taichi in its module dict (catches accidental - # top-level imports in __init__.py). - algo2code_submodules = { - name for name in sys.modules if name == "algo2code" or name.startswith("algo2code.") - } - for mod_name in algo2code_submodules: - mod = sys.modules[mod_name] - if not isinstance(mod, types.ModuleType): - continue - for attr_name in vars(mod): - attr = getattr(mod, attr_name, None) - if isinstance(attr, types.ModuleType): - top = attr.__name__.split(".")[0] - assert top not in forbidden, ( - f"algo2code.{mod_name} has a reference to forbidden module " - f"{attr.__name__!r} via attribute {attr_name!r}" - ) diff --git a/packages/algo2code/tests/plan_tests/test_p2_2.py b/packages/algo2code/tests/plan_tests/test_p2_2.py deleted file mode 100644 index ed74e4d..0000000 --- a/packages/algo2code/tests/plan_tests/test_p2_2.py +++ /dev/null @@ -1,318 +0,0 @@ -r"""Tests for Task P2-2 (PlanJune14 Phase 2). - -`% type A callable` → matrix-free operator seam (11-ALGO2CODE §8.3): an operator -`A` declared ``callable`` lowers ``A · p`` to an in-place operator call -``A(out, p)`` (the ti_runtime ``apply_A(out, x)`` contract) instead of a dense -``_matvec`` over a stored matrix field. - -Acceptance criteria covered: - AC-1 ``A:callable`` / ``% type A callable`` infers A as ``VarType.CALLABLE`` - and types ``A · p`` as a (matvec) VECTOR result, not a scalar multiply. - AC-2 ``A · p`` lowers to ``A(out, p)`` and emits NO dense ``_matvec(A, …)`` - (and no ``def _matvec`` kernel) in either runtime mode. - AC-3 Numeric parity — a generated matrix-free PCG (callable A, ti_runtime - primitives) solves an injected SPD system to the same answer as - ``numpy.linalg.solve`` / the PJ-1 spike ``pcg`` body (tol < 1e-9). - AC-4 Issue #307 suite stays green — the matrix-``A`` codegen path is byte - stable (verified here; the full suites run in CI / Gate C). - -Convention notes (mirrors the ti_runtime seam + the existing generated driver): - - Operator: the seam contract is ``apply_A(out, x)`` — out FIRST. The generated - callable matvec emits ``A(out, p)`` accordingly. - - Preconditioner: the existing generated driver applies ``M_inv(in, out)`` — - out LAST (the established ``M^{-1}(r)`` callable convention, matching - ``test_pcg_transpiler_parity.py``). The parity test wires both accordingly. -""" - -# NOTE: no ``from __future__ import annotations`` — the parity test defines a -# nested ``@ti.kernel`` whose ``ti.template()`` annotations Taichi must evaluate -# eagerly. PEP 563 would stringify them and break the JIT (the PJ-0/PJ-1 -# finding; the spike module omits the future-import for the same reason). - -import importlib.util -import sys -import tempfile -from pathlib import Path - -import pytest - -# Canonical PCG box with a *callable* operator A (matrix-free seam). Identical to -# the matrix-A conftest PCG except for ``A:callable`` in the % args line. -_CALLABLE_PCG_LATEX = r""" -% algorithm pcg -% backend taichi -% args A:callable, b:vector, x:vector, M_inv:callable, tol:scalar, maxiter:scalar - -% type r vector -% type z vector -% type p vector -% type q vector -% type rho scalar -% type alpha scalar -% type beta scalar - -\begin{algorithmic} -\State $r = b - A \cdot x$ % vector -\State $z = M^{-1}(r)$ % vector -\State $p = z$ % vector -\State $\rho = r^\top z$ % scalar -\For{$k = 0, 1, \ldots, \text{maxiter}$} - \State $q = A \cdot p$ % vector - \State $\alpha = \frac{\rho}{p^\top q}$ % scalar - \State $x = x + \alpha \, p$ % vector - \State $r = r - \alpha \, q$ % vector - \If{$\|r\| < \text{tol}$} - \Return $x, k$ - \EndIf - \State $z = M^{-1}(r)$ % vector - \State $\rho_{\text{new}} = r^\top z$ % scalar - \State $\beta = \frac{\rho_{\text{new}}}{\rho}$ - \State $p = z + \beta \, p$ % vector - \State $\rho = \rho_{\text{new}}$ -\EndFor -\Return $x, \text{maxiter}$ -\end{algorithmic} -""" - -# A minimal single-statement box: declare A callable via `% type` (not % args) -# and apply it once. Exercises the `% type A callable` directive path directly. -_TYPE_DIRECTIVE_CALLABLE = r""" -% algorithm apply_op -% backend taichi -% args b:vector, x:vector - -% type A callable -% type q vector - -\begin{algorithmic} -\State $q = A \cdot x$ % vector -\Return $q$ -\end{algorithmic} -""" - - -def _import_generated(source: str, name: str): - """Write generated Taichi source to a real ``.py`` file and import it. - - Taichi needs ``inspect.getsource`` on the driver, so the code must live in a - real module file (not ``exec``'d) — the - ``mechdsl-core/tests/_e2e_helpers._import_generated_module`` pattern. - """ - tmp = Path(tempfile.mkdtemp()) - path = tmp / f"{name}.py" - path.write_text(source, encoding="utf-8") - spec = importlib.util.spec_from_file_location(name, path) - assert spec is not None and spec.loader is not None - mod = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = mod - spec.loader.exec_module(mod) - return mod - - -class TestTaskP2_2: - """Tests for Task P2-2: matrix-free operator seam. AC covered: 1-3 (+ #307 regression).""" - - @pytest.mark.unit - def test_type_a_callable_parses_to_callable(self): - """AC-1: `% type A callable` / `A:callable` → CALLABLE, and `A · p` types VECTOR. - - Both directive forms must put A into the type system as CALLABLE, and - type inference must resolve ``A · p`` to a (matvec) VECTOR result — not - fall through to a scalar multiply. - """ - from algo2code.algo_parser import parse_algorithm - from algo2code.ast_nodes import Assign, BinOp, Var, VarType - from algo2code.type_inference import infer_types - - # Form (a): `% args A:callable` - algo_args = parse_algorithm(_CALLABLE_PCG_LATEX) - assert ("A", VarType.CALLABLE) in algo_args.args, ( - f"`A:callable` in % args must parse to CALLABLE; got {algo_args.args}" - ) - - # Form (b): `% type A callable` - algo_type = parse_algorithm(_TYPE_DIRECTIVE_CALLABLE) - assert algo_type.type_annotations.get("A") == VarType.CALLABLE, ( - "`% type A callable` must register A as CALLABLE; got " - f"{algo_type.type_annotations.get('A')!r}" - ) - - # Inference: `A · x` must become a matvec producing a VECTOR. - infer_types(algo_type) - assign = algo_type.body[0] - assert isinstance(assign, Assign) - value = assign.value - assert isinstance(value, BinOp) - assert value.op == "matvec", ( - f"callable A applied to a vector must resolve to op='matvec'; got {value.op!r}" - ) - assert value.inferred_type == VarType.VECTOR, ( - f"`A · x` (callable A) must be VECTOR-typed; got {value.inferred_type}" - ) - assert isinstance(value.left, Var) and value.left.inferred_type == VarType.CALLABLE - - @pytest.mark.unit - def test_matvec_lowers_to_callable_apply(self): - """AC-2: `A · p` → in-place `A(out, p)`; NO dense `_matvec(A, …)` or kernel def. - - Checked in both runtime modes. The matrix-free callable operator must - never request or call the dense ``_matvec`` template. - """ - from algo2code import transpile - - for runtime in ("inline", "ti_runtime"): - code = transpile(_CALLABLE_PCG_LATEX, backend="taichi", runtime=runtime) - - assert "def _matvec(" not in code, ( - f"[{runtime}] callable-A PCG must NOT define the dense _matvec kernel:\n{code}" - ) - assert "_matvec(" not in code, ( - f"[{runtime}] callable-A PCG must NOT call _matvec:\n{code}" - ) - # Both `A · x` (in residual) and `A · p` (in the loop) lower to - # in-place operator calls `A(out, …)`. - assert "A(q, p)" in code, ( - f"[{runtime}] `q = A · p` must lower to in-place `A(q, p)`:\n{code}" - ) - a_calls = [ln.strip() for ln in code.splitlines() if ln.strip().startswith("A(")] - assert len(a_calls) == 2, ( - f"[{runtime}] expected two in-place A(out, x) operator calls; got {a_calls}" - ) - - @pytest.mark.unit - def test_matrix_mode_a_unchanged(self): - """AC-4 (structural): matrix-`A` PCG still emits the dense _matvec — backward compatible.""" - from algo2code import transpile - - matrix_pcg = _CALLABLE_PCG_LATEX.replace("A:callable", "A:matrix") - code = transpile(matrix_pcg, backend="taichi", runtime="inline") - assert "def _matvec(" in code, "matrix-A inline PCG must still define _matvec" - assert "_matvec(" in code, "matrix-A inline PCG must still call _matvec" - - @pytest.mark.unit - def test_matrix_operator_rejected_in_runtime_mode(self): - """Guard: ``runtime='ti_runtime'`` with a matrix (non-callable) operator fails loud. - - A matrix-typed ``A`` lowers to a dense scalar-indexed ``_matvec``, - incompatible with the ``ti.Vector.field`` layout and ti_runtime - ``dot``/``norm2`` reductions runtime mode uses — so the backend must raise - ``UnsupportedConstructError`` rather than silently emit non-runnable code. - Inline mode (the legacy dense path) is unaffected. - """ - from algo2code import UnsupportedConstructError, transpile - - matrix_pcg = _CALLABLE_PCG_LATEX.replace("A:callable", "A:matrix") - # inline mode is fine — the dense matvec is the supported legacy path... - transpile(matrix_pcg, backend="taichi", runtime="inline") - # ...but runtime mode must reject the matrix operator loudly. - with pytest.raises(UnsupportedConstructError, match="callable operator"): - transpile(matrix_pcg, backend="taichi", runtime="ti_runtime") - - @pytest.mark.slow - @pytest.mark.integration - def test_generated_matrix_free_pcg_parity(self): - """AC-3: generated matrix-free PCG solves an injected SPD system to spike/NumPy parity. - - Transpile the callable-A PCG in ``ti_runtime`` mode, write it to a real - ``.py`` file, import it under real Taichi, and drive it with - ``ti.Vector.field`` DOF vectors and an injected block-SPD operator (the - ``test_seams.py`` operator). Assert the generated solver's answer matches - ``numpy.linalg.solve`` and the PJ-1 spike ``pcg`` to < 1e-9. - """ - ti = pytest.importorskip("taichi") - import numpy as np - - ti.init(arch=ti.cpu, default_fp=ti.f64) - - from ti_runtime import vector_ops as vops - from ti_runtime.seams import IdentityPreconditioner, LinearSolveContext - - # Block-diagonal SPD operator (mirrors ti-runtime/tests/test_seams.py): - # out[i] = M @ x[i] with a fixed SPD 3x3 M, over ti.Vector.field DOFs. - m_np = np.array([[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]]) - - @ti.kernel - def apply_M(out: ti.template(), x: ti.template()): - mat = ti.Matrix([[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]], dt=ti.f64) - for i in out: - out[i] = mat @ x[i] - - # ── Generate + import the matrix-free PCG (ti_runtime mode) ────────── - from algo2code import transpile - - code = transpile(_CALLABLE_PCG_LATEX, backend="taichi", runtime="ti_runtime") - code = code.replace("arch=ti.gpu", "arch=ti.cpu") - # Drop the generated module-level ti.init — Taichi is already initialised - # by this test, and re-init would drop the fields allocated below. - code = "\n".join(ln for ln in code.splitlines() if not ln.startswith("ti.init")) - gen = _import_generated(code, "gen_pcg_p2_2") - - # ── Build the SPD system over vector fields ────────────────────────── - rng = np.random.default_rng(7) - n = 5 - b_np = rng.standard_normal((n, 3)) - - def _vfield(vals: np.ndarray): - vals = np.ascontiguousarray(vals, dtype=np.float64) - f = ti.Vector.field(vals.shape[1], ti.f64, shape=vals.shape[0]) - f.from_numpy(vals) - return f - - b = _vfield(b_np) - x = ti.Vector.field(3, ti.f64, shape=n) # zero initial guess - - # ── Operator seam: A(out, x) ⇒ apply_A; preconditioner M_inv(in, out) ─ - ctx = LinearSolveContext().set_operator(apply_M) - ctx.set_preconditioner(IdentityPreconditioner()) - - def operator_A(out, vec): - ctx.apply_A(out, vec) # apply_A(out, x): out FIRST (seam contract) - - def precond_M_inv(r_in, z_out): - ctx.apply_preconditioner(z_out, r_in) # M^{-1}(r) → z, out LAST in the call - - x_out, _k = gen.pcg(operator_A, b, x, precond_M_inv, 1e-12, 200) - x_gen = x_out.to_numpy() - - # ── Oracles: per-node M^{-1} b, and the PJ-1 spike pcg body ────────── - expected = np.linalg.solve(m_np, b_np.T).T - np.testing.assert_allclose(x_gen, expected, atol=1e-9, rtol=0) - - # Spike-pcg parity: drive the hand-written PJ-1 PCG body over the SAME - # injected operator and fields; the generated solver must match it. - spike = _import_spike_pcg() - x_spike_field = ti.Vector.field(3, ti.f64, shape=n) - ws = spike._PCGWorkspace.alloc(n) - spike_ctx = LinearSolveContext().set_operator(apply_M) - spike_ctx.set_preconditioner(IdentityPreconditioner()) - spike.pcg(spike_ctx, ws, b, x_spike_field, 1e-12, 200) - np.testing.assert_allclose(x_gen, x_spike_field.to_numpy(), atol=1e-9, rtol=0) - - # Residual sanity: ||A x - b|| ≈ 0 on device (no NumPy in the solve path). - ax = ti.Vector.field(3, ti.f64, shape=n) - apply_M(ax, x_out) - vops.axpy(ax, -1.0, b) - assert vops.norm2(ax) < 1e-9 - - -def _import_spike_pcg(): - """Import the PJ-1 spike module (the hand-written PCG parity oracle). - - The spike lives in ``mechdsl-core/tests/spike/svk_hex8_taichi.py`` and - imports ``numpy``/``taichi`` eagerly — fine here, since the parity test - already requires Taichi. Loaded by absolute file path so the cross-package - location is robust to the test's working directory. - """ - spike_path = ( - Path(__file__).resolve().parents[3] - / "mechdsl-core" - / "tests" - / "spike" - / "svk_hex8_taichi.py" - ) - spec = importlib.util.spec_from_file_location("svk_hex8_taichi_p2_2", spike_path) - assert spec is not None and spec.loader is not None - mod = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = mod - spec.loader.exec_module(mod) - return mod diff --git a/packages/algo2code/tests/test_algo_parser.py b/packages/algo2code/tests/test_algo_parser.py index bc91b76..55229e3 100644 --- a/packages/algo2code/tests/test_algo_parser.py +++ b/packages/algo2code/tests/test_algo_parser.py @@ -29,10 +29,10 @@ def test_body_structure(self, pcg_latex): algo = parse_algorithm(pcg_latex) # Should have: 4 assignments, 1 for loop, 1 return assert len(algo.body) == 6 - assert isinstance(algo.body[0], Assign) # r = ... - assert isinstance(algo.body[1], Assign) # z = ... - assert isinstance(algo.body[2], Assign) # p = ... - assert isinstance(algo.body[3], Assign) # rho = ... + assert isinstance(algo.body[0], Assign) + assert isinstance(algo.body[1], Assign) + assert isinstance(algo.body[2], Assign) + assert isinstance(algo.body[3], Assign) assert isinstance(algo.body[4], ForLoop) assert isinstance(algo.body[5], Return) diff --git a/packages/algo2code/tests/test_radial_return_codegen.py b/packages/algo2code/tests/test_radial_return_codegen.py index f001fd0..b669b62 100644 --- a/packages/algo2code/tests/test_radial_return_codegen.py +++ b/packages/algo2code/tests/test_radial_return_codegen.py @@ -46,9 +46,7 @@ def test_radial_return_j2_parses() -> None: assert algo.name == "radial_return_j2" assert algo.backend == "taichi" arg_names = [name for name, _ in algo.args] - # Must declare the full power-law hardening argument set: scalar - # Newton inner loop signature is - # (sigma_eq, alpha, mu, K, n, sigy0, tol, max_iter). + # The scalar Newton inner loop must declare the full power-law hardening argument set. for required in ("sigma_eq", "alpha", "mu", "K", "n", "sigy0", "tol", "max_iter"): assert required in arg_names, ( f"radial_return_j2 args must include {required!r}; got {arg_names}" diff --git a/packages/mechdsl-core/pyproject.toml b/packages/mechdsl-core/pyproject.toml index 26b58a2..411bc3d 100644 --- a/packages/mechdsl-core/pyproject.toml +++ b/packages/mechdsl-core/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "opt-einsum>=3.3", "pyyaml>=6.0.3", "scipy>=1.17.0", - "nrpylatex @ git+https://github.com/SOSOVSKI/nrpylatex", + "nrpylatex>=1.4.0,<2", ] [project.optional-dependencies] @@ -61,7 +61,7 @@ ti-runtime = { workspace = true } algo2code = { workspace = true } [project.urls] -Repository = "https://github.com/SOSOVSKI/MechDSL" +Repository = "https://github.com/CEmM2/MechDSL" [tool.hatch.metadata] allow-direct-references = true diff --git a/packages/mechdsl-core/src/mechdsl/__init__.py b/packages/mechdsl-core/src/mechdsl/__init__.py index 3385753..81d8579 100644 --- a/packages/mechdsl-core/src/mechdsl/__init__.py +++ b/packages/mechdsl-core/src/mechdsl/__init__.py @@ -216,11 +216,11 @@ def compile_latex( ) from mechdsl.ir.mechanics_ir import BCType, ProblemIR - # fgram P6-1: build the IR through the LaTeX-semantic constructor so - # the emitted bundle carries the source-role metadata (latex_semantics) - # that links generated code sections back to source equation roles. The - # MVP-stable core is identical to from_context, so emitted Taichi is - # unchanged; only the additive latex_semantics record is new. + # Build the IR through the LaTeX-semantic constructor so the emitted + # bundle carries the source-role metadata (latex_semantics) that links + # generated code sections back to source equation roles. The MVP-stable + # core is identical to from_context, so emitted Taichi is unchanged; + # only the additive latex_semantics record is new. problem_ir = ProblemIR.from_latex_semantics(ctx) # When a strain-energy block was supplied, derive its symbolic stress and # attach it to the IR. The codegen layer then emits the constitutive law and @@ -233,18 +233,18 @@ def compile_latex( problem_ir = dataclasses.replace( problem_ir, derived_energy=_derive_constitutive_energy(energy_source) ) - # P3-4 / P3-5: enforce the MVP-stable contract at the canonical - # compile-path boundary so users hitting an experimental combination - # or a missing required-param see a clean IR-level rejection instead - # of a deep codegen / runtime failure. + # Enforce the MVP-stable contract at the canonical compile-path + # boundary so users hitting an experimental combination or a missing + # required-param see a clean IR-level rejection instead of a deep + # codegen / runtime failure. problem_ir.assert_mvp_stable() bundle = compile(problem_ir) - # post_recovery_plan P1-5: surface a Neumann ``f_ext`` initialisation - # kernel for every Neumann BC carrying numeric traction. Symbolic - # (string) traction stays handled by the legacy imported numeric - # injection path so existing callers keep working unchanged. Pure - # Dirichlet problems leave ``f_ext_kernel`` at ``None``. + # Surface a Neumann ``f_ext`` initialisation kernel for every Neumann + # BC carrying numeric traction. Symbolic (string) traction stays + # handled by the legacy imported numeric injection path so existing + # callers keep working unchanged. Pure Dirichlet problems leave + # ``f_ext_kernel`` at ``None``. neumann_bcs = [ bc for bc in problem_ir.boundaries diff --git a/packages/mechdsl-core/src/mechdsl/codegen/__init__.py b/packages/mechdsl-core/src/mechdsl/codegen/__init__.py index 8243125..924a544 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/__init__.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/__init__.py @@ -84,7 +84,7 @@ def compile(problem_ir: ProblemIR) -> ArtifactBundle: contraction_plans=bundle.contraction_plans, emitted_source=source, metadata=bundle.metadata, - # P3-1: preserve the derived-energy Python-object channel through the + # Preserve the derived-energy Python-object channel through the # bundle rebuild so a LaTeX-derived constitutive law reaches codegen. derived_energy=bundle.derived_energy, ) diff --git a/packages/mechdsl-core/src/mechdsl/codegen/artifact.py b/packages/mechdsl-core/src/mechdsl/codegen/artifact.py index 45c2cbc..de829f9 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/artifact.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/artifact.py @@ -42,7 +42,7 @@ class ContractionPlan: contraction_path: list[tuple[int, ...]] = field(default_factory=list) estimated_flops: int = 0 tier: int = 0 # 1, 2, or 3 — 0 means unclassified - family: str = "FALLBACK" # P9-2: enum-name string (see Family registry) + family: str = "FALLBACK" # enum-name string (see Family registry) def to_dict(self) -> dict[str, Any]: """Serialise to a JSON-compatible dict.""" @@ -116,26 +116,27 @@ class ArtifactBundle: contraction_plans: tuple[ContractionPlan, ...] = () emitted_source: str = "" metadata: dict[str, Any] = field(default_factory=dict) - # P4-5: canonical ElementIR contract-surface dict. Default empty so - # pre-P4-5 bundles deserialise cleanly without the new key. + # Canonical ElementIR contract-surface dict. Default empty so older + # bundles deserialise cleanly without the new key. element_ir_dict: dict[str, Any] = field(default_factory=dict) - # post_recovery_plan P1-5: optional source for the Neumann ``f_ext`` - # initialisation kernel(s). ``None`` when the problem has no Neumann BCs - # with numeric traction (legacy symbolic-string traction stays handled - # by the existing imported numeric injection path). Kept off the - # ``content_hash`` for the same reason ``emitted_source`` is — its - # contents are derived from ``problem_ir_dict``'s boundary list. + # Optional source for the Neumann ``f_ext`` initialisation kernel(s). + # ``None`` when the problem has no Neumann BCs with numeric traction + # (legacy symbolic-string traction stays handled by the existing + # imported numeric injection path). Kept off the ``content_hash`` for + # the same reason ``emitted_source`` is — its contents are derived + # from ``problem_ir_dict``'s boundary list. f_ext_kernel: str | None = None - # constitutive_latex P3-1: parallel Python-object channel carrying the - # LaTeX-derived symbolic energy model (PK2 stress + material tangent) so - # the Taichi printer can emit the constitutive ``@ti.func`` from the - # derived energy instead of the hard-coded named-model switch. Held off - # the JSON path entirely (`to_dict`, `from_dict`, `content_hash`, and the - # `to_json`/`from_json` round-trip): the model carries SymPy expressions - # that do not serialise cleanly, and golden files compare the JSON-able - # semantic surface only. ``None`` for every named-model bundle so the JSON - # path and content hash are byte-identical to pre-P3-1 bundles. + # Parallel Python-object channel carrying the LaTeX-derived symbolic + # energy model (PK2 stress + material tangent) so the Taichi printer + # can emit the constitutive ``@ti.func`` from the derived energy + # instead of the hard-coded named-model switch. Held off the JSON path + # entirely (`to_dict`, `from_dict`, `content_hash`, and the + # `to_json`/`from_json` round-trip): the model carries SymPy + # expressions that do not serialise cleanly, and golden files compare + # the JSON-able semantic surface only. ``None`` for every named-model + # bundle so the JSON path and content hash are byte-identical to + # bundles without a derived energy. derived_energy: EnergyModel | None = field(default=None, compare=False) # ------------------------------------------------------------------ @@ -176,11 +177,11 @@ def from_pipeline( "dim": element_ir.dim, "n_quadrature_points": element_ir.quadrature.n_points, "formulation": element_ir.formulation, - # P4-3: surface the P4-1 execution-contract enrichment when - # present so downstream consumers (and golden artifacts) see - # the enriched ElementIR's contract blocks. Each key stays at - # None when the corresponding descriptor is unset, preserving - # round-trip compatibility with pre-P4-3 bundles. + # Surface the execution-contract enrichment when present so + # downstream consumers (and golden artifacts) see the enriched + # ElementIR's contract blocks. Each key stays at None when the + # corresponding descriptor is unset, preserving round-trip + # compatibility with older bundles. "geometry": ( element_ir.geometry.to_dict() if element_ir.geometry is not None else None ), @@ -195,17 +196,17 @@ def from_pipeline( ), } - # P4-5: store the canonical ElementIR contract surface alongside - # the legacy summary. The summary stays for golden-file back-compat; - # `element_ir_dict` is the post-P4-5 primary semantic carrier. + # Store the canonical ElementIR contract surface alongside the legacy + # summary. The summary stays for golden-file back-compat; + # `element_ir_dict` is the primary semantic carrier. return cls( problem_ir_dict=problem_ir.to_dict(), element_ir_summary=element_ir_summary, contraction_plans=contraction_plans, emitted_source=emitted_source, element_ir_dict=element_ir.to_dict(), - # P3-1: carry the LaTeX-derived energy (if any) into codegen via - # the Python-object channel. ``None`` for named-model IRs. + # Carry the LaTeX-derived energy (if any) into codegen via the + # Python-object channel. ``None`` for named-model IRs. derived_energy=problem_ir.derived_energy, ) diff --git a/packages/mechdsl-core/src/mechdsl/codegen/einsum_optimizer.py b/packages/mechdsl-core/src/mechdsl/codegen/einsum_optimizer.py index d2c3a8f..4f7dda4 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/einsum_optimizer.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/einsum_optimizer.py @@ -30,7 +30,7 @@ import opt_einsum -# P9-2: family classification is a realisation decision orthogonal to the +# Family classification is a realisation decision orthogonal to the # tier (scheduling) decision. We import the enum + classifier here so that # every ContractionResult carries both axes. from mechdsl.codegen.family_registry import ( # re-exported for convenience @@ -232,8 +232,6 @@ def estimate_unrolled_lines( total_lines += step_lines - # Update operand list: remove contracted, add result - # (simplified — we just need index sets for estimation) remaining_indices: set[str] = set() for i, op in enumerate(current_operands): if i not in pair: @@ -324,7 +322,7 @@ def optimize_contraction( if not within_budget: budget_detail += " [OVER BUDGET — Tier 3 restructuring required]" - # P9-2: attach the realisation-axis classification. Tier and family are + # Attach the realisation-axis classification. Tier and family are # orthogonal (scheduling vs realisation); see 09-EINSUM-OPTIMISER.md §9. family = classify_einsum_string(einsum_string, operand_shapes) @@ -412,7 +410,7 @@ def optimize_all( # --------------------------------------------------------------------------- -# P9-2 feature flag +# Family-emitter feature flag # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/src/mechdsl/codegen/family_registry.py b/packages/mechdsl-core/src/mechdsl/codegen/family_registry.py index 74dddb7..c230658 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/family_registry.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/family_registry.py @@ -26,7 +26,7 @@ ] -# Feature flag for the P9-2 rollout. Kept here (not in +# Feature flag for the family-emitter rollout. Kept here (not in # ``einsum_optimizer.py``) so that importing the registry does not # trigger any heavy imports from the optimiser. FAMILY_EMITTERS_ENABLED: bool = True @@ -207,7 +207,7 @@ def classify_einsum_string( :attr:`Family.FALLBACK`; P9-2 will refine the pattern-matching using operand shapes to split coarser families if needed. """ - _ = operand_shapes # reserved for P9-2 refinement + _ = operand_shapes # reserved for future refinement if einsum_string in _EXACT_MATCH: return _EXACT_MATCH[einsum_string] diff --git a/packages/mechdsl-core/src/mechdsl/codegen/hex20_tables.py b/packages/mechdsl-core/src/mechdsl/codegen/hex20_tables.py index 55a7c14..b11e792 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/hex20_tables.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/hex20_tables.py @@ -87,7 +87,7 @@ [-1.0, +1.0, 0.0], # N19 — midpoint N3-N7 ], dtype=np.float64, -) # shape (20, 3) +) # --------------------------------------------------------------------------- # Shape function evaluation @@ -147,11 +147,11 @@ def shape_functions(xi: float, eta: float, zeta: float) -> NDArray: # Edge midpoint nodes 8..19 for node_idx, xi_a, eta_a, zeta_a, zero_axis in _EDGE_DATA: - if zero_axis == 0: # xi = 0 + if zero_axis == 0: N[node_idx] = 0.25 * (1.0 - xi * xi) * (1.0 + eta_a * eta) * (1.0 + zeta_a * zeta) - elif zero_axis == 1: # eta = 0 + elif zero_axis == 1: N[node_idx] = 0.25 * (1.0 + xi_a * xi) * (1.0 - eta * eta) * (1.0 + zeta_a * zeta) - else: # zeta = 0 + else: N[node_idx] = 0.25 * (1.0 + xi_a * xi) * (1.0 + eta_a * eta) * (1.0 - zeta * zeta) return N @@ -201,19 +201,19 @@ def shape_gradients(xi: float, eta: float, zeta: float) -> NDArray: # Edge midpoint nodes 8..19 for node_idx, xi_a, eta_a, zeta_a, zero_axis in _EDGE_DATA: - if zero_axis == 0: # xi = 0 + if zero_axis == 0: B = 1.0 + eta_a * eta C = 1.0 + zeta_a * zeta G[node_idx, 0] = 0.25 * (-2.0 * xi) * B * C G[node_idx, 1] = 0.25 * (1.0 - xi * xi) * eta_a * C G[node_idx, 2] = 0.25 * (1.0 - xi * xi) * B * zeta_a - elif zero_axis == 1: # eta = 0 + elif zero_axis == 1: A = 1.0 + xi_a * xi C = 1.0 + zeta_a * zeta G[node_idx, 0] = 0.25 * xi_a * (1.0 - eta * eta) * C G[node_idx, 1] = 0.25 * A * (-2.0 * eta) * C G[node_idx, 2] = 0.25 * A * (1.0 - eta * eta) * zeta_a - else: # zeta = 0 + else: A = 1.0 + xi_a * xi B = 1.0 + eta_a * eta G[node_idx, 0] = 0.25 * xi_a * B * (1.0 - zeta * zeta) @@ -237,12 +237,12 @@ def shape_gradients(xi: float, eta: float, zeta: float) -> NDArray: HEX20_QUAD_POINTS: NDArray = np.array( [[xi, eta, zeta] for xi in _GP_1D for eta in _GP_1D for zeta in _GP_1D], dtype=np.float64, -) # shape (27, 3) +) HEX20_QUAD_WEIGHTS: NDArray = np.array( [wx * wy * wz for wx in _GW_1D for wy in _GW_1D for wz in _GW_1D], dtype=np.float64, -) # shape (27,) +) # --------------------------------------------------------------------------- # Pre-evaluated tables (computed once at module load time) @@ -252,13 +252,13 @@ def shape_gradients(xi: float, eta: float, zeta: float) -> NDArray: SHAPE_AT_QUAD: NDArray = np.array( [shape_functions(float(pt[0]), float(pt[1]), float(pt[2])) for pt in HEX20_QUAD_POINTS], dtype=np.float64, -) # shape (27, 20) +) # GRAD_AT_QUAD[q, a, i] = dN_a / d(xi_i) at quadrature point q GRAD_AT_QUAD: NDArray = np.array( [shape_gradients(float(pt[0]), float(pt[1]), float(pt[2])) for pt in HEX20_QUAD_POINTS], dtype=np.float64, -) # shape (27, 20, 3) +) # --------------------------------------------------------------------------- # Physical-space gradient computation @@ -296,7 +296,7 @@ def reference_gradient_at_physical( ValueError If det(J0) <= 0 — the element is inverted or degenerate. """ - dN_dxi = GRAD_AT_QUAD[q] # (20, 3) + dN_dxi = GRAD_AT_QUAD[q] # Reference Jacobian: J0 = dX/dxi = X^T @ dN/dxi -> (3, 3) J0 = X_elem.T @ dN_dxi @@ -309,6 +309,6 @@ def reference_gradient_at_physical( J0_inv = np.linalg.inv(J0) # dN/dX = dN/dxi @ J0^{-1} - dNdX: NDArray = dN_dxi @ J0_inv # (20, 3) + dNdX: NDArray = dN_dxi @ J0_inv return dNdX, detJ0 diff --git a/packages/mechdsl-core/src/mechdsl/codegen/hex8_reduced_tables.py b/packages/mechdsl-core/src/mechdsl/codegen/hex8_reduced_tables.py index c2cb3ea..82d9094 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/hex8_reduced_tables.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/hex8_reduced_tables.py @@ -66,9 +66,9 @@ HEX8_QUAD_POINTS_REDUCED: NDArray = np.array( [[0.0, 0.0, 0.0]], dtype=np.float64, -) # shape (1, 3) +) -HEX8_QUAD_WEIGHTS_REDUCED: NDArray = np.array([8.0], dtype=np.float64) # shape (1,) +HEX8_QUAD_WEIGHTS_REDUCED: NDArray = np.array([8.0], dtype=np.float64) # Convenience aliases matching the naming convention in sibling table modules. QUAD_POINTS_REDUCED: NDArray = HEX8_QUAD_POINTS_REDUCED @@ -82,10 +82,10 @@ SHAPE_AT_QUAD_REDUCED: NDArray = np.array( [shape_functions(float(pt[0]), float(pt[1]), float(pt[2])) for pt in HEX8_QUAD_POINTS_REDUCED], dtype=np.float64, -) # shape (1, 8) +) # GRAD_AT_QUAD_REDUCED[q, a, i] = dN_a / d(xi_i) at the centre point. GRAD_AT_QUAD_REDUCED: NDArray = np.array( [shape_gradients(float(pt[0]), float(pt[1]), float(pt[2])) for pt in HEX8_QUAD_POINTS_REDUCED], dtype=np.float64, -) # shape (1, 8, 3) +) diff --git a/packages/mechdsl-core/src/mechdsl/codegen/hex8_tables.py b/packages/mechdsl-core/src/mechdsl/codegen/hex8_tables.py index 6812703..14cceda 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/hex8_tables.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/hex8_tables.py @@ -59,11 +59,8 @@ def shape_gradients(xi: float, eta: float, zeta: float) -> NDArray: grad = np.empty((8, 3), dtype=np.float64) for a in range(8): xi_a, eta_a, zeta_a = HEX8_NODE_COORDS[a] - # dN_a/d(xi) grad[a, 0] = 0.125 * xi_a * (1.0 + eta_a * eta) * (1.0 + zeta_a * zeta) - # dN_a/d(eta) grad[a, 1] = 0.125 * (1.0 + xi_a * xi) * eta_a * (1.0 + zeta_a * zeta) - # dN_a/d(zeta) grad[a, 2] = 0.125 * (1.0 + xi_a * xi) * (1.0 + eta_a * eta) * zeta_a return grad @@ -77,9 +74,9 @@ def shape_gradients(xi: float, eta: float, zeta: float) -> NDArray: HEX8_QUAD_POINTS: NDArray = np.array( [[xi, eta, zeta] for xi in (-_g, +_g) for eta in (-_g, +_g) for zeta in (-_g, +_g)], dtype=np.float64, -) # shape (8, 3) +) -HEX8_QUAD_WEIGHTS: NDArray = np.ones(8, dtype=np.float64) # shape (8,) +HEX8_QUAD_WEIGHTS: NDArray = np.ones(8, dtype=np.float64) # --------------------------------------------------------------------------- # Pre-evaluated tables (computed once at module load time) @@ -89,13 +86,13 @@ def shape_gradients(xi: float, eta: float, zeta: float) -> NDArray: SHAPE_AT_QUAD: NDArray = np.array( [shape_functions(float(pt[0]), float(pt[1]), float(pt[2])) for pt in HEX8_QUAD_POINTS], dtype=np.float64, -) # shape (8, 8) +) # GRAD_AT_QUAD[q, a, i] = dN_a / d(xi_i) at quadrature point q GRAD_AT_QUAD: NDArray = np.array( [shape_gradients(float(pt[0]), float(pt[1]), float(pt[2])) for pt in HEX8_QUAD_POINTS], dtype=np.float64, -) # shape (8, 8, 3) +) # --------------------------------------------------------------------------- # Physical-space gradient computation @@ -128,7 +125,7 @@ def reference_gradient_at_physical( detJ0 : float Determinant of the reference Jacobian. """ - dN_dxi = GRAD_AT_QUAD[q] # (8, 3) + dN_dxi = GRAD_AT_QUAD[q] # Reference Jacobian: J0 = dX/dxi = X^T @ dN/dxi -> (3, 3) J0 = X_elem.T @ dN_dxi @@ -141,7 +138,7 @@ def reference_gradient_at_physical( J0_inv = np.linalg.inv(J0) # dN/dX = dN/dxi @ J0^{-1} - dNdX: NDArray = dN_dxi @ J0_inv # (8, 3) + dNdX: NDArray = dN_dxi @ J0_inv return dNdX, detJ0 @@ -195,7 +192,7 @@ def current_gradient_at_physical( reference-config helper for numerical consistency. """ del X_elem # not used at F = I; retained for API symmetry (see docstring). - dN_dxi = GRAD_AT_QUAD[q] # (8, 3) + dN_dxi = GRAD_AT_QUAD[q] # Current Jacobian: j = dx/dxi = x^T @ dN/dxi -> (3, 3) j = x_elem.T @ dN_dxi @@ -212,6 +209,6 @@ def current_gradient_at_physical( j_inv = np.linalg.inv(j) # dN/dx = dN/dxi @ j^{-1} - dNdx: NDArray = dN_dxi @ j_inv # (8, 3) + dNdx: NDArray = dN_dxi @ j_inv return dNdx, detj diff --git a/packages/mechdsl-core/src/mechdsl/codegen/hourglass.py b/packages/mechdsl-core/src/mechdsl/codegen/hourglass.py index 95544d4..6fd63de 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/hourglass.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/hourglass.py @@ -190,10 +190,10 @@ def _projected_hourglass_vectors(X_nodes: NDArray) -> tuple[NDArray, float]: V_e, B_bar = _element_volume_and_mean_B(X_nodes) gamma = _GAMMA_RAW.copy() # Gamma . X[:, j] = sum_a Gamma[alpha, a] * X[a, j] -> shape (4, 3) - gamma_dot_X = _GAMMA_RAW @ X_nodes # (4, 3) + gamma_dot_X = _GAMMA_RAW @ X_nodes # Subtract (Gamma . X) contracted with B_bar (using 1/V_e factor) # correction[alpha, a] = sum_j (Gamma_alpha . X[:, j]) * B_bar[a, j] - correction = gamma_dot_X @ B_bar.T # (4, 8) + correction = gamma_dot_X @ B_bar.T gamma -= correction return gamma, V_e @@ -289,16 +289,16 @@ def flanagan_belytschko_force( msg = f"X_nodes must have shape (8, 3); got {X_nodes.shape}." raise ValueError(msg) - gamma, V_e = _projected_hourglass_vectors(X_nodes) # (4, 8), scalar + gamma, V_e = _projected_hourglass_vectors(X_nodes) epsilon = _hourglass_stiffness_scalar(V_e, mu, lambda_h) # Hourglass generalized displacements h_{alpha, i} (FB eq. 2.31) # h[alpha, i] = epsilon * sum_b gamma[alpha, b] * u_nodes[b, i] - h = epsilon * (gamma @ u_nodes) # (4, 3) + h = epsilon * (gamma @ u_nodes) # Scatter back to nodal forces (FB eq. 4.8): # f_HG[a, i] = sum_alpha gamma[alpha, a] * h[alpha, i] - f_HG = gamma.T @ h # (8, 3) + f_HG = gamma.T @ h return f_HG @@ -343,11 +343,11 @@ def flanagan_belytschko_stiffness( msg = f"X_nodes must have shape (8, 3); got {X_nodes.shape}." raise ValueError(msg) - gamma, V_e = _projected_hourglass_vectors(X_nodes) # (4, 8), scalar + gamma, V_e = _projected_hourglass_vectors(X_nodes) epsilon = _hourglass_stiffness_scalar(V_e, mu, lambda_h) # Node-node hourglass coupling: G[a, b] = sum_alpha gamma[alpha, a] * gamma[alpha, b] - G = gamma.T @ gamma # (8, 8) + G = gamma.T @ gamma # Tensor up with the 3x3 identity in the spatial component block K_HG = np.zeros((24, 24), dtype=np.float64) diff --git a/packages/mechdsl-core/src/mechdsl/codegen/mfem_printer.py b/packages/mechdsl-core/src/mechdsl/codegen/mfem_printer.py index 7cf0560..f455cf4 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/mfem_printer.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/mfem_printer.py @@ -65,7 +65,7 @@ from mechdsl.codegen.artifact import ArtifactBundle # --------------------------------------------------------------------------- -# Voigt convention conversion helpers (Plan B §8 / 07-CONVENTIONS.md) +# Voigt convention conversion helpers (see 07-CONVENTIONS.md) # --------------------------------------------------------------------------- # Shear component indices in the shared [xx, yy, zz, xy, xz, yz] ordering. @@ -73,7 +73,7 @@ # --------------------------------------------------------------------------- -# P9-2: Per-family emitters (MFEM backend) +# Per-family emitters (MFEM backend) # --------------------------------------------------------------------------- # # MFEM emission is largely structural — the integrator-class skeleton is @@ -82,7 +82,7 @@ # contraction (all inside AssembleElementVector / AssembleElementGrad). # Each helper owns one shape; the call sites below dispatch through the # :data:`family_emitters` table so the happy path actually exercises the -# table (P8-3 Gate B lesson). +# table (define-but-don't-call is a silent failure). def _emit_family_displacement_gradient_mfem(ctx: EmissionContext) -> None: @@ -495,7 +495,7 @@ def emit_force_integrator(ctx: EmissionContext, bundle: ArtifactBundle) -> None: ctx.emit("el.CalcDShape(ip, DSh);") ctx.emit("Mult(DSh, Jinv, DS);") ctx.emit("") - # P9-2: DISPLACEMENT_GRADIENT dispatch ('qaI,ai->qiI' shape). + # DISPLACEMENT_GRADIENT dispatch ('qaI,ai->qiI' shape). if not _dispatch_family(Family.DISPLACEMENT_GRADIENT, ctx): ctx.emit("// F = I + grad(u)") ctx.emit("F = 0.0;") @@ -517,7 +517,7 @@ def emit_force_integrator(ctx: EmissionContext, bundle: ArtifactBundle) -> None: ctx.emit("const double w = ip.weight * Ttr.Weight();") ctx.emit("DenseMatrix FS(dim, dim);") ctx.emit("Mult(F, S, FS);") - # P9-2: FORCE_INTEGRATION dispatch ('qaI,qiI->qai' shape). + # FORCE_INTEGRATION dispatch ('qaI,qiI->qai' shape). if not _dispatch_family(Family.FORCE_INTEGRATION, ctx): ctx.emit("for (int a = 0; a < dof; ++a) {") with ctx.indent_block(): @@ -613,7 +613,7 @@ def emit_tangent_integrator(ctx: EmissionContext) -> None: ctx.emit("// CB = C_eng * B, then elmat += w * B^T * CB.") ctx.emit("Mult(C_eng, B, CB);") ctx.emit("const double w = ip.weight * Ttr.Weight();") - # P9-2: MATERIAL_TANGENT_CONTRACTION dispatch + # MATERIAL_TANGENT_CONTRACTION dispatch # ('qaI,qiIjJ,qbJ->qaibj' collapsed to Voigt B^T C_eng B). if not _dispatch_family(Family.MATERIAL_TANGENT_CONTRACTION, ctx): ctx.emit("for (int p = 0; p < ndof_total; ++p) {") diff --git a/packages/mechdsl-core/src/mechdsl/codegen/moose_printer.py b/packages/mechdsl-core/src/mechdsl/codegen/moose_printer.py index fbf3029..fffce0b 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/moose_printer.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/moose_printer.py @@ -81,7 +81,7 @@ # --------------------------------------------------------------------------- -# P9-2: Per-family emitters (MOOSE backend) +# Per-family emitters (MOOSE backend) # --------------------------------------------------------------------------- # # The MOOSE emission path is action-driven: ``ComputeStressBase`` writes @@ -170,7 +170,7 @@ def _dispatch_family(family: Family, ctx: EmissionContext, *args: object) -> boo # --------------------------------------------------------------------------- # MVP tensorial Voigt ordering: [xx, yy, zz, xy, xz, yz], unscaled shears. -# Matches dev/design_docs/07-CONVENTIONS.md. +# Matches 07-CONVENTIONS.md. _VOIGT_INDEX: tuple[tuple[int, int], ...] = ( (0, 0), # xx (1, 1), # yy @@ -467,7 +467,7 @@ def emit_cpp(bundle: ArtifactBundle) -> str: ctx.emit("const Real trE = E.trace();") ctx.emit("RankTwoTensor stress;") ctx.emit("stress.zero();") - # P9-2: dispatch through the family emitter table. Legacy body is + # Dispatch through the family emitter table. Legacy body is # retained as the fallback when the flag is off so byte-identical # emission is preserved under ``MECHDSL_FAMILY_EMITTERS=0``. if not _dispatch_family(Family.MATERIAL_TANGENT_CONTRACTION, ctx): @@ -493,7 +493,7 @@ def emit_cpp(bundle: ArtifactBundle) -> str: ctx.emit("// + mu * (delta_ik * delta_jl + delta_il * delta_jk)") ctx.emit("RankFourTensor C;") ctx.emit("C.zero();") - # P9-2: dispatch through the family emitter table; legacy inline body + # Dispatch through the family emitter table; legacy inline body # retained as the fallback for byte-identical emission when the flag # is off. if not _dispatch_family(Family.TANGENT_DOUBLE_CONTRACTION, ctx): diff --git a/packages/mechdsl-core/src/mechdsl/codegen/taichi_printer.py b/packages/mechdsl-core/src/mechdsl/codegen/taichi_printer.py index 01961e0..7da70b3 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/taichi_printer.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/taichi_printer.py @@ -118,11 +118,11 @@ def __exit__(self, *args: object) -> None: # --------------------------------------------------------------------------- -# Recovery P5-4 — enriched-IR consumption helpers +# Enriched-IR consumption helpers # # The printer historically read element identity fields from -# ``ArtifactBundle.element_ir_summary`` (the legacy 4-key dict). Post-P4-5, -# the canonical carrier is ``ArtifactBundle.element_ir_dict``, which is +# ``ArtifactBundle.element_ir_summary`` (the legacy 4-key dict). The +# canonical carrier is now ``ArtifactBundle.element_ir_dict``, which is # ``ElementIR.to_dict()`` and includes the four execution-contract blocks # (``geometry``, ``material_eval``, ``local_force``, ``local_tangent``). # @@ -379,20 +379,19 @@ def _fmt_1d_literal(arr: np.ndarray, name: str) -> str: # --------------------------------------------------------------------------- -# P9-2: Per-family emitters (Taichi backend) +# Per-family emitters (Taichi backend) # --------------------------------------------------------------------------- # # These helpers emit the per-contraction code shapes that the Taichi MVP # uses today. Each helper is the single owner of its family's emission # pattern; the legacy inline bodies that used to sit in the force / # tangent kernels now delegate to these helpers via :data:`family_emitters` -# so the dispatch is exercised on the happy path (see P8-3 Gate B lesson: -# define-but-don't-call is a silent failure). +# so the dispatch is exercised on the happy path (define-but-don't-call +# is a silent failure). # -# Every helper is **source-identical** to the pre-P9-2 inline body it -# replaces — that is the whole point of P9-2 being a pure refactor. The -# cross-backend equivalence tests (P8-3) and the regenerated goldens are -# the regression guard. +# Every helper is **source-identical** to the inline body it replaces — +# this is a pure refactor. The cross-backend equivalence tests and the +# regenerated goldens are the regression guard. def _emit_family_displacement_gradient_taichi(ctx: EmissionContext) -> None: @@ -497,7 +496,7 @@ def _emit_family_fallback_taichi(ctx: EmissionContext) -> None: ctx.emit("# (P9-2 fallback: no template override — caller emits inline)") -# P9-2 family-emitter dispatch table. Keyed on :class:`Family`; each +# Family-emitter dispatch table. Keyed on :class:`Family`; each # entry is callable with ``(ctx, *args)`` at the relevant emission site. # Heterogeneous signatures are intentional — families emit into different # containing scopes. The table is total over :data:`family_registry.FAMILIES` @@ -529,7 +528,7 @@ def _dispatch_family(family: Family, ctx: EmissionContext, *args: object) -> boo if emitter is _emit_family_fallback_taichi: # Fallback intentionally defers to the legacy body; signal the # caller to emit the inline code path. Log at DEBUG so that - # silent-fallback collisions (P9-2 Gate B finding) are observable. + # silent-fallback collisions are observable. _logger.debug("taichi_printer: family %s routed to legacy body", family.name) return False emitter(ctx, *args) @@ -556,7 +555,7 @@ def emit_preamble(ctx: EmissionContext, bundle: ArtifactBundle) -> None: # Extract material model for the header comment material_model = bundle.problem_ir_dict.get("material", {}).get("model", "svk") formulation = bundle.problem_ir_dict.get("formulation", "total_lagrangian") - # P5-4: prefer element_ir_dict over element_ir_summary for identity fields. + # Prefer element_ir_dict over element_ir_summary for identity fields. element_type = _ir_field(bundle, "element_type", "hex8") dim = _ir_field(bundle, "dim", 3) @@ -566,7 +565,7 @@ def emit_preamble(ctx: EmissionContext, bundle: ArtifactBundle) -> None: ctx.emit(f"Material : {material_model}") ctx.emit(f"Element : {element_type}") ctx.emit(f"Dimension : {dim}") - # P5-4 auditability — opt-in, never on by default. Surface enriched-IR + # Auditability — opt-in, never on by default. Surface enriched-IR # contract fields so users can see WHY the codegen made specific # decisions (stress measure, integration count, force/tangent layout). if ctx.verbose: @@ -595,7 +594,7 @@ def _emit_enrichment_audit(ctx: EmissionContext, bundle: ArtifactBundle) -> None local_tangent = _ir_block(bundle, "local_tangent") if not any((material_eval, geometry, local_force, local_tangent)): - # Legacy bundle (pre-P4-3 enrichment) — nothing to surface. + # Legacy bundle without enrichment blocks — nothing to surface. return ctx.emit("") @@ -761,7 +760,7 @@ def emit_field_declarations(ctx: EmissionContext, bundle: ArtifactBundle) -> Non ctx.emit("ti.root.dense(ti.ij, (n_elem, N_NODES)).place(elem_nodes)") emits_matvec = _emits_generated_tangent_matvec(bundle) if emits_matvec: - # Quadrature tables (PlanJune14 WI-1): mesh-independent, but placed + + # Quadrature tables: mesh-independent, but placed + # filled here because allocate_fields() is guaranteed (by the seam # contract) to run after the caller's ti.init and before any kernel # launch — so the fill never races Taichi materialisation. The runtime-q @@ -797,7 +796,7 @@ def emit_constitutive_update(ctx: EmissionContext, bundle: ArtifactBundle) -> No material_model = bundle.problem_ir_dict.get("material", {}).get("model", "svk") material_params = bundle.problem_ir_dict.get("material", {}).get("params", {}) - # P3-1: when the bundle carries a LaTeX-derived energy model, emit the + # When the bundle carries a LaTeX-derived energy model, emit the # constitutive ``@ti.func`` from the derived PK2 stress (via the proven # energy_emitter path) instead of the hard-coded named-model switch. This # replaces the advisory-only LatexSemantics path: the derived energy now @@ -1276,7 +1275,7 @@ def emit_internal_force_kernel(ctx: EmissionContext, bundle: ArtifactBundle) -> ctx.emit("# " + "=" * 70) ctx.emit("") - # P6-2: document the element-deletion contract so downstream readers + # Document the element-deletion contract so downstream readers # know the generated residual shape mutates across Newton steps only # by dropping elements (never by bringing them back). if is_damage: @@ -1351,14 +1350,14 @@ def emit_internal_force_kernel(ctx: EmissionContext, bundle: ArtifactBundle) -> ctx.emit("dN_dxi[a, d] = GRAD_AT_QUAD[q][a][d]") ctx.emit("") - # ----- Configuration dispatch (Plan B B1.3) ----- + # ----- Configuration dispatch ----- # Python-time branch: emits EITHER the TL body (P = F@S, # detJ0) or the UL body (sigma = F@S@F.T/J, detj). # The generated source file contains only ONE path. if configuration == "current": _emit_ul_force_qp_inner(ctx, material_model) else: - # === TL body (Plan A, unchanged) === + # === TL body (reference configuration) === ctx.emit("# Reference Jacobian J0 = X^T @ dN/dxi (3x3)") ctx.emit("J0 = X_elem.transpose() @ dN_dxi") ctx.emit("detJ0 = J0.determinant()") @@ -1373,7 +1372,7 @@ def emit_internal_force_kernel(ctx: EmissionContext, bundle: ArtifactBundle) -> ctx.emit("# dN/dX = dN/dxi @ J0^{-1} (N_NODES x DIM)") ctx.emit("dNdX = dN_dxi @ J0_inv") ctx.emit("") - # P9-2: family-emitter dispatch for DISPLACEMENT_GRADIENT. + # Family-emitter dispatch for DISPLACEMENT_GRADIENT. # Emits the F = I + grad_u scatter ('qaI,ai->qiI'). # When the feature flag is OFF we fall through to the # legacy inline body below (byte-identical output). @@ -1441,7 +1440,7 @@ def emit_internal_force_kernel(ctx: EmissionContext, bundle: ArtifactBundle) -> ctx.emit("# 1st Piola-Kirchhoff stress P = F @ S") ctx.emit("P = F @ S") ctx.emit("") - # P9-2: family-emitter dispatch for FORCE_INTEGRATION + # Family-emitter dispatch for FORCE_INTEGRATION # (einsum 'qaI,qiI->qai'). Legacy inline body below # is the fallback when the flag is off. if not _dispatch_family(Family.FORCE_INTEGRATION, ctx): @@ -1525,7 +1524,7 @@ def _emit_tl_tangent_qp_body( if derived_lines is not None: for line in derived_lines: ctx.emit(line) - # P9-2: family-emitter dispatch for MATERIAL_TANGENT_CONTRACTION + # Family-emitter dispatch for MATERIAL_TANGENT_CONTRACTION # (einsum 'qaI,qiIjJ,qbJ->qaibj' — collapsed for SVK into the short # closed form). Legacy inline body below is the fallback. elif not _dispatch_family(Family.MATERIAL_TANGENT_CONTRACTION, ctx, is_plastic): @@ -1624,7 +1623,7 @@ def _emit_ul_tangent_qp_body(ctx: EmissionContext, is_plastic: bool) -> None: ctx.emit("# Spatial gradient of v (using dN/dx, not dN/dX)") ctx.emit("grad_v = v_elem.T @ dNdx") ctx.emit("") - # P9-2: family-emitter dispatch for TANGENT_DOUBLE_CONTRACTION + # Family-emitter dispatch for TANGENT_DOUBLE_CONTRACTION # (einsum 'ijkl,kl->ij'). Legacy inline body below is the fallback. if not _dispatch_family(Family.TANGENT_DOUBLE_CONTRACTION, ctx): ctx.emit("# Material term: dsigma_mat_{ij} = c^tau_{ijkl} * grad_v_{kl}") @@ -1873,33 +1872,32 @@ def emit_tangent_matvec_kernel(ctx: EmissionContext, bundle: ArtifactBundle) -> # --------------------------------------------------------------------------- -# PlanJune14 P3-2 — generated @ti.kernel matrix-free SVK tangent operator +# Generated @ti.kernel matrix-free SVK tangent operator # # The function above (``emit_tangent_matvec_kernel``) emits a host-NumPy # ``tangent_matvec`` consumed by the imported ``CGSolver`` in the emitted -# Newton driver. P3-2 adds — *alongside* it, not replacing it — a generated -# ``@ti.kernel`` that applies the SVK tangent ``K(u)·v`` **fully matrix-free** -# (D-A: never store element tangents), routing the tangent contraction through -# the Layer-4b einsum optimiser (the P3-1 ``ContractionPlan``) and calling the -# Tier-1 ``ti_runtime`` ``@ti.func`` helpers. The kernel targets the -# ``ti_runtime`` ``apply_A(out, x)`` seam (like the PJ-1 spike) so a generated +# Newton driver. This section adds — *alongside* it, not replacing it — a +# generated ``@ti.kernel`` that applies the SVK tangent ``K(u)·v`` **fully +# matrix-free** (never store element tangents), routing the tangent +# contraction through the einsum optimiser (``ContractionPlan``) and +# calling the Tier-1 ``ti_runtime`` ``@ti.func`` helpers. The kernel +# targets the ``ti_runtime`` ``apply_A(out, x)`` seam so a generated # PCG / Newton driver can inject it. # -# Why alongside, not in place: the host ``tangent_matvec`` name, its closed-form -# ``dS = lam*tr(dE)*I + 2*mu*dE`` body, and the ``def tangent_matvec(`` signature -# are pinned by ``test_taichi_printer.py`` / ``test_codegen.py`` and the three -# ``*.py.golden`` snapshots, and the host function is wired into the emitted -# Newton driver's ``CGSolver`` call. A wholesale swap would cascade through every -# e2e/golden/J2/UL path. The smallest faithful P3-2 delivery is the generated -# ``@ti.kernel`` SVK route plus its <1e-10 parity + JIT-budget gates; flipping -# the default emission to it (and the J2 variant) are the explicitly-downstream -# tasks P4-3 and P5-1. +# Why alongside, not in place: the host ``tangent_matvec`` name, its +# closed-form ``dS = lam*tr(dE)*I + 2*mu*dE`` body, and the +# ``def tangent_matvec(`` signature are pinned by the printer/codegen tests +# and the ``*.py.golden`` snapshots, and the host function is wired into +# the emitted Newton driver's ``CGSolver`` call. A wholesale swap would +# cascade through every e2e/golden/J2/UL path, so the generated +# ``@ti.kernel`` SVK route ships with <1e-10 parity + JIT-budget gates +# while the host route stays the default. # -# The A-formation. The P3-1 contraction ``qaI,qiIjJ,qbJ,bj->qai`` applies the +# The A-formation. The contraction ``qaI,qiIjJ,qbJ,bj->qai`` applies the # consistent **two-point** tangent ``A(i,I,j,J)`` such that -# ``dP_{iI} = A_{iIjJ} (∂v_j/∂X_J)``. For SVK that A (matching the spike's -# ``dP = grad_v·S + F·(C:dE)``) collapses to the closed form, per quadrature -# point, +# ``dP_{iI} = A_{iIjJ} (∂v_j/∂X_J)``. For SVK that A (matching +# ``dP = grad_v·S + F·(C:dE)``) collapses to the closed form, per +# quadrature point, # # A_{iIjJ} = δ_{ij} S_{JI} (geometric / initial-stress) # + λ F_{iI} F_{jJ} @@ -2069,8 +2067,8 @@ def emit_svk_tangent_matvec_kernel(ctx: EmissionContext, bundle: ArtifactBundle) from mechdsl.ir.element_ir import create_hex8_element_ir from mechdsl.lowering.einsum_extract import build_tangent_matvec_plan - # Route the tangent contraction through Layer-4b: this is the P3-1 - # ContractionPlan (opt_einsum path + JIT-budget-checked tier), not a + # Route the tangent contraction through the einsum optimiser: this is + # the ContractionPlan (opt_einsum path + JIT-budget-checked tier), not a # hand-rolled einsum. The path drives the loop structure emitted below. element_ir = create_hex8_element_ir(formulation="total_lagrangian", configuration="reference") plan = build_tangent_matvec_plan(element_ir) @@ -2687,7 +2685,7 @@ def emit_newton_driver(ctx: EmissionContext, bundle: ArtifactBundle) -> None: # tests/test_lemaitre_acceptance.py::_newton_step_lemaitre, which # snapshots+restores alpha AND damage_D around every residual eval). # Without this, damage_D / is_deleted would drift across Newton - # iterations exactly as alpha did before the WI-2 fix. + # iterations just as alpha would without its committed mirror. # copy_from is dtype-agnostic: it works for the f64 damage_D and # the i32 is_deleted alike (raw same-shape field copy on device). ctx.emit("_damage_D_committed.copy_from(damage_D)") @@ -2883,9 +2881,6 @@ def emit_explicit_driver(ctx: EmissionContext, bundle: ArtifactBundle) -> None: ctx.emit("u[a][i] += dt * v[a][i]") ctx.emit("") - # Document the deleted-element guard is already honoured in - # compute_internal_force (Phase 6 idiom) -- advance_one_step itself operates - # on nodal state, not element state, so the guard sits upstream. if is_damage: ctx.emit("# Note: deleted elements (is_deleted[e] != 0) contribute zero to") ctx.emit("# f_int via the guard already emitted in compute_internal_force.") @@ -2934,7 +2929,7 @@ def emit_validate_mesh(ctx: EmissionContext) -> None: # --------------------------------------------------------------------------- -# post_recovery_plan P1-4 — Neumann f_ext init kernel +# Neumann f_ext init kernel # --------------------------------------------------------------------------- @@ -3173,7 +3168,7 @@ def emit_main(ctx: EmissionContext, bundle: ArtifactBundle) -> None: # Explicit-dynamics __main__: no Newton solve; the generated module exposes # advance_one_step(dt) so the user drives time stepping externally. We emit # a minimal informational main here so the module is still importable and - # the file is self-contained (Plan B §B7, P7-1). + # the file is self-contained. if dynamics_mode == "explicit": ctx.emit("# " + "=" * 70) ctx.emit("# Main entry point (explicit dynamics)") @@ -3375,7 +3370,7 @@ def emit(bundle: ArtifactBundle) -> str: """ ctx = EmissionContext() - # Validate material model before emission. P3-1: a bundle carrying a + # Validate material model before emission. A bundle carrying a # LaTeX-derived energy model emits its constitutive @ti.func from that # energy (see emit_constitutive_update), so the named-model allow-list # does not gate it — any model whose law was derived from a strain-energy @@ -3392,7 +3387,7 @@ def emit(bundle: ArtifactBundle) -> str: f"Perzyna/Johnson-Cook Taichi emission is planned for a future integration task." ) - # Dynamics mode branch (Plan B §B7, P7-1). STATIC (or missing) keeps the + # Dynamics mode branch. STATIC (or missing) keeps the # existing Newton-driver emission byte-identical; EXPLICIT swaps the driver # for central-difference ``advance_one_step`` and skips the tangent matvec # that only the implicit Newton solver consumes. @@ -3421,7 +3416,7 @@ def emit(bundle: ArtifactBundle) -> str: configuration = bundle.problem_ir_dict.get("configuration", "reference") if material_model == "svk" and bundle.derived_energy is None and configuration != "current": emit_svk_tangent_matvec_kernel(ctx, bundle) - # PlanJune14 P5-1: J2's dissipative counterpart — the generated matrix-free + # J2's dissipative counterpart — the generated matrix-free # *algorithmic consistent tangent* @ti.kernel, emitted alongside the host # tangent_matvec for the J2 / Total-Lagrangian / reference path only (NOT # Lemaitre, which layers damage and stays on the host route until a @@ -3442,7 +3437,7 @@ def emit(bundle: ArtifactBundle) -> str: # --------------------------------------------------------------------------- -# Design-doc-aligned façade (P5-3 / R4.3) +# Design-doc-aligned façade # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/src/mechdsl/codegen/tet10_tables.py b/packages/mechdsl-core/src/mechdsl/codegen/tet10_tables.py index 27a375b..47954f6 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/tet10_tables.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/tet10_tables.py @@ -187,15 +187,15 @@ def shape_gradients(xi: float, eta: float, zeta: float) -> NDArray: TET10_QUAD_POINTS: NDArray = np.array( [ - [_A, _A, _A], # Q0: L0 = b - [_B, _A, _A], # Q1: L1 = b - [_A, _B, _A], # Q2: L2 = b - [_A, _A, _B], # Q3: L3 = b + [_A, _A, _A], + [_B, _A, _A], + [_A, _B, _A], + [_A, _A, _B], ], dtype=np.float64, -) # shape (4, 3) +) -TET10_QUAD_WEIGHTS: NDArray = np.full(4, 1.0 / 24.0, dtype=np.float64) # shape (4,) +TET10_QUAD_WEIGHTS: NDArray = np.full(4, 1.0 / 24.0, dtype=np.float64) # --------------------------------------------------------------------------- # Pre-evaluated tables (computed once at module load time) @@ -205,13 +205,13 @@ def shape_gradients(xi: float, eta: float, zeta: float) -> NDArray: SHAPE_AT_QUAD: NDArray = np.array( [shape_functions(float(pt[0]), float(pt[1]), float(pt[2])) for pt in TET10_QUAD_POINTS], dtype=np.float64, -) # shape (4, 10) +) # GRAD_AT_QUAD[q, a, i] = dN_a / d(xi_i) at quadrature point q GRAD_AT_QUAD: NDArray = np.array( [shape_gradients(float(pt[0]), float(pt[1]), float(pt[2])) for pt in TET10_QUAD_POINTS], dtype=np.float64, -) # shape (4, 10, 3) +) # --------------------------------------------------------------------------- # Physical-space gradient computation @@ -249,7 +249,7 @@ def reference_gradient_at_physical( ValueError If det(J0) <= 0 — the element is inverted or degenerate. """ - dN_dxi = GRAD_AT_QUAD[q] # (10, 3) + dN_dxi = GRAD_AT_QUAD[q] # Reference Jacobian: J0 = dX/dxi = X^T @ dN/dxi -> (3, 3) J0 = X_elem.T @ dN_dxi @@ -262,6 +262,6 @@ def reference_gradient_at_physical( J0_inv = np.linalg.inv(J0) # dN/dX = dN/dxi @ J0^{-1} - dNdX: NDArray = dN_dxi @ J0_inv # (10, 3) + dNdX: NDArray = dN_dxi @ J0_inv return dNdX, detJ0 diff --git a/packages/mechdsl-core/src/mechdsl/codegen/tet4_tables.py b/packages/mechdsl-core/src/mechdsl/codegen/tet4_tables.py index 0bc0cc7..d98e442 100644 --- a/packages/mechdsl-core/src/mechdsl/codegen/tet4_tables.py +++ b/packages/mechdsl-core/src/mechdsl/codegen/tet4_tables.py @@ -100,9 +100,9 @@ def shape_gradients(xi: float, eta: float, zeta: float) -> NDArray: TET4_QUAD_POINTS: NDArray = np.array( [[0.25, 0.25, 0.25]], dtype=np.float64, -) # shape (1, 3) +) -TET4_QUAD_WEIGHTS: NDArray = np.array([1.0 / 6.0], dtype=np.float64) # shape (1,) +TET4_QUAD_WEIGHTS: NDArray = np.array([1.0 / 6.0], dtype=np.float64) # --------------------------------------------------------------------------- # Pre-evaluated tables (computed once at module load time) @@ -112,14 +112,14 @@ def shape_gradients(xi: float, eta: float, zeta: float) -> NDArray: SHAPE_AT_QUAD: NDArray = np.array( [shape_functions(float(pt[0]), float(pt[1]), float(pt[2])) for pt in TET4_QUAD_POINTS], dtype=np.float64, -) # shape (1, 4) +) # GRAD_AT_QUAD[q, a, i] = dN_a / d(xi_i) at quadrature point q # For Tet4 the gradient is constant, so all q rows are identical. GRAD_AT_QUAD: NDArray = np.array( [shape_gradients(float(pt[0]), float(pt[1]), float(pt[2])) for pt in TET4_QUAD_POINTS], dtype=np.float64, -) # shape (1, 4, 3) +) # --------------------------------------------------------------------------- # Physical-space gradient computation @@ -160,7 +160,7 @@ def reference_gradient_at_physical( ValueError If det(J0) <= 0 — the element is inverted or degenerate. """ - dN_dxi = GRAD_AT_QUAD[q] # (4, 3) + dN_dxi = GRAD_AT_QUAD[q] # Reference Jacobian: J0 = dX/dxi = X^T @ dN/dxi -> (3, 3) J0 = X_elem.T @ dN_dxi @@ -173,6 +173,6 @@ def reference_gradient_at_physical( J0_inv = np.linalg.inv(J0) # dN/dX = dN/dxi @ J0^{-1} - dNdX: NDArray = dN_dxi @ J0_inv # (4, 3) + dNdX: NDArray = dN_dxi @ J0_inv return dNdX, detJ0 diff --git a/packages/mechdsl-core/src/mechdsl/frontend/__init__.py b/packages/mechdsl-core/src/mechdsl/frontend/__init__.py index c779f1d..2605f80 100644 --- a/packages/mechdsl-core/src/mechdsl/frontend/__init__.py +++ b/packages/mechdsl-core/src/mechdsl/frontend/__init__.py @@ -39,10 +39,10 @@ ] -# post_recovery_plan Phase 4 (P4-3) — detect ``$...$`` math blocks in -# LaTeX source. The pattern uses non-greedy capture and disallows -# unescaped newlines so commented-out maths or stray dollar signs in -# verbatim blocks do not span lines. +# Detect ``$...$`` math blocks in LaTeX source. The pattern uses +# non-greedy capture and disallows unescaped newlines so +# commented-out maths or stray dollar signs in verbatim blocks do +# not span lines. _MATH_BLOCK_RE = re.compile(r"(? dict[str, Any]: "tensors": convert_namespace(all_tensors, all_classifications), "namespace": all_tensors, "classifications": all_classifications, - # Per-equation semantics from the math parser (fgram P5-1). JSON- - # primitive dicts so downstream IR construction reads real pipeline - # output rather than a hand-built shape. + # Per-equation semantics from the math parser. JSON-primitive dicts + # so downstream IR construction reads real pipeline output rather + # than a hand-built shape. "equations": all_equations, } return context @@ -325,9 +325,8 @@ def build_context( ) # Topology + integration + hourglass validation is delegated to # ElementFactory.create so the frontend, parser, and lowering layers all - # agree on the supported triples (Plan B phase B5, task P5-6). Any - # ValueError from the factory is rewrapped as UnsupportedError so the - # frontend's public-API contract (UnsupportedError + Plan B pointer) + # agree on the supported triples. Any ValueError from the factory is + # rewrapped as UnsupportedError so the frontend's public-API contract # is preserved. from mechdsl.ir.element_factory import ElementFactory diff --git a/packages/mechdsl-core/src/mechdsl/frontend/directives.py b/packages/mechdsl-core/src/mechdsl/frontend/directives.py index 70a5c14..924f092 100644 --- a/packages/mechdsl-core/src/mechdsl/frontend/directives.py +++ b/packages/mechdsl-core/src/mechdsl/frontend/directives.py @@ -48,7 +48,6 @@ class ParseError(ValueError): "weak_form": frozenset({"residual"}), "verify": frozenset({"patch_test"}), } -"""Command-scoped flag-only options accepted by the directive splitter.""" # --------------------------------------------------------------------------- @@ -304,8 +303,8 @@ def _mech_boundary(accum: dict[str, Any], args: ParsedArgs, line_no: int) -> Non elif key == "traction": bc["traction"] = _parse_traction(raw_value, line_no=line_no) elif key == "surface": - # post_recovery_plan P1-2: --surface tags the mesh sideset the BC - # acts on; routes through to BoundaryCondition.surface_tag. + # --surface tags the mesh sideset the BC acts on; routes through + # to BoundaryCondition.surface_tag. bc["surface_tag"] = raw_value else: bc[key] = _parse_scalar(raw_value) @@ -742,9 +741,8 @@ def _mech_assign(accum: dict[str, Any], args: ParsedArgs, line_no: int) -> None: "index": _mech_index, "assign": _mech_assign, } -"""Registry of documented ``% mechanics`` directive handlers.""" -# Compatibility hook for older tests/importers. P3-1 promotes all documented -# directive shapes into HANDLERS, so no command is currently deferred here. +# Compatibility hook for older tests/importers. All documented +# directive shapes live in HANDLERS, so no command is currently deferred here. DEFERRED_DIRECTIVES: dict[str, tuple[str, str]] = {} diff --git a/packages/mechdsl-core/src/mechdsl/frontend/parser.py b/packages/mechdsl-core/src/mechdsl/frontend/parser.py index 7760a16..40e5ed4 100644 --- a/packages/mechdsl-core/src/mechdsl/frontend/parser.py +++ b/packages/mechdsl-core/src/mechdsl/frontend/parser.py @@ -241,8 +241,8 @@ def parse(source: str) -> dict[str, Any]: if "material_type" not in accum: raise ParseError("missing required '% mechanics material' directive") - # Delegate subset validation to build_context. Anything outside the - # MVP subset raises UnsupportedError with a Plan B phase pointer. + # Delegate subset validation to build_context; anything outside the + # supported subset raises UnsupportedError. base = build_context( dim=accum["dim"], cell_type=accum["cell_type"], @@ -253,8 +253,8 @@ def parse(source: str) -> dict[str, Any]: coord_system=accum.get("coord_system", "cartesian"), integration=accum.get("integration", "full"), hourglass=accum.get("hourglass"), - # constitutive_latex P5-1: the `% mechanics fiber` directive supplies the - # fiber field, satisfying build_context's anisotropic-requires-fiber gate. + # The `% mechanics fiber` directive supplies the fiber field, + # satisfying build_context's anisotropic-requires-fiber gate. fiber_families=accum.get("fiber_families"), ) diff --git a/packages/mechdsl-core/src/mechdsl/integration/__init__.py b/packages/mechdsl-core/src/mechdsl/integration/__init__.py index 7695b29..98532dd 100644 --- a/packages/mechdsl-core/src/mechdsl/integration/__init__.py +++ b/packages/mechdsl-core/src/mechdsl/integration/__init__.py @@ -749,7 +749,6 @@ def _verify_ad_oracle_svk(params: dict) -> dict: "nu": float(params.get("nu", 0.25)), } else: - # Default: unit Lamé parameters mat_params = {"lam": 1.0, "mu": 1.0} n_samples = int(params.get("n_samples", 100)) @@ -867,8 +866,8 @@ def _verify_benchmark(params: dict) -> dict: rel_error = _coerce_finite(extras.get("relative_error")) reference_checked = rel_error is not None - # Tolerance: prefer the benchmark's own published bar, else 2% — the - # benchmark convergence tolerance from .claude/rules/tests.md. + # Tolerance: prefer the benchmark's own published bar, else default + # to 2% relative error. tol_raw = _coerce_finite(extras.get("tip_tolerance", extras.get("rel_error_tol"))) tolerance = tol_raw if tol_raw is not None else 0.02 diff --git a/packages/mechdsl-core/src/mechdsl/ir/element_factory.py b/packages/mechdsl-core/src/mechdsl/ir/element_factory.py index 7430abe..a74ec13 100644 --- a/packages/mechdsl-core/src/mechdsl/ir/element_factory.py +++ b/packages/mechdsl-core/src/mechdsl/ir/element_factory.py @@ -138,7 +138,7 @@ def create( ) # -- 2. Combination validation ----------------------------------- - # Reduced integration is currently hex8-only (Plan B §B5.4 / P5-4). + # Reduced integration is currently hex8-only. if integration == "reduced" and topology != "hex8": raise ValueError( f"Reduced integration is only implemented for hex8, got " @@ -152,7 +152,7 @@ def create( f"reduced integration; got integration={integration!r}. " f"See {_PLAN_REF} (§B5.5)." ) - # Flanagan-Belytschko is hex8-specific (Plan B §B5.5 / P5-5). + # Flanagan-Belytschko is hex8-specific. if hourglass == "flanagan_belytschko" and topology != "hex8": raise ValueError( f"Hourglass scheme 'flanagan_belytschko' is hex8-specific, " diff --git a/packages/mechdsl-core/src/mechdsl/ir/element_ir.py b/packages/mechdsl-core/src/mechdsl/ir/element_ir.py index 2fb93b4..fdb39a7 100644 --- a/packages/mechdsl-core/src/mechdsl/ir/element_ir.py +++ b/packages/mechdsl-core/src/mechdsl/ir/element_ir.py @@ -130,19 +130,18 @@ def gradient(self, xi: float, eta: float, zeta: float) -> NDArray: # --------------------------------------------------------------------------- -# Execution-contract enrichment (recovery-plan Phase 4 / R3.1 / P4-1). +# Execution-contract enrichment. # -# Pre-P4-1, downstream codegen and lowering re-derived facts about each -# element's reference volume, the constitutive call's input/output shapes, -# and the local force / tangent layout from a mix of `element_type` strings, -# `quadrature.n_points`, and hard-coded knowledge of MVP Hex8. P4-1 promotes -# those facts into four small frozen dataclasses that ride on `ElementIR` as -# optional fields with safe defaults so legacy callers continue working. +# Downstream codegen and lowering used to re-derive each element's reference +# volume, the constitutive call's input/output shapes, and the local force / +# tangent layout from `element_type` strings, `quadrature.n_points`, and +# hard-coded Hex8 knowledge. Those facts are promoted into four small frozen +# dataclasses that ride on `ElementIR` as optional fields with safe defaults +# so legacy callers continue working. # # The four contracts deliberately stay backend-agnostic — no Taichi-specific # slot names, no Voigt-specific field layouts that would leak into MFEM / -# MOOSE. Per the Phase 4 constraints: "Avoid backend-specific leakage into -# IR types." +# MOOSE. # --------------------------------------------------------------------------- @@ -390,14 +389,14 @@ class ElementIR: configuration: str = "reference" # "reference" (TL) or "current" (UL) integration_rule: IntegrationRule = IntegrationRule.FULL - # Recovery-plan P4-1 execution-contract enrichment. Optional; safe defaults. + # Execution-contract enrichment. Optional; safe defaults. geometry: GeometrySummary | None = None material_eval: MaterialEvalContract | None = None local_force: LocalForceDescriptor | None = None local_tangent: LocalTangentDescriptor | None = None - # constitutive_latex P5-1: per-element fiber-orientation field data carried - # down from ProblemIR.fiber_field (anisotropic models, HGO). One unit-ish + # Per-element fiber-orientation field data carried down from + # ProblemIR.fiber_field (anisotropic models, HGO). One unit-ish # direction per fiber family; None for isotropic problems. Held as a plain # data tuple so the Element IR stays decoupled from the Mechanics-IR # FiberFieldSpec type. This is the no-layer-bypass carry: frontend directive @@ -431,7 +430,7 @@ def __post_init__(self) -> None: f"got {type(self.integration_rule).__name__}. " "See Plan B phase B5 (§B5.4) for the integration-rule axis." ) - # Reduced integration is currently only implemented for Hex8 (Plan B §B5.4). + # Reduced integration is currently only implemented for Hex8. if self.integration_rule == IntegrationRule.REDUCED and self.element_type != "hex8": raise ValueError( f"Reduced integration is only implemented for hex8, got " @@ -440,7 +439,7 @@ def __post_init__(self) -> None: "Plan B phase B5." ) - # P4-1: enriched execution-contract consistency checks. Each runs only + # Enriched execution-contract consistency checks. Each runs only # when the optional descriptor is populated, so legacy callers that # leave the fields at None see no behaviour change. if self.geometry is not None and self.geometry.n_quad != self.quadrature.n_points: @@ -480,7 +479,7 @@ def __post_init__(self) -> None: ) # ------------------------------------------------------------------ - # Serialization (recovery-plan P4-1 / P4-5). + # Serialization. # # ElementIR carries numpy arrays via QuadratureRule and a basis-function # object; the round-trip serialization here is intentionally narrow — @@ -657,7 +656,7 @@ def create_hex8_element_ir( # --------------------------------------------------------------------------- -# Tet4 constructors (Plan B §B5.1) +# Tet4 constructors # --------------------------------------------------------------------------- @@ -711,7 +710,7 @@ def create_tet4_element_ir( # --------------------------------------------------------------------------- -# Tet10 constructors (Plan B §B5.2) +# Tet10 constructors # --------------------------------------------------------------------------- @@ -768,7 +767,7 @@ def create_tet10_element_ir( # --------------------------------------------------------------------------- -# Hex20 constructors (Plan B §B5.3) +# Hex20 constructors # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/src/mechdsl/ir/mechanics_ir.py b/packages/mechdsl-core/src/mechdsl/ir/mechanics_ir.py index f1ffab3..f2a56c5 100644 --- a/packages/mechdsl-core/src/mechdsl/ir/mechanics_ir.py +++ b/packages/mechdsl-core/src/mechdsl/ir/mechanics_ir.py @@ -75,9 +75,9 @@ class ElementType(Enum): """ HEX8 = "hex8" - TET4 = "tet4" # Plan B §B5.1 — experimental - TET10 = "tet10" # Plan B §B5.2 — experimental - HEX20 = "hex20" # Plan B §B5.3 — experimental + TET4 = "tet4" # experimental + TET10 = "tet10" # experimental + HEX20 = "hex20" # experimental class DynamicsMode(Enum): @@ -136,9 +136,8 @@ class BCType(Enum): # - a symbolic string ("t_bar", "1e3") referencing a legacy named load, # - a 3-component numeric vector specifying explicit components, or # - None for non-Neumann BCs. -# The numeric form was added in post_recovery_plan Phase 1 (P1-1) to allow -# directive-driven Neumann BCs ("--traction \"0 0 -1000\"") to round-trip -# through the IR without a separate symbolic-load registry. +# The numeric form lets directive-driven Neumann BCs ("--traction \"0 0 -1000\"") +# round-trip through the IR without a separate symbolic-load registry. TractionT = str | tuple[float, float, float] | None @@ -291,12 +290,12 @@ def from_dict(cls, d: dict[str, Any]) -> MaterialSpec: # --------------------------------------------------------------------------- -# Optional semantic enrichment dataclasses (recovery-plan Phase 3 / R2 / P3-1). +# Optional semantic enrichment dataclasses. # # These four small frozen dataclasses carry information that the original -# (Plan A) ProblemIR left implicit. They are *additive*: every field is -# optional with a safe default, so legacy callers continue working without -# source changes. +# ProblemIR left implicit. They are *additive*: every field is optional +# with a safe default, so legacy callers continue working without source +# changes. # --------------------------------------------------------------------------- @@ -498,11 +497,10 @@ def from_dict(cls, d: dict[str, Any]) -> ResidualContract: # Roles that the equation classifier may emit but that carry no committed -# constitutive meaning. Per the Phase 4 → Phase 5 handoff, a role of -# ``unknown`` (or ``None``) must NOT be inferred into a real constitutive -# role — it is recorded as an auxiliary definition so the serialized IR still -# explains that the compiler saw the equation without claiming to understand -# its physics. +# constitutive meaning. A role of ``unknown`` (or ``None``) must NOT be +# inferred into a real constitutive role — it is recorded as an auxiliary +# definition so the serialized IR still explains that the compiler saw the +# equation without claiming to understand its physics. # # ``auxiliary_definition`` is bucketed here because it is the classifier's own # label for "a definition with no committed physics" — the same semantic bucket @@ -561,17 +559,16 @@ def from_dict(cls, d: dict[str, Any]) -> LatexSemantics: # --------------------------------------------------------------------------- -# MVP-stable subset contract (recovery-plan Phase 3 / R2 / P3-4). +# MVP-stable subset contract. # # The "MVP-stable subset" is the set of `ProblemIR` configurations that the -# canonical compile path on the Taichi backend supports today (see -# `README.md` Support tiers and `dev/design_docs/04-MECHANICS-IR.md` §3.1). -# Configurations outside this subset may still construct successfully -# (`ProblemIR.__post_init__` keeps experimental enum values valid for -# in-tree research code), but `compile_latex(profile="mvp")` and other -# stability-promised entry points must reject them at the IR boundary so -# that the failure points at the IR rather than surfacing deep inside -# codegen / runtime. +# canonical compile path on the Taichi backend supports today (see the +# README.md support tiers). Configurations outside this subset may still +# construct successfully (`ProblemIR.__post_init__` keeps experimental enum +# values valid for in-tree research code), but `compile_latex(profile="mvp")` +# and other stability-promised entry points must reject them at the IR +# boundary so that the failure points at the IR rather than surfacing deep +# inside codegen / runtime. # # Keep this descriptor in lock-step with `ALLOWED_PROFILES` in # `packages/mechdsl-core/src/mechdsl/__init__.py` and with the support-tier @@ -630,7 +627,7 @@ class MvpStableSubset: ) -# Required parameter sets for MVP-stable material models, used by P3-5 +# Required parameter sets for MVP-stable material models, used by # construction-time validation. Models not listed here skip the check — # experimental constitutive models declare their own parameter contracts # elsewhere and will join this table as they enter the MVP-stable subset. @@ -690,42 +687,40 @@ class ProblemIR: configuration: Configuration | None = None dynamics_mode: DynamicsMode | None = None - # Recovery-plan Phase 3 (R2 / P3-1) optional semantic enrichment. - # All four default to a safe empty / None value so legacy callers - # continue working without source changes. + # Optional semantic enrichment. All four default to a safe empty / + # None value so legacy callers continue working without source changes. fields: tuple[FieldSpec, ...] = () domain: DomainSpec | None = None mesh_contract: MeshContract | None = None residual_contract: ResidualContract | None = None - # fgram Phase 5 (P5-1): LaTeX-derived semantic record. Optional and - # advisory — captures what the compiler understood from the LaTeX source - # (declared fields, constitutive roles, weak-form label, equation roles) - # so the serialized bundle can explain itself. None for IRs built without - # a LaTeX semantic source (legacy / programmatic callers). + # LaTeX-derived semantic record. Optional and advisory — captures what + # the compiler understood from the LaTeX source (declared fields, + # constitutive roles, weak-form label, equation roles) so the serialized + # bundle can explain itself. None for IRs built without a LaTeX semantic + # source (legacy / programmatic callers). latex_semantics: LatexSemantics | None = None - # constitutive_latex Phase 3 (P3-1): the LaTeX-derived symbolic energy - # model (PK2 stress + material tangent) when the constitutive law was - # derived from a strain-energy density rather than dispatched by model - # name. This is the carrier that lets codegen emit from the derived - # energy instead of the hard-coded named-model switch — replacing the - # advisory-only `latex_semantics` path. Appended last, defaulting to - # ``None`` so the ~21 direct / 61 transitive ProblemIR constructors pass - # no new argument and are unaffected. Held as a Python object (SymPy - # expressions inside) and deliberately NOT serialized into `to_dict`: - # SymPy does not JSON-encode cleanly, and the codegen contract reads it - # off the in-memory IR / ArtifactBundle, not the serialized dict. + # The LaTeX-derived symbolic energy model (PK2 stress + material + # tangent) when the constitutive law was derived from a strain-energy + # density rather than dispatched by model name. This is the carrier that + # lets codegen emit from the derived energy instead of the hard-coded + # named-model switch. Appended last, defaulting to ``None`` so existing + # ProblemIR constructors pass no new argument and are unaffected. Held as + # a Python object (SymPy expressions inside) and deliberately NOT + # serialized into `to_dict`: SymPy does not JSON-encode cleanly, and the + # codegen contract reads it off the in-memory IR / ArtifactBundle, not + # the serialized dict. derived_energy: EnergyModel | None = None - # constitutive_latex Phase 5 (P5-1): per-element fiber-orientation field - # data for anisotropic models (HGO). Field data, NOT a scalar material - # param — carried separately from MaterialSpec.params and flowed through to - # the Element IR. Appended last, defaulting to None so every existing - # ProblemIR constructor is unaffected. + # Per-element fiber-orientation field data for anisotropic models + # (HGO). Field data, NOT a scalar material param — carried separately + # from MaterialSpec.params and flowed through to the Element IR. Appended + # last, defaulting to None so every existing ProblemIR constructor is + # unaffected. fiber_field: FiberFieldSpec | None = None - # Formulation → Configuration mapping (Plan B §B1.5). + # Formulation → Configuration mapping. _FORMULATION_TO_CONFIG: ClassVar[dict[Formulation, Configuration]] = { Formulation.TOTAL_LAGRANGIAN: Configuration.REFERENCE, Formulation.UPDATED_LAGRANGIAN: Configuration.CURRENT, @@ -733,9 +728,9 @@ class ProblemIR: def __post_init__(self) -> None: """Validate at construction time.""" - # Auto-infer configuration from formulation when not explicitly provided. - # Plan B §B1.5: "by flipping one directive" — callers should not need - # to manually pass configuration when the formulation implies it. + # Auto-infer configuration from formulation when not explicitly + # provided — callers should not need to manually pass configuration when + # the formulation implies it. if self.configuration is None: inferred = self._FORMULATION_TO_CONFIG.get(self.formulation) if inferred is None: @@ -747,15 +742,14 @@ def __post_init__(self) -> None: assert self.configuration is not None # guaranteed by auto-inference above - # Auto-infer dynamics_mode to STATIC when omitted (Plan B §B7). - # Matches the `configuration` auto-infer pattern above: a ProblemIR - # without `dynamics_mode` behaves identically to Plan A (Newton solve). + # Auto-infer dynamics_mode to STATIC when omitted. Matches the + # `configuration` auto-infer pattern above: a ProblemIR without + # `dynamics_mode` gets the implicit static Newton solve. if self.dynamics_mode is None: object.__setattr__(self, "dynamics_mode", DynamicsMode.STATIC) assert self.dynamics_mode is not None # guaranteed by auto-inference above - # dim must be 3 for MVP if self.dim != 3: raise ValueError( f"dim={self.dim} not supported. " @@ -790,7 +784,6 @@ def __post_init__(self) -> None: f"Element type {self.element_type.value!r} not supported. " "Additional element families are planned for Plan B phase B5." ) - # material model must be known if self.material.model not in ( "svk", "j2_power_law", @@ -810,7 +803,6 @@ def __post_init__(self) -> None: # need at least one boundary if not self.boundaries: raise ValueError("At least one boundary condition required.") - # coordinate tuples must match dim if len(self.coord_spatial) != self.dim: raise ValueError( f"Expected {self.dim} spatial coordinates, got {len(self.coord_spatial)}" @@ -819,7 +811,7 @@ def __post_init__(self) -> None: raise ValueError( f"Expected {self.dim} material coordinates, got {len(self.coord_material)}" ) - # M4: check BC names against declared regions when regions are provided + # Check BC names against declared regions when regions are provided. if self.declared_regions is not None: for bc in self.boundaries: if bc.name not in self.declared_regions: @@ -830,13 +822,13 @@ def __post_init__(self) -> None: ) # ------------------------------------------------------------------ - # P3-5: targeted validation for semantics that were previously + # Targeted validation for semantics that were previously # implicit. Each block below converts a class of silent acceptance # (a malformed IR that used to surface as a cryptic codegen / runtime # error) into a clear ValueError raised at construction time. # ------------------------------------------------------------------ - # P3-5a: BC region names must be unique across the boundary tuple. + # BC region names must be unique across the boundary tuple. # Two BCs with the same `name` would either silently overwrite each # other or produce conflicting load assemblers downstream. seen_bc_names: set[str] = set() @@ -850,7 +842,7 @@ def __post_init__(self) -> None: ) seen_bc_names.add(bc.name) - # P3-5b: BC component indices must lie in [0, dim). Out-of-range + # BC component indices must lie in [0, dim). Out-of-range # values used to flow through to codegen and produce off-by-one # array slices or silent zero rows. for bc in self.boundaries: @@ -862,7 +854,7 @@ def __post_init__(self) -> None: f"valid indices are 0..{self.dim - 1}." ) - # P3-5c: spatial and material coordinate names must be unique. + # Spatial and material coordinate names must be unique. # Repeated names produce ambiguous metadata downstream. if len(set(self.coord_spatial)) != len(self.coord_spatial): raise ValueError( @@ -875,7 +867,7 @@ def __post_init__(self) -> None: "material coordinate labels must be unique." ) - # P3-5d: declared field names must be unique. Two FieldSpec entries + # Declared field names must be unique. Two FieldSpec entries # with the same `name` are always a configuration bug. if self.fields: seen_field_names: set[str] = set() @@ -887,7 +879,7 @@ def __post_init__(self) -> None: ) seen_field_names.add(f.name) - # P3-5e: BC `field_name` must reference a declared field when + # BC `field_name` must reference a declared field when # `fields` is populated. Catches typos like `field_name="ux"` vs # `FieldSpec(name="u")`. declared_field_names = {f.name for f in self.fields} @@ -900,7 +892,7 @@ def __post_init__(self) -> None: f"FieldSpec with that name or correct the BC." ) - # P3-5f required-params check moved to `assert_mvp_stable()` — see + # The required-params check lives in `assert_mvp_stable()` — see # that method for the rationale. In-tree research code builds # minimal IRs for shape testing without touching the constitutive # path, so validating required params at every IR construction would @@ -908,12 +900,12 @@ def __post_init__(self) -> None: # compile-path boundary (`compile_latex(profile="mvp")` calls # `assert_mvp_stable()`). - # constitutive_latex P3-1: validate the derived-energy carrier at - # construction time (IR discipline). A no-op when None (the default, - # so every legacy constructor is unaffected); when present it must be - # a fully-formed energy model carrying symbolic PK2 stress and tangent - # so codegen can emit from it. Duck-typed to avoid importing the heavy - # symbolic.energy module at IR-construction time. + # Validate the derived-energy carrier at construction time (IR + # discipline). A no-op when None (the default, so every legacy + # constructor is unaffected); when present it must be a fully-formed + # energy model carrying symbolic PK2 stress and tangent so codegen can + # emit from it. Duck-typed to avoid importing the heavy symbolic.energy + # module at IR-construction time. if self.derived_energy is not None: de = self.derived_energy # Three recognised derivation shapes, each carrying the symbolic @@ -935,10 +927,10 @@ def __post_init__(self) -> None: "(HGO); got an object missing all of their required attributes." ) - # constitutive_latex P5-1: validate the fiber-field carrier at - # construction time (IR discipline). A no-op when None (the default). - # Duck-typed to keep this cheap; FiberFieldSpec already validates its - # own families in its __post_init__. + # Validate the fiber-field carrier at construction time (IR + # discipline). A no-op when None (the default). Duck-typed to keep this + # cheap; FiberFieldSpec already validates its own families in its + # __post_init__. if self.fiber_field is not None and not hasattr(self.fiber_field, "families"): raise ValueError( "fiber_field must be a mechdsl.ir.mechanics_ir.FiberFieldSpec " @@ -960,9 +952,9 @@ def to_dict(self) -> dict[str, Any]: "declared_regions": sorted(self.declared_regions) if self.declared_regions else None, "configuration": self.configuration.value, "dynamics_mode": self.dynamics_mode.value, - # Recovery-plan Phase 3 enrichment fields. Always emitted so - # consumers can round-trip them; legacy dicts without these keys - # are accepted in `from_dict` and rebuild with safe defaults. + # Enrichment fields. Always emitted so consumers can round-trip + # them; legacy dicts without these keys are accepted in `from_dict` and + # rebuild with safe defaults. "fields": [f.to_dict() for f in self.fields], "domain": self.domain.to_dict() if self.domain is not None else None, "mesh_contract": ( @@ -971,15 +963,15 @@ def to_dict(self) -> dict[str, Any]: "residual_contract": ( self.residual_contract.to_dict() if self.residual_contract is not None else None ), - # fgram Phase 5 (P5-1) LaTeX-derived semantic record. Always - # emitted (None when absent) so consumers can round-trip it; - # legacy dicts without the key rebuild with `latex_semantics=None`. + # LaTeX-derived semantic record. Always emitted (None when absent) + # so consumers can round-trip it; legacy dicts without the key rebuild + # with `latex_semantics=None`. "latex_semantics": ( self.latex_semantics.to_dict() if self.latex_semantics is not None else None ), - # constitutive_latex P5-1: fiber field data. Emitted only when - # present so every existing (fiber-less) golden stays byte-identical; - # from_dict rebuilds None when the key is absent. + # Fiber field data. Emitted only when present so every existing + # (fiber-less) golden stays byte-identical; from_dict rebuilds None when + # the key is absent. **({"fiber_field": self.fiber_field.to_dict()} if self.fiber_field is not None else {}), } @@ -994,16 +986,16 @@ def from_dict(cls, d: dict[str, Any]) -> ProblemIR: raw_regions = d.get("declared_regions") declared_regions = frozenset(raw_regions) if raw_regions is not None else None # configuration is optional — when missing, auto-inferred from - # formulation in __post_init__ (Plan B §B1.5). Pre-P1-1 TL goldens - # without a "configuration" key auto-infer to REFERENCE (correct). + # formulation in __post_init__. Legacy TL goldens without a + # "configuration" key auto-infer to REFERENCE (correct). raw_cfg = d.get("configuration") configuration = Configuration(raw_cfg) if raw_cfg is not None else None - # dynamics_mode is optional — Plan B §B7 (P7-1). Legacy dicts without - # the key auto-infer to STATIC in __post_init__ (Plan A default). + # dynamics_mode is optional — legacy dicts without the key + # auto-infer to STATIC in __post_init__. raw_dyn = d.get("dynamics_mode") dynamics_mode = DynamicsMode(raw_dyn) if raw_dyn is not None else None - # Phase-3 enrichment fields (recovery R2 / P3-1). All optional; missing - # keys rebuild as empty / None. + # Enrichment fields. All optional; missing keys rebuild as empty / + # None. raw_fields = d.get("fields", ()) fields_tuple = tuple(FieldSpec.from_dict(f) for f in raw_fields) if raw_fields else () raw_domain = d.get("domain") @@ -1012,8 +1004,8 @@ def from_dict(cls, d: dict[str, Any]) -> ProblemIR: mesh_contract = MeshContract.from_dict(raw_mesh) if raw_mesh else None raw_residual = d.get("residual_contract") residual_contract = ResidualContract.from_dict(raw_residual) if raw_residual else None - # fgram Phase 5 (P5-1) LaTeX semantic record. Optional; missing key - # rebuilds as None so every existing golden continues to round-trip. + # LaTeX semantic record. Optional; missing key rebuilds as None so + # every existing golden continues to round-trip. raw_latex = d.get("latex_semantics") latex_semantics = LatexSemantics.from_dict(raw_latex) if raw_latex else None raw_fiber = d.get("fiber_field") @@ -1038,10 +1030,10 @@ def from_dict(cls, d: dict[str, Any]) -> ProblemIR: ) # ------------------------------------------------------------------ - # Boundary / domain semantic helpers (recovery-plan P3-3). + # Boundary / domain semantic helpers. # - # Pre-P3-3, every downstream layer (lowering, codegen, solver, mesh - # validation) re-derived the same fact: "the BC name equals the mesh + # Every downstream layer (lowering, codegen, solver, mesh validation) + # used to re-derive the same fact: "the BC name equals the mesh # boundary tag". The two helpers below centralize that assumption on # the IR — the semantic center — so consumers read a single source of # truth instead of scattering the implicit contract across layers. @@ -1079,7 +1071,7 @@ def derived_mesh_contract(self) -> MeshContract: return MeshContract(region_tags=self.required_region_tags()) # ------------------------------------------------------------------ - # Frontend context-dict adapter (recovery-plan P3-2). + # Frontend context-dict adapter. # ------------------------------------------------------------------ @classmethod @@ -1150,7 +1142,7 @@ def _fiber_field_from_context(ctx: dict[str, Any]) -> FiberFieldSpec | None: return FiberFieldSpec(families=tuple(families)) # ------------------------------------------------------------------ - # LaTeX-semantic adapter (fgram Phase 5 / P5-1). + # LaTeX-semantic adapter. # ------------------------------------------------------------------ @classmethod @@ -1337,7 +1329,7 @@ def _equation_lhs_and_role(eq: Any) -> tuple[str, str]: return lhs, role # ------------------------------------------------------------------ - # MVP-stable subset contract (recovery-plan P3-4). + # MVP-stable subset contract. # ------------------------------------------------------------------ def assert_mvp_stable(self) -> None: @@ -1407,7 +1399,7 @@ def assert_mvp_stable(self) -> None: f"{tuple(c.value for c in MVP_STABLE_SUBSET.configurations)}. " "Current-configuration kinematics are planned for Plan B phase B1." ) - # P3-5f: known MVP material models require their full parameter set. + # Known MVP material models require their full parameter set. # Lives here (rather than in `__post_init__`) because in-tree # research code constructs minimal IRs for shape-only testing — the # check belongs to the production-readiness contract enforced by diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/README.md b/packages/mechdsl-core/src/mechdsl/lawgen/README.md new file mode 100644 index 0000000..272c390 --- /dev/null +++ b/packages/mechdsl-core/src/mechdsl/lawgen/README.md @@ -0,0 +1,32 @@ +# MechDSL lawgen — scope and prior-art statement + +MechDSL lawgen is an **independent implementation**. It does **not** implement +or accept MFront/TFEL DSL syntax, and it does **not** parse, translate, or +reproduce TFEL/MFront source code. MFront is acknowledged only as prior art +that informed the general idea of authoring constitutive laws in a compact +form and emitting runtime code from them. + +## What lawgen actually is + +The lawgen pipeline turns a small, self-contained YAML law spec into a Taichi +carrier for the NumerixWeave `ticonstit` library: + +- **Own YAML law schema.** A law declares `name`, `parameters` (material + constants), `variables` (free-variable bindings such as `p`, `edot`, `T`), + and `expressions` with the three required roles `R`/`H`/`Q`. See + `../../../../laws/plasticity/swift_voce.yaml` for a worked example. +- **Restricted SymPy expression parser.** Each `R`/`H`/`Q` string is parsed + against the declared symbols with a non-eval parser and a restricted math + allow-list — never `sympify`/`eval` on untrusted YAML (`cli.py`). +- **Deterministic SymPy → Taichi lowering.** A bespoke printer with + deterministic common-subexpression elimination and injected numerical guards + turns each scalar `sympy.Expr` into Taichi source + (`sympy_to_taichi.py`, `guard_transforms.py`), gated by pre-emission JIT + budgets (`budgets.py`). +- **ticonstit-specific carrier contract.** The emitted `@ti.func` carrier + class, generated tests, and provenance manifest target the frozen + `ticonstit` contract (`contracts.py`, `carrier_emitter.py`, `manifest.py`), + stamped `mechdsl-lawgen/`. + +The only `--target` the CLI accepts is `ticonstit`; there is no MFront target +or MFront input path anywhere in the pipeline. diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/REUSE.md b/packages/mechdsl-core/src/mechdsl/lawgen/REUSE.md index ed4dbbe..00e2e04 100644 --- a/packages/mechdsl-core/src/mechdsl/lawgen/REUSE.md +++ b/packages/mechdsl-core/src/mechdsl/lawgen/REUSE.md @@ -1,6 +1,6 @@ # lawgen Reuse Map — MechDSL modules the `ticonstit` target composes -**Task P1-3** (MFront-mimic Cycle M0, Phase 1). Doc-only, but load-bearing: +Part of the MechDSL lawgen pipeline. 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 @@ -8,7 +8,7 @@ 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 +(`CEmM2/NumerixWeave`), *not* MechDSL — it is a read-only reference (gitignored there), so every scaffold path in this doc is prefixed `NumerixWeave repo:`. --- diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/__init__.py b/packages/mechdsl-core/src/mechdsl/lawgen/__init__.py index 48e4f67..1f2d6f8 100644 --- a/packages/mechdsl-core/src/mechdsl/lawgen/__init__.py +++ b/packages/mechdsl-core/src/mechdsl/lawgen/__init__.py @@ -1,9 +1,12 @@ """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. +Part of the MechDSL lawgen pipeline (YAML law spec → restricted SymPy → +Taichi carrier). 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. + +MechDSL lawgen is an independent implementation; it does not implement or +translate MFront/TFEL. See ``lawgen/README.md`` for the scope statement. """ from __future__ import annotations diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/budgets.py b/packages/mechdsl-core/src/mechdsl/lawgen/budgets.py index bf67677..fd56752 100644 --- a/packages/mechdsl-core/src/mechdsl/lawgen/budgets.py +++ b/packages/mechdsl-core/src/mechdsl/lawgen/budgets.py @@ -1,6 +1,7 @@ """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). +Part of the MechDSL lawgen pipeline (YAML law spec → restricted SymPy → +Taichi carrier). This module is the *gate* that stands between the deterministic lowerer (:mod:`mechdsl.lawgen.sympy_to_taichi`, P2-1) and any Taichi emission @@ -66,10 +67,10 @@ 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. +# 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 @@ -127,7 +128,7 @@ def for_budget( # --------------------------------------------------------------------------- -# Module-level pure counters (independently testable; reused by P2-3/P2-4/P3). +# Module-level pure counters (independently testable). # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/carrier_emitter.py b/packages/mechdsl-core/src/mechdsl/lawgen/carrier_emitter.py index 9d10255..0f72bc8 100644 --- a/packages/mechdsl-core/src/mechdsl/lawgen/carrier_emitter.py +++ b/packages/mechdsl-core/src/mechdsl/lawgen/carrier_emitter.py @@ -1,6 +1,7 @@ """Spec-driven Taichi carrier-class emitter (Task P4-1). -MFront-mimic Cycle M0, Phase 4 (``dev/plans/mfront_cycleM0.md`` lines 114-116). +Part of the MechDSL lawgen pipeline (YAML law spec → restricted SymPy → +Taichi carrier). 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 @@ -89,13 +90,13 @@ def __init__(self, params_dict, ti_type=ti.f64): ... # binds self. # 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 consumer'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`` +# spec binds ``p`` (accumulated plastic strain); the consumer'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"} @@ -104,7 +105,7 @@ def __init__(self, params_dict, ti_type=ti.f64): ... # binds self. _I1 = " " _I2 = " " -# The ``ti.f64`` yield-scale annotation Cycle 0's get_R/get_dR use on the +# The ``ti.f64`` yield-scale annotation get_R/get_dR use on the # ``yield_scale`` argument. _YIELD_SCALE_SIG = "yield_scale: ti.f64 = 1.0" @@ -377,7 +378,7 @@ def emit_carrier( 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) -------------- + # --- 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. @@ -416,8 +417,8 @@ def emit_carrier( 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). +# Modules the generated runtime carrier must never import: the offline +# generator (SymPy, MechDSL) and the consumer (ticonstit / NumerixWeave). _FORBIDDEN_IMPORT_TOKENS: tuple[str, ...] = ("sympy", "mechdsl", "ticonstit", "numerixweave") diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/cli.py b/packages/mechdsl-core/src/mechdsl/lawgen/cli.py index 1521be8..84a4585 100644 --- a/packages/mechdsl-core/src/mechdsl/lawgen/cli.py +++ b/packages/mechdsl-core/src/mechdsl/lawgen/cli.py @@ -1,6 +1,7 @@ -"""``mechdsl-lawgen`` command-line entry point (Task P1-2). +"""``mechdsl-lawgen`` command-line entry point. -MFront-mimic Cycle M0, Phase 1 (``dev/plans/mfront_cycleM0.md`` lines 59-62). +Part of the MechDSL lawgen pipeline (YAML law spec → restricted SymPy → +Taichi carrier). This is the *skeleton* CLI: it wires the ``mechdsl-lawgen compile --target ticonstit --out `` surface, parses a law YAML into a @@ -55,8 +56,8 @@ ) 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. +# The only ``--target`` value currently accepted. 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 @@ -65,12 +66,12 @@ _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). +# typo or an attempt to smuggle unexpected data — rejected. _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). +# The math functions an R/H/Q expression may call. A deliberately +# conservative set for the CLI front-end; the printer owns the full Taichi +# allow-list (what it can actually lower). _ALLOWED_FUNCTIONS: dict[str, object] = { "exp": sp.exp, "log": sp.log, @@ -87,7 +88,7 @@ "sign": sp.sign, } -# The *only* global namespace expressions are parsed against (F1). We do NOT +# The *only* global namespace expressions are parsed against. 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 @@ -163,7 +164,7 @@ def load_carrier_source(law_path: Path) -> tuple[PlasticityCarrierSpec, dict[str f"{list(_REQUIRED_KEYS)}, got {type(doc).__name__}." ) - # F6: reject any unexpected top-level key up front (typos, smuggled data). + # 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)}") @@ -175,7 +176,7 @@ def load_carrier_source(law_path: Path) -> tuple[PlasticityCarrierSpec, dict[str 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 + # 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") @@ -183,7 +184,7 @@ def load_carrier_source(law_path: Path) -> tuple[PlasticityCarrierSpec, dict[str 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 + # 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: @@ -201,7 +202,7 @@ def load_carrier_source(law_path: Path) -> tuple[PlasticityCarrierSpec, dict[str 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. + # 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( @@ -318,7 +319,7 @@ def _parse_expr(raw: object, *, role: str, locals_table: dict[str, sp.Symbol]) - 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 + # 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: @@ -490,8 +491,7 @@ def _emit( 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)"``. + # source_hash. Built from the raw YAML ``R`` string with the ``"R = "`` prefix. input_formula = f"R = {raw_expressions['R']}" source_hash = compute_input_formula_hash(input_formula) @@ -518,8 +518,8 @@ def _emit( # 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 + # parameter list). A `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, ) @@ -555,9 +555,9 @@ def _emit( 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). +# The runtime contract the SwiftVoce carrier implements (the ``target_contract`` +# recorded in the manifest). 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 diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/contracts.py b/packages/mechdsl-core/src/mechdsl/lawgen/contracts.py index 9be130c..3787d8d 100644 --- a/packages/mechdsl-core/src/mechdsl/lawgen/contracts.py +++ b/packages/mechdsl-core/src/mechdsl/lawgen/contracts.py @@ -1,15 +1,15 @@ """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). +Part of the MechDSL lawgen pipeline (YAML law spec → restricted SymPy → +Taichi carrier). -This module defines the two frozen dataclasses that every downstream Phase 2 -task and the Phase 4 end-to-end test consume: +This module defines the two frozen dataclasses that every downstream lowerer +task and the 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. + type, and the six JIT budget knobs. The budget-knob defaults MUST stay in + lock-step with ``budgets.py`` — which 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``). @@ -30,7 +30,7 @@ # 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 +# sp.Symbol)``. SymPy is a first-class mechdsl-core dependency, so the # runtime import is cheap and expected. import sympy as sp @@ -46,10 +46,10 @@ # --------------------------------------------------------------------------- TICONSTIT_CONTRACT_ID: str = "ticonstit.plasticity_carrier.v1" -"""Canonical, frozen ticonstit emission-contract id (plan line 55).""" +# Canonical, frozen ticonstit emission-contract id. TICONSTIT_PACKAGE: str = "ticonstit.generated" -"""Default Python package for ticonstit-generated code (plan line 55).""" +# Default Python package for ticonstit-generated code. # --------------------------------------------------------------------------- @@ -70,17 +70,16 @@ class TiconstitTarget: (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. + The six budget-knob defaults are defined by the MechDSL lawgen target + contract and are frozen: ``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) --- + # --- JIT budget knobs (defaults frozen) --- max_expr_ops: int = 400 max_cse_temps_per_func: int = 96 max_func_lines: int = 220 @@ -89,7 +88,7 @@ class TiconstitTarget: 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 + # 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) @@ -104,7 +103,7 @@ def __post_init__(self) -> None: 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`` + # 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 ( @@ -224,7 +223,7 @@ def __post_init__(self) -> None: 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 + # 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 @@ -252,7 +251,7 @@ def __post_init__(self) -> None: f"sympy.Symbol, got {type(sym).__name__} {sym!r}." ) - # F5: ``monotone_check`` is an emit-time flag, not a SymPy object; it must + # ``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: diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/diagnostics.py b/packages/mechdsl-core/src/mechdsl/lawgen/diagnostics.py index ac4e09d..84bbea2 100644 --- a/packages/mechdsl-core/src/mechdsl/lawgen/diagnostics.py +++ b/packages/mechdsl-core/src/mechdsl/lawgen/diagnostics.py @@ -1,6 +1,7 @@ """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). +Part of the MechDSL lawgen pipeline (YAML law spec → restricted SymPy → +Taichi carrier). 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`, @@ -160,7 +161,7 @@ def __init__(self, diagnostics: Iterable[LawgenDiagnostic]) -> None: 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"). + # ``err.args`` sees all problems. super().__init__(message, *collected) @staticmethod diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/guard_transforms.py b/packages/mechdsl-core/src/mechdsl/lawgen/guard_transforms.py index 6fbb6f0..678e69c 100644 --- a/packages/mechdsl-core/src/mechdsl/lawgen/guard_transforms.py +++ b/packages/mechdsl-core/src/mechdsl/lawgen/guard_transforms.py @@ -1,9 +1,9 @@ """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. +Part of the MechDSL lawgen pipeline (YAML law spec → restricted SymPy → +Taichi carrier). Correctness-critical: the guards emitted here must match the +hand-authored guards in the reference ``swift_voce.py`` so the +numerical-equivalence check (``rtol=1e-10``) holds. Mechanism — a SymPy-tree rewrite pass with stand-in marker nodes ---------------------------------------------------------------- @@ -229,6 +229,6 @@ def _guard_pow(base: sp.Expr, exp: sp.Expr) -> sp.Expr: 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. + # 4. Non-negative integer power: no floor. Exact, and the printer 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 index e95227c..afa3b90 100644 --- a/packages/mechdsl-core/src/mechdsl/lawgen/manifest.py +++ b/packages/mechdsl-core/src/mechdsl/lawgen/manifest.py @@ -1,6 +1,7 @@ """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). +Part of the MechDSL lawgen pipeline (YAML law spec → restricted SymPy → +Taichi carrier). :func:`emit_manifest` produces one *laws entry* — and, via :func:`write_manifest`, merges it into the ``{"_schema": {...}, "laws": [...]}`` file that @@ -66,9 +67,9 @@ "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. +# 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 the byte-compare tests check against. LAWS_ENTRY_FIELDS: tuple[str, ...] = ( "name", "kind", @@ -81,8 +82,8 @@ "tests", ) -#: ``generated_by`` value for M0. Cycle 0 (hand-authored) used -#: ``"mfront_mimic Cycle 0 (hand-authored)"``; the MechDSL lawgen emitter stamps +#: ``generated_by`` value. Legacy hand-authored carriers used a placeholder +#: generator tag; the MechDSL lawgen emitter now 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}" @@ -151,7 +152,7 @@ def formula_matches_spec(input_formula: str, spec: PlasticityCarrierSpec) -> boo 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 re # identifier tokenising for the parse — not a codegen path import sympy as sp # local import: only the opt-in consistency check needs SymPy @@ -334,8 +335,8 @@ def emit_manifest( "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. + # Invariant: the entry key set is exactly the Cycle 0 laws-entry field + # set, guarding against future field drift. 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)}." diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/sympy_to_taichi.py b/packages/mechdsl-core/src/mechdsl/lawgen/sympy_to_taichi.py index 2efd0dd..cd84bf9 100644 --- a/packages/mechdsl-core/src/mechdsl/lawgen/sympy_to_taichi.py +++ b/packages/mechdsl-core/src/mechdsl/lawgen/sympy_to_taichi.py @@ -1,6 +1,7 @@ """Deterministic SymPy → Taichi scalar-expression lowerer (Task P2-1). -MFront-mimic Cycle M0, Phase 2 (``dev/plans/mfront_cycleM0.md`` lines 76-78). +Part of the MechDSL lawgen pipeline (YAML law spec → restricted SymPy → +Taichi carrier). 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 @@ -65,18 +66,18 @@ if TYPE_CHECKING: from collections.abc import Iterable, Sequence -# Small-integer ``Pow`` inlining threshold (P2-4). +# Small-integer ``Pow`` inlining threshold. # # ``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. +# exponent keeps ``ti.pow`` / ``**`` (guarded by the guard-injection pass). +# ``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 a large unrolled +# multiplication. SMALL_INT_POW_LIMIT: int = 4 @@ -116,7 +117,7 @@ def _inlines_to_product(item: sp.Basic) -> bool: # * ``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 +# * ``lawgen/cli._ALLOWED_FUNCTIONS`` — the 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. @@ -126,9 +127,9 @@ def _inlines_to_product(item: sp.Basic) -> bool: # 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. +# Today this map and ``cli._ALLOWED_FUNCTIONS`` are two aligned literals; the +# alignment is asserted in the tests. +# --------------------------------------------------------------------------- MATH_TO_TAICHI: dict[str, str] = { "exp": "ti.exp", "log": "ti.log", @@ -684,7 +685,7 @@ def lower_expression( 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 + # Pre-pass (collect-all): 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. @@ -707,7 +708,7 @@ def lower_expression( 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). + # CSE call in lawgen. replacements, reduced = sp.cse(expr_list, order="canonical") temporaries = tuple( diff --git a/packages/mechdsl-core/src/mechdsl/lawgen/test_emitter.py b/packages/mechdsl-core/src/mechdsl/lawgen/test_emitter.py index 90a074e..09e5032 100644 --- a/packages/mechdsl-core/src/mechdsl/lawgen/test_emitter.py +++ b/packages/mechdsl-core/src/mechdsl/lawgen/test_emitter.py @@ -1,6 +1,7 @@ """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). +Part of the MechDSL lawgen pipeline (YAML law spec → restricted SymPy → +Taichi carrier). :func:`emit_tests` writes a **self-contained, valid-Python** pytest file that exercises one :class:`~mechdsl.lawgen.contracts.PlasticityCarrierSpec`. The file @@ -98,10 +99,11 @@ #: 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). +# Relative tolerance for the FD-vs-analytic derivative comparison. ``1e-5`` is +# standard central-difference precision — deliberately looser than the symbolic +# 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 @@ -111,8 +113,7 @@ # 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``: +# its derivative — matching ``swift_voce.py``'s ``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``; @@ -220,7 +221,7 @@ def emit_tests( 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 + # Lowering ``R`` here is the fail-loud gate: 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. diff --git a/packages/mechdsl-core/src/mechdsl/lib/plasticity_mixed.py b/packages/mechdsl-core/src/mechdsl/lib/plasticity_mixed.py index 8fc6b3e..60b3b61 100644 --- a/packages/mechdsl-core/src/mechdsl/lib/plasticity_mixed.py +++ b/packages/mechdsl-core/src/mechdsl/lib/plasticity_mixed.py @@ -269,8 +269,9 @@ def radial_return_mixed( # ||2*mu*dl*nf||_eq = 3*mu*dl, the Prager back-stress advances by # ||(2/3)*H_kin*dl*nf||_eq = H_kin*dl, and the radius grows by # sigma_y(alpha+dl) - sigma_y(alpha) — so ||xi||_eq returns exactly to the - # (expanded) yield surface. Same flow-normal convention as P6-2; using - # xi/||xi||_eq directly would mis-scale both the return and the back-stress. + # (expanded) yield surface. Same flow-normal convention as the kinematic + # return map; using xi/||xi||_eq directly would mis-scale both the return + # and the back-stress. nf = 1.5 * xi_trial / xi_eq_trial S_dev_updated = S_dev_trial - 2.0 * mu * dl * nf diff --git a/packages/mechdsl-core/src/mechdsl/lib/tensor_ops.py b/packages/mechdsl-core/src/mechdsl/lib/tensor_ops.py index 150c651..48ab0ea 100644 --- a/packages/mechdsl-core/src/mechdsl/lib/tensor_ops.py +++ b/packages/mechdsl-core/src/mechdsl/lib/tensor_ops.py @@ -69,7 +69,7 @@ def deformation_gradient(grad_u: Mat33) -> Mat33: # --------------------------------------------------------------------------- -# Updated Lagrangian (Plan B §B1.1) primitives +# Updated Lagrangian primitives # # These mirror their reference-configuration counterparts used by the TL path: # J0 = X^T @ dN/dxi (reference Jacobian) @@ -79,7 +79,7 @@ def deformation_gradient(grad_u: Mat33) -> Mat33: # (deformed) element nodes ``x = X + u``. Both helpers are shape-generic so # they work for any element type, not just Hex8 — the per-element assembler # passes the appropriate ``dN_dxi`` table from ``mechdsl.codegen.hex8_tables`` -# (or its element-specific equivalent in later Plan B phases). +# (or its element-specific equivalent). # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/src/mechdsl/lowering/einsum_extract.py b/packages/mechdsl-core/src/mechdsl/lowering/einsum_extract.py index de214db..bcd52d1 100644 --- a/packages/mechdsl-core/src/mechdsl/lowering/einsum_extract.py +++ b/packages/mechdsl-core/src/mechdsl/lowering/einsum_extract.py @@ -109,12 +109,12 @@ def extract_einsum_specs(element_ir: ElementIR) -> dict[str, EinsumSpec]: # --------------------------------------------------------------------------- -# Matrix-free tangent matvec (PlanJune14 P3-1) +# Matrix-free tangent matvec # --------------------------------------------------------------------------- # # The ``tangent_matvec`` spec above is the *full* per-quadrature element # tangent stiffness ``K(q,a,i,b,j) = dN(q,a,I) A(q,i,I,j,J) dN(q,b,J)``. The -# matrix-free operator (D-A: never store element tangents) instead applies +# matrix-free operator (never store element tangents) instead applies # that tangent directly to a direction field ``v(b,j)`` at each matvec: # # Kv(q,a,i) = sum_{b,J,j,I} dN(q,a,I) A(q,i,I,j,J) dN(q,b,J) v(b,j) @@ -125,12 +125,13 @@ def extract_einsum_specs(element_ir: ElementIR) -> dict[str, EinsumSpec]: # collapsing the rank-5 K(q,a,i,b,j) intermediate to a rank-3 per-qp result # and keeping the unrolled-line count well inside the @ti.func budget. # -# This is the production replacement for the PJ-1 hand-written spike tangent -# kernel. P3-2 consumes the resulting ContractionPlan to emit the matvec -# @ti.kernel; nothing here touches the backend printer. +# This is the production replacement for the hand-written spike tangent +# kernel. The kernel emitter consumes the resulting ContractionPlan to emit +# the matvec @ti.kernel; nothing here touches the backend printer. -# Public name of the matrix-free tangent matvec contraction. P3-2 keys on -# this when locating the matvec plan among an element's contraction plans. +# Public name of the matrix-free tangent matvec contraction. The kernel +# emitter keys on this when locating the matvec plan among an element's +# contraction plans. TANGENT_MATVEC_APPLY_NAME = "tangent_matvec_apply" # The matrix-free tangent matvec subscripts: apply the consistent tangent diff --git a/packages/mechdsl-core/src/mechdsl/lowering/fe_localise.py b/packages/mechdsl-core/src/mechdsl/lowering/fe_localise.py index 8686df2..3c5624c 100644 --- a/packages/mechdsl-core/src/mechdsl/lowering/fe_localise.py +++ b/packages/mechdsl-core/src/mechdsl/lowering/fe_localise.py @@ -57,7 +57,7 @@ class LocalisationError(UnsupportedError, ValueError): ) -# Reference-cell volumes (∫_ref 1 dV). Used by `_enrich_element_ir` (P4-3) to +# Reference-cell volumes (∫_ref 1 dV). Used by `_enrich_element_ir` to # populate `GeometrySummary.reference_volume`. Hex variants live on the # canonical [-1,1]^3 reference; the tet variants sit on the unit reference # tetrahedron (volume 1/6). @@ -72,7 +72,7 @@ class LocalisationError(UnsupportedError, ValueError): # Material models whose algorithmic consistent tangent is symmetric. SVK is # strictly symmetric (hyperelastic ∂²Ψ/∂E²); the J2 + power-law return map # is algorithmically symmetric for associative flow with a smooth yield -# surface. Plan B's rate-dependent / non-associative models (perzyna, +# surface. The rate-dependent / non-associative models (perzyna, # johnson_cook, lemaitre) ship non-symmetric tangents and are flagged here # accordingly. _SYMMETRIC_TANGENT_MODELS: frozenset[str] = frozenset({"svk", "j2_power_law"}) @@ -131,7 +131,7 @@ def _enrich_element_ir(legacy_ir: ElementIR, problem_ir: ProblemIR) -> ElementIR contraction_sketch="qaI,qIJKL,qbK->qaJbL", # B^T C B ) - # constitutive_latex P5-1: carry fiber-orientation field data down from the + # Carry fiber-orientation field data down from the # ProblemIR to the Element IR (anisotropic models, HGO). Lossless copy of # the declared family directions — no layer bypass; None for isotropic. fiber_field = problem_ir.fiber_field.families if problem_ir.fiber_field is not None else None @@ -202,7 +202,7 @@ class LocalisationResult: element_ir: ElementIR einsum_specs: tuple[EinsumSpec, ...] - problem_ir: ProblemIR # back-reference + problem_ir: ProblemIR @classmethod def from_element_ir( @@ -262,8 +262,8 @@ def _check_stable_path_combo(problem_ir: ProblemIR) -> None: (formulation → element → material) so error messages stay stable across runs. """ - # Formulation: both TL and UL are valid in the IR after Plan B §B1.3, - # but anything outside the canonical pair is a hard reject. + # Formulation: both TL and UL are valid in the IR, but anything + # outside the canonical pair is a hard reject. if problem_ir.formulation not in ( Formulation.TOTAL_LAGRANGIAN, Formulation.UPDATED_LAGRANGIAN, @@ -275,15 +275,14 @@ def _check_stable_path_combo(problem_ir: ProblemIR) -> None: "Plan B phase B1." ) # Element type: only Hex8 is wired into the lowering pass today; - # Tet4 / Tet10 / Hex20 are valid in the IR but Plan B §B5 owns their - # localisation. + # Tet4 / Tet10 / Hex20 are valid in the IR but their localisation is not + # wired up yet. if problem_ir.element_type != ElementType.HEX8: raise LocalisationError( f"Element type {problem_ir.element_type.value!r} not supported " "for localisation. Only hex8 is wired into the lowering pass " "today; Tet4 / Tet10 / Hex20 support is planned for Plan B phase B5." ) - # Material model: must be on the lowering pass's allowlist. if problem_ir.material.model not in _SUPPORTED_MODELS: raise LocalisationError( f"Material model {problem_ir.material.model!r} not supported " @@ -317,15 +316,15 @@ def localise(problem_ir: ProblemIR) -> LocalisationResult: Plan-B phase that adds support. """ # -- Validate formulation/element compatibility ---------------------- - # Both TL and UL are valid after Plan B §B1.3. The configuration enum on - # ProblemIR is already guaranteed consistent with the formulation by + # Both TL and UL are valid. The configuration enum on ProblemIR is + # already guaranteed consistent with the formulation by # ProblemIR.__post_init__. _check_stable_path_combo(problem_ir) # -- Create element IR ----------------------------------------------- # Thread formulation + configuration through the element IR so downstream - # emitters (P1-3 residual, P1-4 tangent) can branch on element_ir.configuration - # rather than sniffing ProblemIR again. See Plan B §B1.3. + # emitters (residual, tangent) can branch on element_ir.configuration + # rather than sniffing ProblemIR again. configuration_str = ( Configuration.CURRENT.value if problem_ir.formulation == Formulation.UPDATED_LAGRANGIAN @@ -335,9 +334,9 @@ def localise(problem_ir: ProblemIR) -> LocalisationResult: formulation=problem_ir.formulation.value, configuration=configuration_str, ) - # P4-3: lowering emits the enriched ElementIR first, then derives the - # einsum optimizer view from it. Pre-P4-3, the bare IR was returned and - # downstream layers re-derived these contract facts inline. + # Lowering emits the enriched ElementIR first, then derives the + # einsum optimizer view from it; downstream layers no longer re-derive + # these contract facts inline. element_ir = _enrich_element_ir(legacy_ir, problem_ir) # -- Derive optimizer view from the enriched IR ---------------------- diff --git a/packages/mechdsl-core/src/mechdsl/solver/_seam_runtime.py b/packages/mechdsl-core/src/mechdsl/solver/_seam_runtime.py index 5fb0261..4d6e724 100644 --- a/packages/mechdsl-core/src/mechdsl/solver/_seam_runtime.py +++ b/packages/mechdsl-core/src/mechdsl/solver/_seam_runtime.py @@ -19,7 +19,7 @@ # NOTE: no ``from __future__ import annotations`` — kept consistent with the seam # modules that consume this helper and wire imported generated code into # ``@ti.kernel`` bodies needing eager annotation evaluation (PEP 563 breaks the -# JIT; the PJ-0/PJ-1 finding). +# JIT). import importlib.util import re @@ -31,7 +31,7 @@ # Matches a module-level ``ti.init(...)`` line regardless of indentation/spacing, # so :func:`strip_ti_init` can never miss it. A missed init line would # re-initialise Taichi at import and free the caller's already-allocated DOF -# fields (the P2-2 finding). +# fields. TI_INIT_LINE = re.compile(r"^\s*ti\.init\s*\(") diff --git a/packages/mechdsl-core/src/mechdsl/solver/critical_timestep.py b/packages/mechdsl-core/src/mechdsl/solver/critical_timestep.py index 9a02048..4927d46 100644 --- a/packages/mechdsl-core/src/mechdsl/solver/critical_timestep.py +++ b/packages/mechdsl-core/src/mechdsl/solver/critical_timestep.py @@ -63,7 +63,6 @@ # 0:(-,-,-), 1:(+,-,-), 2:(+,+,-), 3:(-,+,-) # 4:(-,-,+), 5:(+,-,+), 6:(+,+,+), 7:(-,+,+) -# TET4 has 6 edges: _TET4_EDGES: list[tuple[int, int]] = [ (0, 1), (0, 2), @@ -101,7 +100,7 @@ def _hex8_volume(X_elem: NDArray) -> float: n_qp = GRAD_AT_QUAD.shape[0] # 8 for full 2×2×2 rule vol = 0.0 for q in range(n_qp): - J0 = X_elem.T @ GRAD_AT_QUAD[q] # (3, 3) + J0 = X_elem.T @ GRAD_AT_QUAD[q] vol += float(np.linalg.det(J0)) * float(HEX8_QUAD_WEIGHTS[q]) return vol @@ -251,7 +250,7 @@ def _hex8_critical_timestep( dt_min = np.inf for e in range(n_elem): - X_elem = coords[conn[e]] # (8, 3) + X_elem = coords[conn[e]] vol = _hex8_volume(X_elem) if vol <= 0.0: raise ValueError( @@ -290,7 +289,7 @@ def _tet_critical_timestep( dt_min = np.inf for e in range(n_elem): # For TET10, corner nodes are indices 0..3 (mid-nodes are 4..9). - X_elem = coords[conn[e]] # (4 or 10, 3) + X_elem = coords[conn[e]] L_e = _tet4_shortest_edge(X_elem, edges) if L_e <= 0.0: raise ValueError(f"Zero or negative edge length ({L_e:.6e}) at element {e}.") diff --git a/packages/mechdsl-core/src/mechdsl/solver/import_adapter.py b/packages/mechdsl-core/src/mechdsl/solver/import_adapter.py index 24eaf67..5b66bf8 100644 --- a/packages/mechdsl-core/src/mechdsl/solver/import_adapter.py +++ b/packages/mechdsl-core/src/mechdsl/solver/import_adapter.py @@ -283,8 +283,7 @@ def __init__( precond_fn: Callable[[np.ndarray], np.ndarray] | None = None, ) -> None: # Bind the identity preconditioner at construction time so the - # algorithm body never sees `None`. Matches the contract in the - # P6-1 task JSON (`preconditioner_contract` field). + # algorithm body never sees `None`. self._apply_M_inv: Callable[[np.ndarray], np.ndarray] = ( precond_fn if precond_fn is not None else _identity ) @@ -394,23 +393,15 @@ def solve( # --------------------------------------------------------------------------- -# P6-2 — Solver-mode factories. -# -# Purely additive surface introduced by Task P6-2 of the recovery plan. -# Everything above this banner (including ``LinearSolverInterface``, -# ``CGSolver``, ``PCGSolver``, ``ScipyCGSolver``, ``_identity``, and -# ``Algo2CodePCGSolver``) MUST remain byte-identical to its P6-1 form; this -# block only appends new module-level callables and a private alias. +# Solver-mode factories. # --------------------------------------------------------------------------- -# ``Literal`` is imported here at the bottom of the module — after the -# byte-identity-protected block (lines 1-391, P6-1 Gate-A invariant) — so that -# ``LinearSolverInterface``, ``CGSolver``, ``PCGSolver``, ``ScipyCGSolver``, -# ``_identity``, and ``Algo2CodePCGSolver`` remain byte-identical to their -# P6-1 form (verified via SHA-256 of ``ast.dump``). Once that protection -# is lifted, this import can move into the ``TYPE_CHECKING`` block at the -# top of the module (``from __future__ import annotations`` on line 15 -# already makes the annotation lazy at runtime). +# ``Literal`` is imported here at the bottom of the module so the solver +# classes above remain byte-identical to their previously verified form +# (checked via SHA-256 of ``ast.dump``). Once that protection is lifted, +# this import can move into the ``TYPE_CHECKING`` block at the top of the +# module (``from __future__ import annotations`` already makes the +# annotation lazy at runtime). from typing import Literal as _Literal # noqa: E402 (intentional bottom-of-module import) _SolverMode = _Literal["fallback", "generated"] @@ -483,7 +474,7 @@ def build_solver( # --------------------------------------------------------------------------- -# PlanJune14 P4-3 — selectable all-Taichi seam solve path (Option 1, opt-in). +# Selectable all-Taichi seam solve path (opt-in). # # The seam path is NOT a ``build_solver`` mode: it does not implement the # host-NumPy ``LinearSolverInterface`` (matvec-callback) contract consumed by diff --git a/packages/mechdsl-core/src/mechdsl/solver/lumped_mass.py b/packages/mechdsl-core/src/mechdsl/solver/lumped_mass.py index 5eea8ba..d5eeb04 100644 --- a/packages/mechdsl-core/src/mechdsl/solver/lumped_mass.py +++ b/packages/mechdsl-core/src/mechdsl/solver/lumped_mass.py @@ -116,10 +116,10 @@ def compute_lumped_mass( # With partition of unity sum_b N_b = 1 the element mass sum equals rho * V_e # (within float tolerance), so the row-sum lumping conserves total mass. for e in range(n_elem): - X_elem = coords[conn[e]] # (8, 3) + X_elem = coords[conn[e]] for q in range(SHAPE_AT_QUAD.shape[0]): # Reference Jacobian J0 = X^T @ dN/dxi - J0 = X_elem.T @ GRAD_AT_QUAD[q] # (3, 3) + J0 = X_elem.T @ GRAD_AT_QUAD[q] detJ0 = float(np.linalg.det(J0)) if detJ0 <= 0.0: raise ValueError( @@ -129,7 +129,7 @@ def compute_lumped_mass( w = float(HEX8_QUAD_WEIGHTS[q]) # Row-sum: m_a += integral rho * N_a * (sum_b N_b) dV # = integral rho * N_a dV (partition of unity). - N_q = SHAPE_AT_QUAD[q] # (8,) + N_q = SHAPE_AT_QUAD[q] contrib = rho * N_q * detJ0 * w for a in range(8): m_node[conn[e, a]] += contrib[a] diff --git a/packages/mechdsl-core/src/mechdsl/solver/newton.py b/packages/mechdsl-core/src/mechdsl/solver/newton.py index 87df340..477fd39 100644 --- a/packages/mechdsl-core/src/mechdsl/solver/newton.py +++ b/packages/mechdsl-core/src/mechdsl/solver/newton.py @@ -37,13 +37,11 @@ class NewtonConfig: """Relative tolerance: converged when ``||R|| < tol * ||R_0||``.""" max_iter: int = 50 - """Maximum Newton iterations.""" cg_tol: float = 1e-10 """Relative tolerance for the CG linear solver.""" cg_max_iter: int = 2000 - """Maximum CG iterations.""" @dataclass @@ -51,13 +49,10 @@ class NewtonResult: """Result of a Newton-Raphson solve.""" converged: bool - """Whether the solve converged within tolerance.""" n_iterations: int - """Number of Newton iterations performed.""" residual_history: list[float] = field(default_factory=list) - """Residual norm at each iteration.""" def newton_solve( @@ -136,7 +131,7 @@ def newton_solve( # Fail-loud on a non-finite residual. A NaN/Inf ``||R||`` means the # assembled residual is poisoned -- e.g. the generated J2 return map - # set ``dl = NaN`` on non-convergence (WI-1, taichi_printer) and it + # set ``dl = NaN`` on non-convergence (see taichi_printer) and it # propagated through stress -> internal force -> R. A magnitude-only # convergence test cannot catch this: ``NaN < tol`` is False, so the # loop would silently exhaust ``max_iter`` (or, on a backend that diff --git a/packages/mechdsl-core/src/mechdsl/solver/seam_integrate.py b/packages/mechdsl-core/src/mechdsl/solver/seam_integrate.py index c1b313e..f1f7e2b 100644 --- a/packages/mechdsl-core/src/mechdsl/solver/seam_integrate.py +++ b/packages/mechdsl-core/src/mechdsl/solver/seam_integrate.py @@ -33,7 +33,7 @@ # NOTE: no ``from __future__ import annotations`` — downstream callers wire this # into modules that define ``@ti.kernel`` bodies, and Taichi requires *eager* # annotation evaluation (PEP 563 stringifies ti.template() and breaks the JIT; -# the PJ-0/PJ-1 finding, shared with seam_solve). +# same constraint as seam_solve). import functools from collections.abc import Callable diff --git a/packages/mechdsl-core/src/mechdsl/solver/seam_solve.py b/packages/mechdsl-core/src/mechdsl/solver/seam_solve.py index 1aa685f..d53a192 100644 --- a/packages/mechdsl-core/src/mechdsl/solver/seam_solve.py +++ b/packages/mechdsl-core/src/mechdsl/solver/seam_solve.py @@ -31,8 +31,7 @@ # NOTE: no ``from __future__ import annotations`` — downstream callers wire this # into modules that define ``@ti.kernel`` bodies, and Taichi requires *eager* -# annotation evaluation (PEP 563 stringifies ti.template() and breaks the JIT; -# the PJ-0/PJ-1 finding). +# annotation evaluation (PEP 563 stringifies ti.template() and breaks the JIT). import functools from collections.abc import Callable diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/anisotropic_energy.py b/packages/mechdsl-core/src/mechdsl/symbolic/anisotropic_energy.py index 1e28aca..d73a357 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/anisotropic_energy.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/anisotropic_energy.py @@ -12,7 +12,7 @@ Authoring contract ------------------ The energy is authored once for a SINGLE (generic) fiber family in named -invariants (see ``dev/examples/hgo_energy.tex``):: +invariants (see ``examples/hgo_energy.tex``):: Psi = (mu/2)(Ibar1 - 3) + (kappa/2)(Jdet - 1)^2 + (k1/2k2)(exp(k2 (Ibar4 - 1)^2) - 1) @@ -143,7 +143,7 @@ class AnisotropicEnergyModel: iso_pk2: sp.ImmutableDenseMatrix # S_iso+vol(E, *iso_params) fiber_pk2: sp.ImmutableDenseMatrix # S_fib(E, a0,a1,a2, *fiber_params) — active branch fiber_ibar4: sp.Expr # Ibar4(E, a0,a1,a2) for the Macaulay gate - fiber_symbols: tuple[sp.Symbol, sp.Symbol, sp.Symbol] # a0, a1, a2 + fiber_symbols: tuple[sp.Symbol, sp.Symbol, sp.Symbol] iso_param_symbols: tuple[sp.Symbol, ...] fiber_param_symbols: tuple[sp.Symbol, ...] parameters: dict[sp.Symbol, str] # sanitised -> original LaTeX name @@ -151,8 +151,6 @@ class AnisotropicEnergyModel: _fiber_fn: Callable[..., NDArray] _ibar4_fn: Callable[..., float] - # ------------------------------------------------------------------ - def _iso_args(self, e_strain: NDArray, params: dict[str, float]) -> list[float]: flat = [e_strain[i, j] for i in range(3) for j in range(3)] return [*flat, *(float(params[s.name]) for s in self.iso_param_symbols)] diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/bridge.py b/packages/mechdsl-core/src/mechdsl/symbolic/bridge.py index 468f7e4..8b4cd5f 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/bridge.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/bridge.py @@ -156,11 +156,10 @@ def convert( ) if rank == 4: - # Rank-4 tangent moduli C_IJKL are accepted by the bridge (P3-2). + # Rank-4 tangent moduli C_IJKL are accepted by the bridge. # JIT budget enforcement (≤ 512 unrolled lines per @ti.func) must be # applied before any unrolled Taichi emission — use # mechdsl.codegen.einsum_optimizer.optimize_contraction to gate emission. - # Full Taichi emission for rank-4 nodes is wired in P3-3. return SymbolicNode( name=name, kind="tensor4", diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/energy.py b/packages/mechdsl-core/src/mechdsl/symbolic/energy.py index 147ef11..0b684b4 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/energy.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/energy.py @@ -115,7 +115,7 @@ def _c_from_strain(strain: tuple[tuple[sp.Symbol, ...], ...]) -> sp.Matrix: return 2 * e + sp.eye(3) -# Named-invariant authoring contract (P2-1). Each entry maps an authored +# Named-invariant authoring contract. Each entry maps an authored # scalar symbol name (emitted by nrpylatex from ``\mathrm{}``) to a # callable that builds the invariant as a SymPy scalar on C = 2E + I, drawing # every definition from symbolic/invariants.py so this module never re-derives @@ -148,17 +148,16 @@ def _jdet(c: sp.Matrix) -> sp.Expr: } # Invariant symbols that are recognised but require a fiber direction, which -# is not plumbed until Phase 5 (P5-1). Authoring one today is rejected with a -# phase pointer rather than silently treated as a free parameter. +# is not plumbed in this path. Authoring one is rejected with a clear error +# rather than silently treated as a free parameter. _FIBER_INVARIANTS: dict[str, str] = { "I4f": "I4 = a·C·a", "I5f": "I5 = a·C²·a", } # Any authored symbol matching this shape is treated as an *intended* invariant -# (so an unknown one is rejected with a phase pointer instead of being mistaken -# for a free material parameter). Bare Greek/Latin parameter names (mu, kappa, -# aleph, …) do not match. +# (so an unknown one is rejected instead of being mistaken for a free material +# parameter). Bare Greek/Latin parameter names (mu, kappa, aleph, …) do not match. _INVARIANT_NAME_RE = re.compile(r"^(?:I\d+\w*|Ibar\d+|J(?:det)?)$") diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/models/hgo.py b/packages/mechdsl-core/src/mechdsl/symbolic/models/hgo.py index 3c38113..b8ea7d4 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/models/hgo.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/models/hgo.py @@ -131,7 +131,6 @@ def _fiber_contrib( dI1bar_dC = J23m * (eye3 - (I1 / 3.0) * Cinv) dI4bar_dC = J23m * (A - (I4 / 3.0) * Cinv) dE_dC = kd * dI1bar_dC + (1.0 - 3.0 * kd) * dI4bar_dC - # S_fi = 2 * k1 * E_fi * exp(k2 * E_fi^2) * dE_dC coeff = 2.0 * mat.k1 * E_fi * np.exp(mat.k2 * E_fi * E_fi) return coeff * dE_dC diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/models/j2_power_law.py b/packages/mechdsl-core/src/mechdsl/symbolic/models/j2_power_law.py index dd2285f..5e31e13 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/models/j2_power_law.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/models/j2_power_law.py @@ -210,7 +210,7 @@ def assemble_j2_like_tangent( Algorithmic consistent tangent. """ kappa = lam + 2.0 * mu / 3.0 - beta = 1.0 - 3.0 * mu * dl / sigma_eq_trial # == theta in task notation + beta = 1.0 - 3.0 * mu * dl / sigma_eq_trial P_dev = deviatoric_projector() n_flow = S_dev_trial / sigma_eq_trial # flow direction, 2-norm = sqrt(2/3) @@ -337,7 +337,7 @@ def radial_return( H_prime = yield_stress_derivative(mat, alpha_trial) f = sigma_eq_trial - 3.0 * mu * dl - sy - df = -3.0 * mu - H_prime # df/d(dl) + df = -3.0 * mu - H_prime if abs(df) < 1e-30: if abs(f) > effective_tol: diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/models/johnson_cook.py b/packages/mechdsl-core/src/mechdsl/symbolic/models/johnson_cook.py index 5372755..77a41d3 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/models/johnson_cook.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/models/johnson_cook.py @@ -247,7 +247,7 @@ class JCReturnMappingResult: T_new: float # Updated temperature delta_lambda: float # Plastic multiplier increment dl is_plastic: bool # Whether yielding occurred - tangent: NDArray # Algorithmic tangent (3, 3, 3, 3) — elastic stub for P3-2 + tangent: NDArray # Algorithmic tangent (3, 3, 3, 3) # --------------------------------------------------------------------------- @@ -407,12 +407,12 @@ def _residuals_and_jac(dl_: float, dT_: float) -> tuple[float, float, NDArray]: J = np.array( [ [ - -3.0 * mu - dsy_da - dsy_d_dl_rate, # dR1/ddl - -dsy_dT, # dR1/ddT + -3.0 * mu - dsy_da - dsy_d_dl_rate, + -dsy_dT, ], [ - -mat.beta * (dsy_da + dsy_d_dl_rate) * dl_ - mat.beta * sy, # dR2/ddl - mat.rho_c_p - mat.beta * dsy_dT * dl_, # dR2/ddT + -mat.beta * (dsy_da + dsy_d_dl_rate) * dl_ - mat.beta * sy, + mat.rho_c_p - mat.beta * dsy_dT * dl_, ], ] ) @@ -422,8 +422,8 @@ def _residuals_and_jac(dl_: float, dT_: float) -> tuple[float, float, NDArray]: R1 = float("inf") R2 = float("inf") # J_conv is the 2x2 Newton Jacobian at convergence — retained for the - # consistent tangent assembly (P3-3). Initialised to a dummy value; - # always overwritten before the tangent is computed. + # consistent tangent assembly. Initialised to a dummy value; always + # overwritten before the tangent is computed. J_conv: NDArray = np.zeros((2, 2)) J = J_conv # alias so the name is defined before the loop body runs @@ -496,17 +496,19 @@ def _residuals_and_jac(dl_: float, dT_: float) -> tuple[float, float, NDArray]: ratio = 3.0 * mu * dl / sigma_eq_trial S_updated: NDArray = S_vol_trial + (1.0 - ratio) * S_dev_trial - # --- Algorithmic consistent tangent (P3-3) --- + # --- Algorithmic consistent tangent --- # # Derived from implicit differentiation of the coupled (dl, dT) Newton system # at convergence (Simo & Hughes 1998, §3.4 extended for thermal coupling; # Schur-complement elimination of dT). # # At convergence the 2x2 Newton residual satisfies: + # # J_conv * [d(dl)/d(E_IJ); d(dT)/d(E_IJ)] = [∂sigma_eq_trial/∂E_IJ; 0] # # where J_conv = [[J00, J01], [J10, J11]] is the converged Jacobian. # Solving by Cramer's rule: + # # d(dl)/d(sigma_eq_trial) = J_conv[1,1] / det(J_conv) # # For the J2-like box form (Simo & Hughes §3.4, Box 3.5), the effective diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/models/lemaitre.py b/packages/mechdsl-core/src/mechdsl/symbolic/models/lemaitre.py index fcbeaf9..e0b81b6 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/models/lemaitre.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/models/lemaitre.py @@ -52,9 +52,9 @@ # Numerical constants # --------------------------------------------------------------------------- -#: Upper bound on damage imposed during the return map. Keeps the (1 - D) -#: divisor numerically safe; element deletion at ``D_crit`` (default 0.95 in -#: P6-2) happens well below this ceiling. +# Upper bound on damage imposed during the return map. Keeps the (1 - D) +# divisor numerically safe; element deletion at ``D_crit`` happens well +# below this ceiling. D_MAX = 1.0 - 1e-6 diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/models/mooney_rivlin.py b/packages/mechdsl-core/src/mechdsl/symbolic/models/mooney_rivlin.py index 22ca555..602b646 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/models/mooney_rivlin.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/models/mooney_rivlin.py @@ -96,7 +96,6 @@ def _kinematics(E_strain: NDArray) -> tuple[NDArray, float, float, float, NDArra raise ValueError(f"det(C) must be > 0 for a valid deformation, got {det_C:.3e}") J = float(np.sqrt(det_C)) I1 = float(np.trace(C)) - # I2 = 0.5 * (I1^2 - tr(C @ C)) I2 = 0.5 * (I1 * I1 - float(np.trace(C @ C))) Cinv = np.linalg.inv(C) return C, J, I1, I2, Cinv @@ -153,13 +152,10 @@ def material_tangent_4th(mat: MooneyRivlinMaterial, E_strain: NDArray) -> NDArra II = np.einsum("ij,kl->ijkl", eye3, eye3) # minor-symmetric identity: (1/2)(delta_IK*delta_JL + delta_IL*delta_JK) I_sym = 0.5 * (np.einsum("ik,jl->ijkl", eye3, eye3) + np.einsum("il,jk->ijkl", eye3, eye3)) - # Cinv_KL*delta_IJ + delta_KL*Cinv_IJ cross_cinv_eye = np.einsum("ij,kl->ijkl", eye3, Cinv) + np.einsum("ij,kl->ijkl", Cinv, eye3) - # Cinv_IJ*Cinv_KL cinv2 = np.einsum("ij,kl->ijkl", Cinv, Cinv) # Cinv_IK*Cinv_JL + Cinv_IL*Cinv_JK (minor-symmetric in IJ, KL and major-symmetric) cinv_sym = np.einsum("ik,jl->ijkl", Cinv, Cinv) + np.einsum("il,jk->ijkl", Cinv, Cinv) - # Cinv_KL*C_IJ + C_KL*Cinv_IJ cross_cinv_C = np.einsum("ij,kl->ijkl", C, Cinv) + np.einsum("ij,kl->ijkl", Cinv, C) # --- C1 contribution (Neo-Hookean iso with mu = 2*C1) --- diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/models/neo_hookean.py b/packages/mechdsl-core/src/mechdsl/symbolic/models/neo_hookean.py index b743e8c..a5789c2 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/models/neo_hookean.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/models/neo_hookean.py @@ -162,7 +162,6 @@ def material_tangent_4th(mat: NeoHookeanMaterial, E_strain: NDArray) -> NDArray: # Kronecker outer products assembled via einsum # term1_IJKL = Cinv_KL * delta_IJ + delta_KL * Cinv_IJ term_cross = np.einsum("ij,kl->ijkl", eye3, Cinv) + np.einsum("ij,kl->ijkl", Cinv, eye3) - # term2_IJKL = Cinv_IJ * Cinv_KL term_cinv2 = np.einsum("ij,kl->ijkl", Cinv, Cinv) # term_sym_IJKL = Cinv_IK * Cinv_JL + Cinv_IL * Cinv_JK (minor-symmetric) term_sym = np.einsum("ik,jl->ijkl", Cinv, Cinv) + np.einsum("il,jk->ijkl", Cinv, Cinv) diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/models/ogden.py b/packages/mechdsl-core/src/mechdsl/symbolic/models/ogden.py index 47faf49..ad91c4e 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/models/ogden.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/models/ogden.py @@ -122,12 +122,11 @@ def _principal_stresses( tau_iso = np.zeros(3, dtype=np.float64) for mu_p, alpha_p in zip(mus, alphas, strict=True): - lb_a = lam_bar**alpha_p # (3,) + lb_a = lam_bar**alpha_p mean_lb_a = float(lb_a.sum()) / 3.0 tau_iso += mu_p * (lb_a - mean_lb_a) tau_vol = kappa * J * (J - 1.0) - # S_i = (tau_iso_i + tau_vol) / e_i S_prin = (tau_iso + tau_vol) / e_safe return S_prin, J diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/models/perzyna.py b/packages/mechdsl-core/src/mechdsl/symbolic/models/perzyna.py index 5086538..d7a9046 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/models/perzyna.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/models/perzyna.py @@ -351,7 +351,7 @@ def _residual(d: float) -> tuple[float, float]: # --- 6. Update alpha --- alpha_new = alpha_old + dl - # --- Algorithmic consistent tangent (P3-3) --- + # --- Algorithmic consistent tangent --- # # Derived from linearisation of the Perzyna return-map residual at convergence # (Simo & Hughes 1998, §3.4, Box 3.5 extended for viscoplasticity). diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/objective_rates.py b/packages/mechdsl-core/src/mechdsl/symbolic/objective_rates.py index 0c89f18..19f3c63 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/objective_rates.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/objective_rates.py @@ -282,8 +282,7 @@ def truesdell_tangent( "the Truesdell push-forward." ) # Piola push-forward c_ijkl = (1/J) F_iI F_jJ F_kK F_lL C_IJKL. - # The 4-leg einsum is ~243 multiply-adds for 3D and runs in microseconds; - # P1-4's emitter will inline this when it generates the UL tangent body. + # The 4-leg einsum is ~243 multiply-adds for 3D and runs in microseconds. return cast( "Tensor4", (1.0 / J) * np.einsum("iI,jJ,kK,lL,IJKL->ijkl", F, F, F, F, C4), diff --git a/packages/mechdsl-core/src/mechdsl/symbolic/spectral_energy.py b/packages/mechdsl-core/src/mechdsl/symbolic/spectral_energy.py index 051b0b3..1eece23 100644 --- a/packages/mechdsl-core/src/mechdsl/symbolic/spectral_energy.py +++ b/packages/mechdsl-core/src/mechdsl/symbolic/spectral_energy.py @@ -15,7 +15,7 @@ ``\\mathrm{lbar1}``, ``\\mathrm{lbar2}``, ``\\mathrm{lbar3}`` and the Jacobian as ``\\mathrm{Jdet}`` — the same ``\\mathrm{..}`` escape the named-invariant path uses (nrpylatex emits them as scalar symbols with no index contraction). See -``dev/examples/ogden_energy.tex``. +``examples/ogden_energy.tex``. ================ ============================================================ authored as meaning (substituted before differentiation) @@ -108,7 +108,7 @@ class SpectralEnergyModel: """ psi: sp.Expr - stretch_symbols: tuple[sp.Symbol, sp.Symbol, sp.Symbol] # lambda_1, _2, _3 + stretch_symbols: tuple[sp.Symbol, sp.Symbol, sp.Symbol] principal_pk2: tuple[sp.Expr, sp.Expr, sp.Expr] # S_i(lambda_1, _2, _3, *params) parameters: dict[sp.Symbol, str] # sanitised symbol -> original LaTeX name param_symbols: tuple[sp.Symbol, ...] # sorted parameter symbols (eval order) diff --git a/packages/mechdsl-core/src/mechdsl/verify/_assembly.py b/packages/mechdsl-core/src/mechdsl/verify/_assembly.py index 6cc86b3..271cd8b 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/_assembly.py +++ b/packages/mechdsl-core/src/mechdsl/verify/_assembly.py @@ -111,7 +111,7 @@ def element_tangent_matvec( """ from mechdsl.symbolic.models.svk import SVKMaterial, material_tangent_4th - C4 = material_tangent_4th(SVKMaterial(lam, mu)) # constant (3,3,3,3) + C4 = material_tangent_4th(SVKMaterial(lam, mu)) Kv = np.zeros((8, 3), dtype=np.float64) for q in range(_QUAD.n_points): @@ -121,14 +121,14 @@ def element_tangent_matvec( dN_dX, detJ0 = _shape_grad_reference(X_elem, xi, eta, zeta) # Current kinematics - grad_u = u_elem.T @ dN_dX # (3, 3) + grad_u = u_elem.T @ dN_dX F = deformation_gradient(grad_u) E = green_lagrange(F) tr_E = np.trace(E) S = lam * tr_E * _I3 + 2.0 * mu * E # PK2 stress # Linearisation in direction v - grad_v = v_elem.T @ dN_dX # (3, 3) + grad_v = v_elem.T @ dN_dX dE = 0.5 * (F.T @ grad_v + grad_v.T @ F) # linearised E dS = np.einsum("ijkl,kl->ij", C4, dE) # linearised PK2 dP = grad_v @ S + F @ dS # linearised PK1 diff --git a/packages/mechdsl-core/src/mechdsl/verify/_patch_test_kernels.py b/packages/mechdsl-core/src/mechdsl/verify/_patch_test_kernels.py index c892959..4d11eb4 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/_patch_test_kernels.py +++ b/packages/mechdsl-core/src/mechdsl/verify/_patch_test_kernels.py @@ -115,8 +115,8 @@ def _shape_grad_reference( centroid) that are not pre-tabulated in the per-family GRAD_AT_QUAD arrays. """ - dN_dxi = element_ir.basis.gradient(xi, eta, zeta) # (n_nodes, 3) - J0 = X_elem.T @ dN_dxi # (3, 3) + dN_dxi = element_ir.basis.gradient(xi, eta, zeta) + J0 = X_elem.T @ dN_dxi detJ0 = float(np.linalg.det(J0)) if detJ0 <= 0.0: msg = ( @@ -125,7 +125,7 @@ def _shape_grad_reference( ) raise ValueError(msg) J0_inv = np.linalg.inv(J0) - dN_dX = dN_dxi @ J0_inv # (n_nodes, 3) + dN_dX = dN_dxi @ J0_inv return dN_dX, detJ0 @@ -174,7 +174,7 @@ def element_svk_internal_force( w_q = float(quad.weights[q]) dN_dX, detJ0 = _shape_grad_reference(element_ir, X_elem, xi, eta, zeta) - grad_u = u_elem.T @ dN_dX # (3, 3) + grad_u = u_elem.T @ dN_dX F = deformation_gradient(grad_u) E = green_lagrange(F) tr_E = float(np.trace(E)) diff --git a/packages/mechdsl-core/src/mechdsl/verify/ad_oracle.py b/packages/mechdsl-core/src/mechdsl/verify/ad_oracle.py index 7adda46..441d7ca 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/ad_oracle.py +++ b/packages/mechdsl-core/src/mechdsl/verify/ad_oracle.py @@ -285,7 +285,7 @@ def verify_j2_elastic_branch( # --------------------------------------------------------------------------- -# Hyperelastic strain-energy functions (P4-5 AD oracle) +# Hyperelastic strain-energy functions (AD oracle) # --------------------------------------------------------------------------- # # Each returns Psi(E) so FD central-differences recover PK2 via S = dPsi/dE. diff --git a/packages/mechdsl-core/src/mechdsl/verify/analytical.py b/packages/mechdsl-core/src/mechdsl/verify/analytical.py index 9125dac..9696283 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/analytical.py +++ b/packages/mechdsl-core/src/mechdsl/verify/analytical.py @@ -121,7 +121,6 @@ def rigid_body_reference( if coords.ndim != 2 or coords.shape[1] != 3: raise ValueError(f"coords must be shape (N, 3), got {coords.shape}") - # u = (R - I) @ X + t R_minus_I = rotation - np.eye(3) return cast("np.ndarray", coords @ R_minus_I.T + translation[np.newaxis, :]) diff --git a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/_core.py b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/_core.py index 0fae3b3..097f60e 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/_core.py +++ b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/_core.py @@ -59,14 +59,14 @@ class BenchmarkResult: ``radial_displacement_samples``. Keys are benchmark-specific. """ - displacements: NDArray # (N, 3) + displacements: NDArray newton_iters: int wallclock_s: float extras: dict[str, Any] = field(default_factory=dict) # --------------------------------------------------------------------------- -# Element-level stress helper (reusable by P10-6/8/9) +# Element-level stress helper # --------------------------------------------------------------------------- @@ -88,7 +88,7 @@ def _shape_grad_at(X_elem: NDArray, xi: float, eta: float, zeta: float) -> tuple Absolute value of the reference Jacobian determinant. """ dN_dxi = _BASIS.gradient(xi, eta, zeta) - J0 = X_elem.T @ dN_dxi # (3, 3) + J0 = X_elem.T @ dN_dxi detJ0 = float(np.linalg.det(J0)) if detJ0 <= 0.0: msg = ( @@ -96,7 +96,7 @@ def _shape_grad_at(X_elem: NDArray, xi: float, eta: float, zeta: float) -> tuple f"xi=({xi},{eta},{zeta}) - check element connectivity." ) raise ValueError(msg) - dN_dX = dN_dxi @ np.linalg.inv(J0) # (8, 3) + dN_dX = dN_dxi @ np.linalg.inv(J0) return dN_dX, detJ0 @@ -132,7 +132,7 @@ def element_cauchy_stress( xi, eta, zeta = _XI_CENTROID dN_dX, _ = _shape_grad_at(X_elem, xi, eta, zeta) - grad_u = u_elem.T @ dN_dX # (3, 3) + grad_u = u_elem.T @ dN_dX F = deformation_gradient(grad_u) E = green_lagrange(F) tr_E = float(np.trace(E)) @@ -140,4 +140,4 @@ def element_cauchy_stress( J = float(np.linalg.det(F)) sigma = (1.0 / J) * (F @ S @ F.T) - return sigma # (3, 3) + return sigma diff --git a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/_taylor_runtime.py b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/_taylor_runtime.py index b7bda0b..904493f 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/_taylor_runtime.py +++ b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/_taylor_runtime.py @@ -224,7 +224,7 @@ def _reduced_hex8_svk_internal_force( Element internal force in the same sign convention as the rest of the codebase (resisting force; add to the residual). """ - dN_dxi = shape_gradients(0.0, 0.0, 0.0) # (8, 3) + dN_dxi = shape_gradients(0.0, 0.0, 0.0) J0 = X_elem.T @ dN_dxi detJ0 = float(np.linalg.det(J0)) if detJ0 <= 0.0: @@ -234,9 +234,9 @@ def _reduced_hex8_svk_internal_force( ) raise ValueError(msg) J0_inv = np.linalg.inv(J0) - dN_dX = dN_dxi @ J0_inv # (8, 3) + dN_dX = dN_dxi @ J0_inv - grad_u = u_elem.T @ dN_dX # (3, 3) + grad_u = u_elem.T @ dN_dX F = np.eye(3) + grad_u E = 0.5 * (F.T @ F - np.eye(3)) tr_E = float(np.trace(E)) @@ -444,8 +444,8 @@ def explicit_step( state.time = float(state.time) + float(dt) # Energy accumulation — use absolute work so the diagnostic stays a - # non-negative dissipation budget (the AC-1 ratio test reads it as a - # bound, not as a signed integral). + # non-negative dissipation budget (the energy-ratio acceptance test reads + # it as a bound, not as a signed integral). state.internal_energy = float(state.internal_energy) + float(np.abs(np.sum(f_int * du))) state.hourglass_energy = float(state.hourglass_energy) + float( hourglass_energy_increment(du, f_hg) @@ -476,21 +476,21 @@ def apply_rigid_wall_contact( normal = wall.normal # Signed distances of all nodes to the wall plane. - rel = state.coords - point # (n_nodes, 3) - sd = rel @ normal # (n_nodes,) + rel = state.coords - point + sd = rel @ normal pen_mask = sd < 0.0 if not np.any(pen_mask): return state # 1) Position correction: x' = x - s n (only for penetrating nodes) - correction = sd[pen_mask, None] * normal[None, :] # (n_pen, 3) + correction = sd[pen_mask, None] * normal[None, :] state.coords[pen_mask] -= correction state.displacement[pen_mask] -= correction # 2) Velocity correction: zero (or reflect) the inward normal component. v_pen = state.velocity[pen_mask] - vn = v_pen @ normal # (n_pen,) + vn = v_pen @ normal inward = vn < 0.0 # nodes still moving into the wall if np.any(inward): idxs = np.where(pen_mask)[0][inward] @@ -530,7 +530,7 @@ def hourglass_energy_increment(du: NDArray[np.float64], f_hg: NDArray[np.float64 # --------------------------------------------------------------------------- -# Johnson-Cook explicit runtime (P7-2) +# Johnson-Cook explicit runtime # --------------------------------------------------------------------------- @@ -561,7 +561,7 @@ def _reduced_hex8_jc_internal_force( Element internal force in the same sign convention as the rest of the codebase (resisting force; add to the residual). """ - dN_dxi = shape_gradients(0.0, 0.0, 0.0) # (8, 3) + dN_dxi = shape_gradients(0.0, 0.0, 0.0) J0 = X_elem.T @ dN_dxi detJ0 = float(np.linalg.det(J0)) if detJ0 <= 0.0: @@ -571,9 +571,9 @@ def _reduced_hex8_jc_internal_force( ) raise ValueError(msg) J0_inv = np.linalg.inv(J0) - dN_dX = dN_dxi @ J0_inv # (8, 3) + dN_dX = dN_dxi @ J0_inv - grad_u = u_elem.T @ dN_dX # (3, 3) + grad_u = u_elem.T @ dN_dX F = np.eye(3) + grad_u P = F @ S f_int: NDArray[np.float64] = 8.0 * detJ0 * (dN_dX @ P.T) @@ -821,7 +821,7 @@ def explicit_step_jc( state.time = float(state.time) + float(dt) - # --- 5. Energy bookkeeping (absolute work, matches P7-1 convention) --- + # --- 5. Energy bookkeeping (absolute work, matches explicit_step) --- state.internal_energy = float(state.internal_energy) + float(np.abs(np.sum(f_int * du))) state.hourglass_energy = float(state.hourglass_energy) + float( hourglass_energy_increment(du, f_hg) @@ -831,7 +831,7 @@ def explicit_step_jc( # --------------------------------------------------------------------------- -# Postprocessing helpers (P7-2) +# Postprocessing helpers # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/hgo_strip.py b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/hgo_strip.py index 6896ad9..fcf8728 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/hgo_strip.py +++ b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/hgo_strip.py @@ -66,8 +66,8 @@ class StripMesh: """Rectangular Hex8 strip mesh on [0, Lx] x [0, Ly] x [0, Lz].""" - coords: NDArray # (n_nodes, 3) - connectivity: NDArray # (n_elem, 8) + coords: NDArray + connectivity: NDArray n_nodes: int n_elem: int Lx: float diff --git a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/notched_bar.py b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/notched_bar.py index b11ac91..8d227a8 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/notched_bar.py +++ b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/notched_bar.py @@ -549,7 +549,7 @@ def run_notched_bar_benchmark( wallclock_s = time.perf_counter() - t0 # --- Damage post-processing --- - damage_qp = mod.damage_D.to_numpy() # (n_elem, n_qp) + damage_qp = mod.damage_D.to_numpy() if damage_qp.shape != (n_elem, 8): msg = f"Unexpected damage_D shape {damage_qp.shape}; expected ({n_elem}, 8)" raise RuntimeError(msg) @@ -557,7 +557,7 @@ def run_notched_bar_benchmark( max_damage = float(damage_elem.max()) damage_argmax = int(np.argmax(damage_elem)) - centroids = coords[conn].mean(axis=1) # (n_elem, 3) + centroids = coords[conn].mean(axis=1) d_to_root = np.linalg.norm(centroids - mesh.notch_root_xyz, axis=1) notch_root_elem = int(np.argmin(d_to_root)) h = max( diff --git a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/taylor_impact.py b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/taylor_impact.py index 5003e6c..dc1d5c4 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/taylor_impact.py +++ b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/taylor_impact.py @@ -125,8 +125,8 @@ class TaylorImpactParameters: # --- Geometry (Johnson & Cook 1985 reference, SI units) --- length: float = 25.4e-3 # m, bar length along +z - width: float = 7.62e-3 # m, x-extent - height: float = 7.62e-3 # m, y-extent + width: float = 7.62e-3 + height: float = 7.62e-3 nx: int = 2 ny: int = 2 nz: int = 8 diff --git a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/thick_cylinder.py b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/thick_cylinder.py index a0c0cef..77eb75e 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/benchmarks/thick_cylinder.py +++ b/packages/mechdsl-core/src/mechdsl/verify/benchmarks/thick_cylinder.py @@ -79,8 +79,8 @@ class QuarterCylinderMesh: indices in CCW order when viewed from outside (i.e. from the fluid). """ - coords: NDArray # (n_nodes, 3) - connectivity: NDArray # (n_elem, 8) + coords: NDArray + connectivity: NDArray n_nodes: int n_elem: int inner_nodes: NDArray @@ -349,30 +349,30 @@ def face_dN_dt(s: float, t: float) -> NDArray: for face in mesh.inner_faces: # face is (n0, n1, n2, n3) - four corner node global indices - X_face = mesh.coords[list(face)] # (4, 3) + X_face = mesh.coords[list(face)] for si, wi in zip(_G2_PTS, _G2_WTS, strict=True): for ti, wt in zip(_G2_PTS, _G2_WTS, strict=True): - N = face_N(si, ti) # (4,) - dN_ds = face_dN_ds(si, ti) # (4,) - dN_dt = face_dN_dt(si, ti) # (4,) + N = face_N(si, ti) + dN_ds = face_dN_ds(si, ti) + dN_dt = face_dN_dt(si, ti) # Physical tangent vectors - dx_ds = X_face.T @ dN_ds # (3,) - dx_dt = X_face.T @ dN_dt # (3,) + dx_ds = X_face.T @ dN_ds + dx_dt = X_face.T @ dN_dt # Face node ordering (bl, br, tr, tl) gives s varying in +theta_hat # and t varying in +z_hat, so cross(dx_ds, dx_dt) = theta_hat x z_hat = +r_hat. # This is the direction from fluid (r < r_inner) into solid, # and hence the direction of the surface traction under internal # pressure p (force on solid per unit area = p * r_hat). - normal_raw = np.cross(dx_ds, dx_dt) # points in +r_hat + normal_raw = np.cross(dx_ds, dx_dt) dA = np.linalg.norm(normal_raw) n_hat = normal_raw / dA # unit normal in +r_hat direction # Traction on solid: p * n_hat (pushes shell outward under # internal pressure, per Cauchy stress convention) - traction = pressure * n_hat # (3,) + traction = pressure * n_hat # Nodal force contributions: f_a += w_s * w_t * N_a * traction * dA for a in range(4): diff --git a/packages/mechdsl-core/src/mechdsl/verify/convergence.py b/packages/mechdsl-core/src/mechdsl/verify/convergence.py index 4bed7cf..352a20d 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/convergence.py +++ b/packages/mechdsl-core/src/mechdsl/verify/convergence.py @@ -125,7 +125,7 @@ def check_convergence_rate( # --------------------------------------------------------------------------- -# MMS (Method of Manufactured Solutions) driver — Task P3-T2 +# MMS (Method of Manufactured Solutions) driver # --------------------------------------------------------------------------- @@ -334,26 +334,26 @@ def _compute_consistent_nodal_forces( for e in range(conn.shape[0]): nodes = conn[e] - X_elem = coords[nodes] # (8, 3) + X_elem = coords[nodes] for q in range(quad.n_points): xi, eta, zeta = quad.points[q] w_q = quad.weights[q] # Shape functions at this quad point - N_vals = basis.evaluate(xi, eta, zeta) # (8,) + N_vals = basis.evaluate(xi, eta, zeta) # Shape function gradients in parametric space -> reference Jacobian - dN_dxi = basis.gradient(xi, eta, zeta) # (8, 3) - J0 = X_elem.T @ dN_dxi # (3, 3) + dN_dxi = basis.gradient(xi, eta, zeta) + J0 = X_elem.T @ dN_dxi detJ0 = float(np.linalg.det(J0)) # Physical coordinates at this quad point - X_qp = N_vals @ X_elem # (3,) + X_qp = N_vals @ X_elem # Body force at this quad point b_vals = bf_func(X_qp[0], X_qp[1], X_qp[2], lam, mu) - b_vec = np.array(b_vals, dtype=np.float64) # (3,) + b_vec = np.array(b_vals, dtype=np.float64) # Scatter: f_ext[a, :] += w * det(J0) * N_a * b for a in range(8): @@ -390,46 +390,46 @@ def _compute_l2_h1_errors( for e in range(conn.shape[0]): nodes = conn[e] - X_elem = coords[nodes] # (8, 3) - u_elem = u_h[nodes] # (8, 3) + X_elem = coords[nodes] + u_elem = u_h[nodes] for q in range(quad.n_points): xi, eta, zeta = quad.points[q] w_q = quad.weights[q] # Shape functions and gradients - N_vals = basis.evaluate(xi, eta, zeta) # (8,) - dN_dxi = basis.gradient(xi, eta, zeta) # (8, 3) + N_vals = basis.evaluate(xi, eta, zeta) + dN_dxi = basis.gradient(xi, eta, zeta) # Reference Jacobian - J0 = X_elem.T @ dN_dxi # (3, 3) + J0 = X_elem.T @ dN_dxi detJ0 = float(np.linalg.det(J0)) J0_inv = np.linalg.inv(J0) # Shape function gradients in reference coords: dN/dX = dN/dxi @ J0^{-1} - dN_dX = dN_dxi @ J0_inv # (8, 3) + dN_dX = dN_dxi @ J0_inv # Physical coordinates at quad point - X_qp = N_vals @ X_elem # (3,) + X_qp = N_vals @ X_elem # --- L2 error --- # Interpolated displacement at quad point - u_h_qp = N_vals @ u_elem # (3,) + u_h_qp = N_vals @ u_elem # Exact displacement at quad point u_exact_vals = u_func(X_qp[0], X_qp[1], X_qp[2]) - u_exact_qp = np.array(u_exact_vals, dtype=np.float64) # (3,) + u_exact_qp = np.array(u_exact_vals, dtype=np.float64) diff_u = u_h_qp - u_exact_qp l2_sq += w_q * detJ0 * np.dot(diff_u, diff_u) # --- H1 error --- # Interpolated displacement gradient: grad(u_h) = u_elem^T @ dN_dX -> (3, 3) - grad_u_h = u_elem.T @ dN_dX # (3, 3) + grad_u_h = u_elem.T @ dN_dX # Exact displacement gradient at quad point grad_exact_vals = grad_func(X_qp[0], X_qp[1], X_qp[2]) - grad_u_exact = np.array(grad_exact_vals, dtype=np.float64) # (3, 3) + grad_u_exact = np.array(grad_exact_vals, dtype=np.float64) diff_grad = grad_u_h - grad_u_exact h1_sq += w_q * detJ0 * np.sum(diff_grad**2) diff --git a/packages/mechdsl-core/src/mechdsl/verify/patch_test.py b/packages/mechdsl-core/src/mechdsl/verify/patch_test.py index 749a431..826763d 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/patch_test.py +++ b/packages/mechdsl-core/src/mechdsl/verify/patch_test.py @@ -267,7 +267,7 @@ def run_patch_test( n_elements = conn.shape[0] # Compute analytical displacement field: u = E @ X - u_analytical = patch_test_reference(coords, strain) # (n_nodes, 3) + u_analytical = patch_test_reference(coords, strain) # Compute internal force with the analytical displacement applied to all nodes f_int = assemble_internal_force(u_analytical, coords, conn, lam, mu) @@ -324,7 +324,7 @@ def run_patch_test( # --------------------------------------------------------------------------- -# Parametric patch test (Plan B phase B5 — all element factory triples) +# Parametric patch test (all element factory triples) # --------------------------------------------------------------------------- @@ -427,7 +427,7 @@ def run_patch_test_parametric( ) # u = E @ X per node (constant Green-Lagrange strain kinematic state) - u_nodes = X_nodes @ strain.T # (n_nodes, 3) + u_nodes = X_nodes @ strain.T # SVK internal force for this element family. f_int = element_svk_internal_force(element_ir, u_nodes, X_nodes, lam, mu) @@ -507,7 +507,7 @@ def run_rigid_body_test( n_elements = conn.shape[0] # Compute rigid body displacement field: u = (R - I) @ X + t - u_rigid = rigid_body_reference(coords, rotation, translation) # (n_nodes, 3) + u_rigid = rigid_body_reference(coords, rotation, translation) # Compute internal force directly (no Newton solve needed) f_int = assemble_internal_force(u_rigid, coords, conn, lam, mu) diff --git a/packages/mechdsl-core/src/mechdsl/verify/perf/registry.py b/packages/mechdsl-core/src/mechdsl/verify/perf/registry.py index 7117ee9..f3324b7 100644 --- a/packages/mechdsl-core/src/mechdsl/verify/perf/registry.py +++ b/packages/mechdsl-core/src/mechdsl/verify/perf/registry.py @@ -371,7 +371,7 @@ def _p10_6_factory() -> dict[str, Any]: def _p10_7_factory() -> dict[str, Any]: - # P8-2 carry-forward: smoke profile only — never `nightly()`. + # Smoke profile only — never `nightly()`. return {"params": TaylorImpactParameters.smoke()} diff --git a/packages/mechdsl-core/tests/_e2e_helpers.py b/packages/mechdsl-core/tests/_e2e_helpers.py index abdf4db..9b9d045 100644 --- a/packages/mechdsl-core/tests/_e2e_helpers.py +++ b/packages/mechdsl-core/tests/_e2e_helpers.py @@ -24,7 +24,7 @@ # Element constants for resolving ``ti.static(range(...))`` trip counts when # weighting unrolled lines (Hex8, 2x2x2 Gauss). Used by -# :func:`count_unrolled_kernel_lines` (PlanJune14 WI-1 honest JIT-budget test). +# :func:`count_unrolled_kernel_lines`. _UNROLL_CONSTS = {"DIM": 3, "N_QP": 8, "N_NODES": 8} diff --git a/packages/mechdsl-core/tests/generate_golden.py b/packages/mechdsl-core/tests/generate_golden.py index 33a9aae..3586ad2 100644 --- a/packages/mechdsl-core/tests/generate_golden.py +++ b/packages/mechdsl-core/tests/generate_golden.py @@ -19,7 +19,7 @@ # Ensure the tests directory is importable when running as a standalone script. _TESTS_DIR = Path(__file__).resolve().parent -_PKG_DIR = _TESTS_DIR.parent # packages/mechdsl-core +_PKG_DIR = _TESTS_DIR.parent if str(_PKG_DIR) not in sys.path: sys.path.insert(0, str(_PKG_DIR)) @@ -83,7 +83,7 @@ def generate_golden_elastic(golden_dir: Path) -> Path: & (np.abs(coords[:, 2] - Lz) < 1e-12) )[0] assert len(right_top) == 1 - f_ext[right_top[0], 2] = -10.0 # downward in z + f_ext[right_top[0], 2] = -10.0 u, residual_history = solve_elastic( coords, @@ -229,11 +229,11 @@ def generate_golden_plastic(golden_dir: Path) -> Path: # smallest mesh that still exhibits necking localization near z=L/2. The # fine-mesh self-convergence study is deferred to offline analysis — generating # a 4x4x16 golden takes ~90 min per run, which is infeasible on every ref-solver -# modification. P3-4 compares the generated Taichi output to this regression -# snapshot; literature-value comparison (Simo & Hughes 1998) is performed -# separately at the benchmark level with 2% tolerance. +# modification. Generated Taichi output is compared to this regression snapshot; +# literature-value comparison (Simo & Hughes 1998) is performed separately at +# the benchmark level with 2% tolerance. # -# Material: steel-like J2 with power-law hardening (from plan sprint3.md §140) +# Material: steel-like J2 with power-law hardening _NB_E = 206.9e3 # MPa _NB_NU = 0.29 _NB_SIGMA_Y0 = 450.0 # MPa diff --git a/packages/mechdsl-core/tests/lawgen/test_budgets.py b/packages/mechdsl-core/tests/lawgen/test_budgets.py index 8696215..d426345 100644 --- a/packages/mechdsl-core/tests/lawgen/test_budgets.py +++ b/packages/mechdsl-core/tests/lawgen/test_budgets.py @@ -1,8 +1,8 @@ """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). +Part of the MechDSL lawgen test suite. -Covers all seven ``test_plan.cases``: +Covers all seven 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 @@ -76,7 +76,7 @@ def _lowered(n_temps: int = 0, n_returns: int = 1) -> LoweredExpr: # --------------------------------------------------------------------------- -# Module-level counters — the reusable primitives (P2-3/P2-4/P3 depend here). +# Module-level counters — the reusable primitives. # --------------------------------------------------------------------------- @@ -100,10 +100,10 @@ def test_count_pow_symbolic_exponent_rule() -> None: 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, + # ±1/2 exponents lower to ti.sqrt / 1/ti.sqrt, 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(sp.sqrt(_x)) == 0 + assert count_pow_symbolic_exponent(1 / sp.sqrt(_x)) == 0 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 @@ -116,7 +116,7 @@ def test_count_pow_symbolic_exponent_rule() -> None: 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 + expr = _x**2 + 2 * _x + 1 target = TiconstitTarget(max_expr_ops=2) checker = BudgetChecker(target) diff --git a/packages/mechdsl-core/tests/lawgen/test_carrier_emitter.py b/packages/mechdsl-core/tests/lawgen/test_carrier_emitter.py index 3079e65..1176abf 100644 --- a/packages/mechdsl-core/tests/lawgen/test_carrier_emitter.py +++ b/packages/mechdsl-core/tests/lawgen/test_carrier_emitter.py @@ -172,8 +172,8 @@ def test_no_forbidden_import_in_emitted_source(self) -> None: ], ) 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 + # Directly exercise the fail-loud self-check raise path (the 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" diff --git a/packages/mechdsl-core/tests/lawgen/test_cli.py b/packages/mechdsl-core/tests/lawgen/test_cli.py index 2025a34..aaa8d48 100644 --- a/packages/mechdsl-core/tests/lawgen/test_cli.py +++ b/packages/mechdsl-core/tests/lawgen/test_cli.py @@ -1,6 +1,6 @@ """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``: +Covers three CLI cases: 1. dry-run on a minimal fixture YAML prints the expected plan lines and writes no files (the ``--out`` dir stays empty); diff --git a/packages/mechdsl-core/tests/lawgen/test_contracts.py b/packages/mechdsl-core/tests/lawgen/test_contracts.py index 577aaf8..0f0b821 100644 --- a/packages/mechdsl-core/tests/lawgen/test_contracts.py +++ b/packages/mechdsl-core/tests/lawgen/test_contracts.py @@ -1,6 +1,6 @@ """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``: +Covers four contract cases: 1. instantiate ``TiconstitTarget`` with all defaults 2. instantiate ``TiconstitTarget`` with overridden budget knobs diff --git a/packages/mechdsl-core/tests/lawgen/test_diagnostics.py b/packages/mechdsl-core/tests/lawgen/test_diagnostics.py index f4c84ec..32c1724 100644 --- a/packages/mechdsl-core/tests/lawgen/test_diagnostics.py +++ b/packages/mechdsl-core/tests/lawgen/test_diagnostics.py @@ -1,8 +1,8 @@ """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). +Part of the MechDSL lawgen test suite. -Covers the four ``test_plan.cases``: +Covers four diagnostics cases: 1. Two distinct unsupported nodes → both surface in ONE ``LawgenError`` (no silent drop) — checked via lowering AND via a raw collector. @@ -94,7 +94,7 @@ def test_two_unsupported_nodes_both_reported_via_lowering() -> None: 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). + # Both appear in the message AND in .args. message = str(exc.value) assert "foo" in message and "bar" in message arg_text = " ".join(str(a) for a in exc.value.args) @@ -258,7 +258,7 @@ 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 + with pytest.raises(NotImplementedError): lower_expression(sp.Function("foo")(x)) diff --git a/packages/mechdsl-core/tests/lawgen/test_guard_injection.py b/packages/mechdsl-core/tests/lawgen/test_guard_injection.py index b77dcf1..9bf700c 100644 --- a/packages/mechdsl-core/tests/lawgen/test_guard_injection.py +++ b/packages/mechdsl-core/tests/lawgen/test_guard_injection.py @@ -1,10 +1,9 @@ """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. +Part of the MechDSL lawgen test suite. Correctness-critical: the guards +emitted by :func:`mechdsl.lawgen.sympy_to_taichi.lower_expression` must match +the hand-authored guards in the reference ``swift_voce.py`` so the +numerical-equivalence check (``rtol=1e-10``) holds. Covers the five ``test_plan.cases``: @@ -128,12 +127,12 @@ def test_sqrt_argument_wrapped_with_ti_max() -> None: # --------------------------------------------------------------------------- -# Case 4 — division denominators are guarded (SIGN-PRESERVING; Gate-B Finding 1). +# Case 4 — division denominators are guarded (SIGN-PRESERVING). # --------------------------------------------------------------------------- # # 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 +# a runtime-negative ``b`` (``a/|b|`` != ``a/b``) — silently wrong. 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``. diff --git a/packages/mechdsl-core/tests/lawgen/test_lowering_table.py b/packages/mechdsl-core/tests/lawgen/test_lowering_table.py index 09e6e24..4d2322a 100644 --- a/packages/mechdsl-core/tests/lawgen/test_lowering_table.py +++ b/packages/mechdsl-core/tests/lawgen/test_lowering_table.py @@ -1,8 +1,8 @@ """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). +Part of the MechDSL lawgen test suite. -Covers the six ``test_plan.cases`` of P2-4: +Covers six lowering cases: 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. @@ -147,7 +147,7 @@ def test_piecewise_nine_branches_raises_budget_error() -> None: (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 "9" in diag.reason and "8" in diag.reason assert diag.fix.strip() @@ -249,7 +249,7 @@ def test_standalone_negative_pow_is_not_inlined() -> None: # --------------------------------------------------------------------------- -# Case 4 (Gate-B regression) — division-by-power must NOT collapse to ``a/x*x``. +# Case 4 (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 @@ -268,7 +268,7 @@ def test_division_by_square_is_parenthesised_not_collapsed() -> None: raw = _lower_one(a / x**2, guards=False) assert raw == "a/(x*x)" - # The Gate-B collapse: unparenthesised ``/x*x`` must NOT appear ... + # The 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" @@ -363,7 +363,6 @@ def test_source_hash_input_is_emitted_lines_in_order() -> None: 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") diff --git a/packages/mechdsl-core/tests/lawgen/test_manifest.py b/packages/mechdsl-core/tests/lawgen/test_manifest.py index d0607f8..74da06d 100644 --- a/packages/mechdsl-core/tests/lawgen/test_manifest.py +++ b/packages/mechdsl-core/tests/lawgen/test_manifest.py @@ -1,8 +1,8 @@ """Unit tests for the manifest emitter (Task P3-3). -Covers the four ``test_plan.cases`` from ``dev/plans/mfront_cycleM0/json/P3-3.json``: +Covers four manifest cases: -1. the manifest entry for a SwiftVoce spec has all the Cycle 0 required fields; +1. the manifest entry for a SwiftVoce spec has all the 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"``; @@ -49,20 +49,21 @@ # # 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). +# 0's: hash the INPUT formula string verbatim (UTF-8, no normalisation). The +# value below is transcribed from Cycle 0's ``_manifest.json`` (read as data, +# not imported). # --------------------------------------------------------------------------- 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 +# NumerixWeave's ``_manifest.json`` — read as data, never imported). 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. +# object is byte-identical to the hand-authored artifact the parity tests compare +# against. CYCLE0_REQUIRED = ["sigma0", "Q_inf", "b"] CYCLE0_OPTIONAL = ["K", "n", "p0", "edot0", "m", "alpha", "T_ref"] @@ -117,7 +118,7 @@ def _cycle0_realistic_entry() -> dict[str, object]: target_contract="VoceHardeningModel", exports="SwiftVoce", source="swift_voce.py", - tests=["libs/ticonstit/tests/plan_tests/mfront_cycle0/test_P2-2.py"], + tests=["libs/ticonstit/tests/generated/test_swift_voce_P2-2.py"], required=CYCLE0_REQUIRED, optional=CYCLE0_OPTIONAL, ) @@ -325,7 +326,7 @@ def test_realistic_entry_written_key_order(self, tmp_path: Path) -> None: # --------------------------------------------------------------------------- # formula ↔ spec consistency — the opt-in guard that closes the "hashed formula -# is a different law than the spec" gap for P4-1. +# is a different law than the spec" gap. # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/lawgen/test_sympy_to_taichi.py b/packages/mechdsl-core/tests/lawgen/test_sympy_to_taichi.py index 258af47..52668b3 100644 --- a/packages/mechdsl-core/tests/lawgen/test_sympy_to_taichi.py +++ b/packages/mechdsl-core/tests/lawgen/test_sympy_to_taichi.py @@ -1,8 +1,8 @@ """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). +Part of the MechDSL lawgen test suite. -Covers the three ``test_plan.cases`` plus the fail-loud route: +Covers three lowerer 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 @@ -32,7 +32,7 @@ ) # 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. +# so the no-pycode/no-regex guard test is robust to where pytest is invoked from. _MODULE_SOURCE = Path(_lowerer_module.__file__) @@ -90,7 +90,6 @@ def test_repeated_subexpression_introduces_cse_temp() -> None: 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] @@ -135,7 +134,7 @@ def test_lower_expression_result_is_immutable() -> None: # --------------------------------------------------------------------------- -# Case 4 — fail loud (R2), no silent fallback. +# Case 4 — fail loud, no silent fallback. # --------------------------------------------------------------------------- @@ -174,7 +173,7 @@ def test_non_expr_input_raises_type_error() -> None: # --------------------------------------------------------------------------- -# R4 guard + allow-list alignment. +# No-pycode/no-regex guard + allow-list alignment. # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/lawgen/test_test_emitter.py b/packages/mechdsl-core/tests/lawgen/test_test_emitter.py index 18a0c70..14c5ae2 100644 --- a/packages/mechdsl-core/tests/lawgen/test_test_emitter.py +++ b/packages/mechdsl-core/tests/lawgen/test_test_emitter.py @@ -1,6 +1,6 @@ """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``: +Covers four emitter cases: 1. ``emit_tests`` on a simple spec → the generated source defines a reference-eval test function AND an FD-derivative test function. @@ -120,7 +120,7 @@ def test_emit_tests_returns_written_path(self, tmp_path: Path) -> None: 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. + # The FD tolerance must be the standard-FD 1e-5, not the stricter 1e-10 reconciliation gate. assert FD_RTOL <= 1e-5 assert f"FD_RTOL = {FD_RTOL!r}" in source assert "1e-10" not in source @@ -238,7 +238,7 @@ def test_generated_tests_pass_under_pytest( # --------------------------------------------------------------------------- -# Fail-loud (R2) — an unsupported node raises LawgenError, no file is written. +# Fail-loud — an unsupported node raises LawgenError, no file is written. # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/plan_tests/__init__.py b/packages/mechdsl-core/tests/plan_tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/__init__.py b/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-1.py b/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-1.py deleted file mode 100644 index 2da0830..0000000 --- a/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-1.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Tests for Task P1-1: Scaffold mechdsl.integration module + capabilities() + model_catalog(). - -AC[0]: capabilities() returns the documented keys. -AC[1]: model_catalog() enumerates each model with tier/dissipative flags. -AC[2]: importing mechdsl.integration does not trigger ti.init. -""" - -from __future__ import annotations - -import subprocess -import sys - -import pytest - - -class TestTaskP1_1: - """Tests for Task P1-1: Scaffold mechdsl.integration module + capabilities() + model_catalog().""" - - # ----------------------------------------------------------------------- - # AC[0]: capabilities() shape - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_capabilities_shape(self): - """capabilities() returns all documented keys with correct types/values.""" - from mechdsl.integration import capabilities - - caps = capabilities() - - # All required keys must be present - required_keys = { - "version", - "python", - "profiles", - "backends", - "actions", - "taichi_required_for", - "models", - } - assert required_keys == set(caps.keys()), ( - f"capabilities() keys mismatch. " - f"Missing: {required_keys - set(caps.keys())!r}. " - f"Extra: {set(caps.keys()) - required_keys!r}." - ) - - # version: a non-empty string - assert isinstance(caps["version"], str) and caps["version"], ( - "version must be a non-empty string" - ) - - # python: fixed specifier string - assert caps["python"] == ">=3.12,<3.13", ( - f"Expected python='>=3.12,<3.13', got {caps['python']!r}" - ) - - # profiles: list derived from ALLOWED_PROFILES — must contain "mvp" - assert isinstance(caps["profiles"], list), "profiles must be a list" - assert "mvp" in caps["profiles"], "profiles must include 'mvp'" - - # backends: exactly ["taichi"] for MVP - assert caps["backends"] == ["taichi"], ( - f"Expected backends=['taichi'], got {caps['backends']!r}" - ) - - # actions: the three canonical actions - expected_actions = {"emit", "transpile", "verify"} - assert set(caps["actions"]) == expected_actions, ( - f"actions mismatch: got {caps['actions']!r}" - ) - - # taichi_required_for: only verify pays the Taichi cost - assert caps["taichi_required_for"] == ["verify"], ( - f"Expected taichi_required_for=['verify'], got {caps['taichi_required_for']!r}" - ) - - # models: non-empty list of strings; must include the two MVP models - assert isinstance(caps["models"], list) and len(caps["models"]) > 0, ( - "models must be a non-empty list" - ) - assert all(isinstance(m, str) for m in caps["models"]), "each model entry must be a string" - assert "svk" in caps["models"], "capabilities.models must include 'svk'" - assert "j2" in caps["models"] or "j2_isotropic" in caps["models"], ( - "capabilities.models must include the j2 power-law model" - ) - - @pytest.mark.unit - def test_capabilities_profiles_matches_allowed_profiles(self): - """capabilities().profiles is exactly the sorted ALLOWED_PROFILES set.""" - from mechdsl import ALLOWED_PROFILES - from mechdsl.integration import capabilities - - caps = capabilities() - assert caps["profiles"] == sorted(ALLOWED_PROFILES), ( - "capabilities.profiles must equal sorted(ALLOWED_PROFILES)" - ) - - # ----------------------------------------------------------------------- - # AC[1]: model_catalog() entries - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_model_catalog_entries(self): - """model_catalog() enumerates each model with correct field shapes.""" - from mechdsl.integration import model_catalog - - catalog = model_catalog() - - assert isinstance(catalog, list) and len(catalog) > 0, ( - "model_catalog() must return a non-empty list" - ) - - required_entry_keys = {"name", "module", "tier", "dissipative", "params", "state_variables"} - - for entry in catalog: - assert required_entry_keys == set(entry.keys()), ( - f"Entry {entry.get('name')!r} has wrong keys. " - f"Missing: {required_entry_keys - set(entry.keys())!r}. " - f"Extra: {set(entry.keys()) - required_entry_keys!r}." - ) - # name: non-empty string - assert isinstance(entry["name"], str) and entry["name"], ( - f"name must be a non-empty string: {entry!r}" - ) - # module: dotted path string - assert isinstance(entry["module"], str) and "." in entry["module"], ( - f"module must be a dotted path string: {entry!r}" - ) - # tier: one of the two allowed values - assert entry["tier"] in ("mvp", "experimental"), ( - f"tier must be 'mvp' or 'experimental': {entry!r}" - ) - # dissipative: bool - assert isinstance(entry["dissipative"], bool), f"dissipative must be bool: {entry!r}" - # params: list of strings - assert isinstance(entry["params"], list) and all( - isinstance(p, str) for p in entry["params"] - ), f"params must be a list of strings: {entry!r}" - # state_variables: list of strings - assert isinstance(entry["state_variables"], list) and all( - isinstance(s, str) for s in entry["state_variables"] - ), f"state_variables must be a list of strings: {entry!r}" - - @pytest.mark.unit - def test_model_catalog_mvp_models_present(self): - """SVK and J2 power-law models are present and correctly tagged as mvp.""" - from mechdsl.integration import model_catalog - - catalog = model_catalog() - by_name = {e["name"]: e for e in catalog} - - # SVK: mvp, not dissipative, Lame parameters - assert "svk" in by_name, "svk must be in model_catalog()" - svk = by_name["svk"] - assert svk["tier"] == "mvp", f"svk tier must be 'mvp', got {svk['tier']!r}" - assert svk["dissipative"] is False, "svk must not be dissipative" - assert set(svk["params"]) == {"lam", "mu"}, ( - f"svk params must be {{'lam', 'mu'}}, got {svk['params']!r}" - ) - assert svk["state_variables"] == [], "svk state_variables must be empty" - - # J2 power-law (symbolic model) - assert "j2" in by_name, "j2 must be in model_catalog()" - j2 = by_name["j2"] - assert j2["tier"] == "mvp", f"j2 tier must be 'mvp', got {j2['tier']!r}" - assert j2["dissipative"] is True, "j2 must be dissipative" - assert "alpha" in j2["state_variables"], "j2 state_variables must contain 'alpha'" - - # J2 isotropic lib dispatcher (MVP tier, Taichi-bound) - assert "j2_isotropic" in by_name, "j2_isotropic must be in model_catalog()" - j2_iso = by_name["j2_isotropic"] - assert j2_iso["tier"] == "mvp", f"j2_isotropic tier must be 'mvp', got {j2_iso['tier']!r}" - assert j2_iso["dissipative"] is True, "j2_isotropic must be dissipative" - - @pytest.mark.unit - def test_model_catalog_experimental_models_present(self): - """All expected experimental models appear in catalog.""" - from mechdsl.integration import model_catalog - - catalog = model_catalog() - names = {e["name"] for e in catalog} - - expected_experimental = { - "neo_hookean", - "mooney_rivlin", - "ogden", - "hgo", - "perzyna", - "johnson_cook", - "lemaitre", - "j2_kinematic", - "j2_mixed", - } - missing = expected_experimental - names - assert not missing, f"model_catalog() is missing experimental models: {missing!r}" - - @pytest.mark.unit - def test_model_catalog_dissipative_models_have_state_variables(self): - """Dissipative models must have at least one state variable.""" - from mechdsl.integration import model_catalog - - catalog = model_catalog() - for entry in catalog: - if entry["dissipative"]: - assert len(entry["state_variables"]) > 0, ( - f"Dissipative model {entry['name']!r} must have state_variables, " - f"got {entry['state_variables']!r}" - ) - - @pytest.mark.unit - def test_model_catalog_elastic_models_have_no_state_variables(self): - """Non-dissipative (elastic) models must have empty state_variables.""" - from mechdsl.integration import model_catalog - - catalog = model_catalog() - for entry in catalog: - if not entry["dissipative"]: - assert entry["state_variables"] == [], ( - f"Elastic model {entry['name']!r} must have empty state_variables, " - f"got {entry['state_variables']!r}" - ) - - # ----------------------------------------------------------------------- - # AC[2]: no ti.init on import (subprocess — most robust) - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_no_ti_init_on_import(self): - """Importing mechdsl.integration and calling capabilities()/model_catalog() - must not trigger ti.init (i.e. must not load the taichi package). - - Uses a subprocess with a fresh interpreter so no parent-process state - can contaminate the result. The subprocess exits with code 0 on success - or code 1 if taichi appears in sys.modules. - """ - script = ( - "import sys; " - "from mechdsl.integration import capabilities, model_catalog; " - "capabilities(); " - "model_catalog(); " - "taichi_loaded = 'taichi' in sys.modules; " - "print('taichi_loaded:', taichi_loaded); " - "sys.exit(1 if taichi_loaded else 0)" - ) - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - timeout=60, - ) - # Print stderr for diagnostics if the test fails - assert result.returncode == 0, ( - "Importing mechdsl.integration triggered ti.init (taichi was loaded).\n" - f"stdout: {result.stdout!r}\n" - f"stderr: {result.stderr!r}" - ) - assert "taichi_loaded: False" in result.stdout, ( - f"Expected 'taichi_loaded: False' in subprocess stdout.\n" - f"stdout: {result.stdout!r}\n" - f"stderr: {result.stderr!r}" - ) - - @pytest.mark.unit - def test_no_ti_init_on_import_monkeypatch(self, monkeypatch): - """Secondary check using monkeypatch: if taichi were imported, ti.init - would be called and the patched version would raise. - - This test runs in the same process. Because other tests may have - already imported mechdsl.integration (and therefore cached imports), - this complements — rather than replaces — the subprocess test above. - """ - import importlib - import types - - # Create a fake taichi module whose init() raises immediately. - fake_ti = types.ModuleType("taichi") - - def _init_raises(*args, **kwargs): - raise RuntimeError("ti.init was called — Taichi-free guarantee violated!") - - fake_ti.init = _init_raises # type: ignore[attr-defined] - - # Patch sys.modules so that any `import taichi` gets our fake module. - monkeypatch.setitem(sys.modules, "taichi", fake_ti) - - # Importing and calling the API must NOT call ti.init. - # (Re-importing may use cached modules; that's fine — the point is - # that the façade API itself doesn't call ti.init.) - integration = importlib.import_module("mechdsl.integration") - caps = integration.capabilities() - catalog = integration.model_catalog() - - assert isinstance(caps, dict), "capabilities() must return a dict" - assert isinstance(catalog, list), "model_catalog() must return a list" - - # ----------------------------------------------------------------------- - # Integration check: capabilities().models matches model_catalog() names - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_capabilities_models_matches_catalog(self): - """capabilities().models must equal [e['name'] for e in model_catalog()].""" - from mechdsl.integration import capabilities, model_catalog - - caps = capabilities() - catalog = model_catalog() - expected_models = [e["name"] for e in catalog] - assert caps["models"] == expected_models, ( - f"capabilities.models does not match model_catalog() names.\n" - f"capabilities.models: {caps['models']!r}\n" - f"model_catalog names: {expected_models!r}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-2.py b/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-2.py deleted file mode 100644 index 5dd6fd9..0000000 --- a/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-2.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Tests for Task P1-2: compile_from_sources() — Taichi-free compile_latex wrapper. - -AC[0]: Returns the documented keys for an SVK energy source. -AC[1]: Does NOT call ti.init (guard via monkeypatch/subprocess sentinel). -AC[2]: content_hash stable for identical input. -""" - -from __future__ import annotations - -import subprocess -import sys - -import pytest - -# --------------------------------------------------------------------------- -# Shared fixtures -# --------------------------------------------------------------------------- - -# Minimal 3-D Hex8 problem with SVK named model (E/nu params) — used for -# named-model and Taichi-free tests that do not supply an energy block. -_PROBLEM_SOURCE = """\ -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "t_bar" -""" - -# St. Venant-Kirchhoff strain-energy density authored in named invariants -# (same source used by dev/examples/svk_energy.tex). -# -# The derived energy uses \lambda (sanitised to ``aleph`` to avoid the Python -# keyword) and \mu as its symbolic parameter names. The companion problem -# source below must therefore supply those names in its material params, NOT -# E/nu — the codegen checks at emission time that every derived parameter is -# present in MaterialSpec.params. -_SVK_ENERGY_SOURCE = """\ -% declare metric gDD --dim 3 -% declare EDD --dim 3 -% declare \\lambda \\mu --const -\\Psi = \\frac{\\lambda}{2} E^{I}_{I} E^{J}_{J} + \\mu E^{I J} E_{I J} -""" - -# E=200e3, nu=0.3 => lam = E*nu/((1+nu)(1-2nu)) ≈ 115384.6, -# mu = E/(2*(1+nu)) ≈ 76923.1 -# ``aleph`` is the sanitised placeholder for \lambda (avoids Python keyword). -_PROBLEM_SOURCE_WITH_ENERGY = """\ -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --aleph 115384.6 --mu 76923.1 -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "t_bar" -""" - - -class TestTaskP1_2: - """Tests for Task P1-2: compile_from_sources() — Taichi-free compile_latex wrapper.""" - - # ----------------------------------------------------------------------- - # AC[0]: compile SVK energy -> summary — documented keys with right types - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_compile_svk_energy_summary(self): - """Verifies: compile SVK energy -> summary. - - AC[0]: Returns the documented keys for an SVK energy source. - The four keys must be present; element_ir_summary must contain the - five stable scalar fields; emitted_source must be a non-empty string; - content_hash must be a 64-char hex string; derived_energy_present - must be True when an energy block is supplied. - """ - from mechdsl.integration import compile_from_sources - - result = compile_from_sources( - problem_source=_PROBLEM_SOURCE_WITH_ENERGY, - energy_source=_SVK_ENERGY_SOURCE, - ) - - # --- top-level keys --- - required_keys = { - "element_ir_summary", - "emitted_source", - "content_hash", - "derived_energy_present", - } - assert set(result.keys()) == required_keys, ( - f"compile_from_sources() keys mismatch.\n" - f"Missing: {required_keys - set(result.keys())!r}\n" - f"Extra: {set(result.keys()) - required_keys!r}" - ) - - # --- element_ir_summary --- - summary = result["element_ir_summary"] - assert isinstance(summary, dict), "element_ir_summary must be a dict" - summary_required = {"element_type", "dim", "n_nodes", "n_quadrature_points", "formulation"} - assert summary_required <= set(summary.keys()), ( - f"element_ir_summary missing keys: {summary_required - set(summary.keys())!r}" - ) - assert summary["element_type"] == "hex8", ( - f"element_type must be 'hex8', got {summary['element_type']!r}" - ) - assert summary["dim"] == 3, f"dim must be 3, got {summary['dim']!r}" - assert isinstance(summary["n_nodes"], int) and summary["n_nodes"] > 0, ( - "n_nodes must be a positive int" - ) - assert ( - isinstance(summary["n_quadrature_points"], int) and summary["n_quadrature_points"] > 0 - ), "n_quadrature_points must be a positive int" - assert summary["formulation"] == "total_lagrangian", ( - f"formulation must be 'total_lagrangian', got {summary['formulation']!r}" - ) - - # --- emitted_source --- - assert isinstance(result["emitted_source"], str) and result["emitted_source"], ( - "emitted_source must be a non-empty string" - ) - - # --- content_hash --- - h = result["content_hash"] - assert isinstance(h, str) and len(h) == 64, ( - f"content_hash must be a 64-char hex string (sha256), got {h!r}" - ) - assert all(c in "0123456789abcdef" for c in h), ( - f"content_hash must be lowercase hex, got {h!r}" - ) - - # --- derived_energy_present --- - assert result["derived_energy_present"] is True, ( - "derived_energy_present must be True when energy_source is supplied" - ) - - @pytest.mark.unit - def test_compile_named_model_no_energy(self): - """compile_from_sources with a named model and no energy_source returns - derived_energy_present=False and still produces all four keys. - """ - from mechdsl.integration import compile_from_sources - - result = compile_from_sources(problem_source=_PROBLEM_SOURCE) - - required_keys = { - "element_ir_summary", - "emitted_source", - "content_hash", - "derived_energy_present", - } - assert set(result.keys()) == required_keys - - assert result["derived_energy_present"] is False, ( - "derived_energy_present must be False for a named-model run (no energy_source)" - ) - assert isinstance(result["emitted_source"], str) and result["emitted_source"] - assert isinstance(result["content_hash"], str) and len(result["content_hash"]) == 64 - - @pytest.mark.unit - def test_compile_requires_problem_source(self): - """compile_from_sources raises ValueError when problem_source is None.""" - from mechdsl.integration import compile_from_sources - - with pytest.raises(ValueError, match="problem_source"): - compile_from_sources() - - # ----------------------------------------------------------------------- - # AC[1]: no ti.init — subprocess sentinel (most robust) - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_no_ti_init_subprocess(self): - """compile_from_sources must not trigger ti.init. - - Uses a subprocess with a fresh interpreter so no parent-process state - can contaminate the result. The subprocess exits with code 0 on - success or code 1 if taichi appears in sys.modules after the call. - """ - script = ( - "import sys; " - "from mechdsl.integration import compile_from_sources; " - "compile_from_sources(" - " problem_source=(" - " '% mechanics dim 3\\n'" - " '% mechanics cell hex8\\n'" - " '% mechanics formulation total_lagrangian\\n'" - " '% mechanics material svk --E 200e3 --nu 0.3\\n'" - " '% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2\\n'" - " '% mechanics boundary load --type neumann --traction \"t_bar\"\\n'" - " )" - "); " - "taichi_loaded = 'taichi' in sys.modules; " - "print('taichi_loaded:', taichi_loaded); " - "sys.exit(1 if taichi_loaded else 0)" - ) - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - timeout=120, - ) - assert result.returncode == 0, ( - "compile_from_sources() triggered ti.init (taichi was loaded in subprocess).\n" - f"stdout: {result.stdout!r}\n" - f"stderr: {result.stderr!r}" - ) - assert "taichi_loaded: False" in result.stdout, ( - f"Expected 'taichi_loaded: False' in subprocess stdout.\n" - f"stdout: {result.stdout!r}\n" - f"stderr: {result.stderr!r}" - ) - - @pytest.mark.unit - def test_no_ti_init(self): - """Secondary guard: if ti.init were called during compile_from_sources, - the monkeypatched version would raise immediately. - - Complements the subprocess test — runs in-process using a fake taichi - module whose init() raises, then calls compile_from_sources. Because - the compile path is Taichi-free this must not raise. - """ - import types - - fake_ti = types.ModuleType("taichi") - - def _init_raises(*args, **kwargs): - raise RuntimeError("ti.init was called — Taichi-free guarantee violated!") - - fake_ti.init = _init_raises # type: ignore[attr-defined] - - import sys as _sys - - original = _sys.modules.get("taichi") - _sys.modules["taichi"] = fake_ti - try: - from mechdsl.integration import compile_from_sources - - # Must not raise even with the sentinel taichi installed. - result = compile_from_sources(problem_source=_PROBLEM_SOURCE) - assert isinstance(result, dict) - finally: - if original is None: - _sys.modules.pop("taichi", None) - else: - _sys.modules["taichi"] = original - - # ----------------------------------------------------------------------- - # AC[2]: stable content_hash — same input twice -> identical hash - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_stable_content_hash(self): - """Verifies: stable content_hash. - - AC[2]: content_hash is deterministic — calling compile_from_sources - twice with identical inputs must produce the same hash. - """ - from mechdsl.integration import compile_from_sources - - result_a = compile_from_sources( - problem_source=_PROBLEM_SOURCE_WITH_ENERGY, - energy_source=_SVK_ENERGY_SOURCE, - ) - result_b = compile_from_sources( - problem_source=_PROBLEM_SOURCE_WITH_ENERGY, - energy_source=_SVK_ENERGY_SOURCE, - ) - - assert result_a["content_hash"] == result_b["content_hash"], ( - f"content_hash is not stable across two identical calls.\n" - f"First: {result_a['content_hash']!r}\n" - f"Second: {result_b['content_hash']!r}" - ) - - @pytest.mark.unit - def test_stable_content_hash_named_model(self): - """content_hash is also stable for named-model runs (no energy_source).""" - from mechdsl.integration import compile_from_sources - - result_a = compile_from_sources(problem_source=_PROBLEM_SOURCE) - result_b = compile_from_sources(problem_source=_PROBLEM_SOURCE) - - assert result_a["content_hash"] == result_b["content_hash"], ( - f"content_hash is not stable for named-model runs.\n" - f"First: {result_a['content_hash']!r}\n" - f"Second: {result_b['content_hash']!r}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-3.py b/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-3.py deleted file mode 100644 index d272ac6..0000000 --- a/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-3.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Tests for Task P1-3: transpile_algorithm() — algo2code.transpile wrapper (Taichi-free). - -AC[0]: Transpiles algo2code.library RADIAL_RETURN_J2_LATEX to valid Python, - returning all four documented keys with the correct types. -AC[1]: valid_python reflects a compile() check (True for known-good input). -AC[2]: Calling transpile_algorithm() does not trigger ti.init. -""" - -from __future__ import annotations - -import subprocess -import sys - -import pytest - - -class TestTaskP1_3: - """Tests for Task P1-3: transpile_algorithm() — algo2code.transpile wrapper (Taichi-free).""" - - # ----------------------------------------------------------------------- - # AC[0]: return-dict shape and types - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_transpile_radial_return_j2_latex(self): - """Verifies: transpile RADIAL_RETURN_J2_LATEX. - - AC[0]: transpile_algorithm() returns all four documented keys with - correct types when given the known-good RADIAL_RETURN_J2_LATEX input. - """ - from algo2code.library.radial_return_j2 import RADIAL_RETURN_J2_LATEX - from mechdsl.integration import transpile_algorithm - - result = transpile_algorithm(RADIAL_RETURN_J2_LATEX, backend="taichi") - - # All four keys must be present. - required_keys = {"code", "entry_point", "line_count", "valid_python"} - assert set(result.keys()) == required_keys, ( - f"transpile_algorithm() keys mismatch.\n" - f"Missing: {required_keys - set(result.keys())!r}\n" - f"Extra: {set(result.keys()) - required_keys!r}" - ) - - # code: non-empty string - assert isinstance(result["code"], str) and result["code"], "code must be a non-empty string" - - # entry_point: non-empty string — must be the J2 radial-return function name - assert isinstance(result["entry_point"], str) and result["entry_point"], ( - "entry_point must be a non-empty string" - ) - assert result["entry_point"] == "radial_return_j2", ( - f"entry_point must be 'radial_return_j2', got {result['entry_point']!r}" - ) - - # line_count: positive int consistent with code - assert isinstance(result["line_count"], int) and result["line_count"] > 0, ( - "line_count must be a positive int" - ) - assert result["line_count"] == len(result["code"].splitlines()), ( - "line_count must equal len(code.splitlines())" - ) - - # valid_python: bool - assert isinstance(result["valid_python"], bool), "valid_python must be a bool" - - # ----------------------------------------------------------------------- - # AC[1]: valid_python reflects compile() check - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_valid_python_true(self): - """Verifies: valid_python True. - - AC[1]: valid_python is True for RADIAL_RETURN_J2_LATEX because the - transpiled source is syntactically valid Python. Also verifies that - the result's code field can itself be compiled (double-check). - """ - from algo2code.library.radial_return_j2 import RADIAL_RETURN_J2_LATEX - from mechdsl.integration import transpile_algorithm - - result = transpile_algorithm(RADIAL_RETURN_J2_LATEX, backend="taichi") - - assert result["valid_python"] is True, ( - f"valid_python must be True for RADIAL_RETURN_J2_LATEX.\n" - f"Transpiled code:\n{result['code']}" - ) - - # Belt-and-suspenders: confirm the code field actually compiles. - try: - compile(result["code"], "", "exec") - except SyntaxError as exc: - pytest.fail( - f"result['code'] does not compile, but valid_python is True.\n" - f"SyntaxError: {exc}\n" - f"Code:\n{result['code']}" - ) - - @pytest.mark.unit - def test_valid_python_false_on_invalid_code(self, monkeypatch): - """valid_python is False when the transpiled source has a SyntaxError. - - Uses monkeypatch to inject a SyntaxError-producing stub so the test - does not rely on algo2code ever emitting broken Python. - """ - # Replace algo2code.transpile with a stub that returns broken Python. - import algo2code - import mechdsl.integration as integration_mod - - original_transpile = algo2code.transpile - - def _broken_transpile(source: str, backend: str = "taichi") -> str: - return "def broken(:\n pass" # deliberate SyntaxError - - monkeypatch.setattr(algo2code, "transpile", _broken_transpile) - # Also patch the name inside the integration module's lazy import scope. - # Since integration uses `from algo2code import transpile` lazily inside - # the function, we need to also patch the algo2code module attribute so - # the lazy import picks up the stub. The monkeypatch above handles that. - - try: - # We need to import algo2code inside the function; monkeypatching the - # module-level attribute is sufficient because Python's import system - # returns the same module object. - from algo2code.library.radial_return_j2 import RADIAL_RETURN_J2_LATEX - - # Call the real transpile_algorithm but with a patched algo2code.transpile. - # Because the lazy import inside transpile_algorithm does - # `from algo2code import transpile`, we need the stub to be on the - # algo2code module object (which monkeypatch.setattr above did). - # However, `from X import Y` inside a function binds the local name Y - # to the current value of algo2code.transpile at call time — so our - # monkeypatch does take effect. - result = integration_mod.transpile_algorithm(RADIAL_RETURN_J2_LATEX, backend="taichi") - assert result["valid_python"] is False, ( - "valid_python must be False when transpiled code has a SyntaxError" - ) - finally: - monkeypatch.setattr(algo2code, "transpile", original_transpile) - - # ----------------------------------------------------------------------- - # AC[2]: no ti.init - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_no_ti_init(self): - """Verifies: no ti.init. - - AC[2]: Calling transpile_algorithm() must not trigger ti.init. - - Uses a subprocess with a fresh interpreter so no parent-process state - can contaminate the result. The subprocess exits with code 0 on - success or code 1 if taichi appears in sys.modules after the call. - - Note: the *transpiled source string* contains ``ti.init(...)`` as text - (that is what the Taichi backend emits), but transpile_algorithm() only - returns it as a string — it never executes it. The subprocess check - confirms this invariant holds end-to-end. - """ - script = ( - "import sys; " - "from mechdsl.integration import transpile_algorithm; " - "from algo2code.library.radial_return_j2 import RADIAL_RETURN_J2_LATEX; " - "result = transpile_algorithm(RADIAL_RETURN_J2_LATEX, backend='taichi'); " - "assert result['entry_point'] == 'radial_return_j2'; " - "taichi_loaded = 'taichi' in sys.modules; " - "print('taichi_loaded:', taichi_loaded); " - "sys.exit(1 if taichi_loaded else 0)" - ) - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - timeout=60, - ) - assert result.returncode == 0, ( - "transpile_algorithm() triggered ti.init (taichi was loaded in subprocess).\n" - f"stdout: {result.stdout!r}\n" - f"stderr: {result.stderr!r}" - ) - assert "taichi_loaded: False" in result.stdout, ( - f"Expected 'taichi_loaded: False' in subprocess stdout.\n" - f"stdout: {result.stdout!r}\n" - f"stderr: {result.stderr!r}" - ) - - @pytest.mark.unit - def test_no_ti_init_monkeypatch(self, monkeypatch): - """Secondary guard: if ti.init were called during transpile_algorithm(), - the monkeypatched version would raise immediately. - - Complements the subprocess test — runs in-process using a fake taichi - module whose init() raises, then calls transpile_algorithm(). Because - the transpile path is Taichi-free this must not raise. - """ - import types - - fake_ti = types.ModuleType("taichi") - - def _init_raises(*args, **kwargs): - raise RuntimeError("ti.init was called — Taichi-free guarantee violated!") - - fake_ti.init = _init_raises # type: ignore[attr-defined] - - monkeypatch.setitem(sys.modules, "taichi", fake_ti) - - from algo2code.library.radial_return_j2 import RADIAL_RETURN_J2_LATEX - from mechdsl.integration import transpile_algorithm - - # Must not raise even with the sentinel taichi installed. - result = transpile_algorithm(RADIAL_RETURN_J2_LATEX, backend="taichi") - assert isinstance(result, dict), "transpile_algorithm() must return a dict" - assert result["entry_point"] == "radial_return_j2", ( - f"entry_point must be 'radial_return_j2', got {result['entry_point']!r}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-4.py b/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-4.py deleted file mode 100644 index 8f717c6..0000000 --- a/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-4.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Tests for Task P1-4: verify() — verify harness wrapper (Taichi-paying path). - -Acceptance criteria: - AC[0]: verify('patch_test', ...) runs and returns a result dict. - AC[1]: unknown kind raises a clear error. - AC[2]: calling verify() does NOT break the module-level Taichi-free invariant - (the lazy-import structure is confirmed by the P1-1 subprocess test, - but we add a fast in-process guard here too). -""" - -from __future__ import annotations - -import subprocess -import sys -import types - -import pytest - - -class TestTaskP1_4: - """Tests for Task P1-4: verify() — verify harness wrapper (Taichi-paying path).""" - - # ----------------------------------------------------------------------- - # AC[1]: unknown kind error — fast, no Taichi - # ----------------------------------------------------------------------- - - @pytest.mark.integration - def test_unknown_kind_error(self): - """verify('nonsense_kind', {}) raises ValueError mentioning supported kinds. - - AC[1]: unknown kind raises a clear error. - """ - from mechdsl.integration import verify - - with pytest.raises(ValueError, match=r"Supported kinds:") as exc_info: - verify("nonsense_kind", {}) - - msg = str(exc_info.value) - # The error must name at least one supported kind so callers can self-correct. - assert "patch_test" in msg, ( - f"ValueError should mention 'patch_test' as a supported kind. Got: {msg!r}" - ) - # The new benchmark kind must also be listed - assert "benchmark" in msg, ( - f"ValueError should mention 'benchmark' as a supported kind. Got: {msg!r}" - ) - - # ----------------------------------------------------------------------- - # AC[2]: lazy-import invariant — fast, in-process guard - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_verify_importable_without_taichi(self, monkeypatch): - """In-process attribute-access guard: importing mechdsl.integration and - reading the ``verify`` attribute does not invoke ``ti.init``. - - This test only proves that attribute access and calling the cheap - (non-Taichi) entry points does not trigger ``ti.init`` in-process. - It is NOT the authoritative Taichi-free guard — the subprocess test - ``test_no_ti_init_on_import_includes_verify_symbol`` (below) is the - load-bearing check, because a fresh interpreter is used there so no - parent-process Taichi state can contaminate the result. - """ - import importlib - - # Fake taichi that explodes if imported - fake_ti = types.ModuleType("taichi") - - def _init_raises(*args, **kwargs): - raise RuntimeError("ti.init was called — Taichi-free guarantee violated!") - - fake_ti.init = _init_raises # type: ignore[attr-defined] - monkeypatch.setitem(sys.modules, "taichi", fake_ti) - - # Importing the module and accessing `verify` must not trigger ti.init. - integration = importlib.import_module("mechdsl.integration") - verify_fn = integration.verify - - # verify itself is callable and in __all__ - assert callable(verify_fn), "verify must be callable" - assert "verify" in integration.__all__, "verify must be listed in __all__" - - @pytest.mark.unit - def test_verify_in_all(self): - """verify is exported in mechdsl.integration.__all__.""" - import mechdsl.integration as mod - - assert "verify" in mod.__all__, f"'verify' missing from __all__; got {mod.__all__!r}" - - # ----------------------------------------------------------------------- - # AC[0]: verify('patch_test') runs and returns a result dict — slow - # ----------------------------------------------------------------------- - - @pytest.mark.integration - @pytest.mark.slow - def test_verify_patch_test_runs_slow(self): - """verify('patch_test', ...) runs and returns a passing result dict. - - AC[0]: verify('patch_test', ...) runs and returns a result dict. - - Uses a minimal 2×2×2 mesh with unit Lame parameters so the test - completes quickly while still exercising the full call path. - """ - from mechdsl.integration import verify - - result = verify( - "patch_test", - { - "lam": 1.0, - "mu": 1.0, - "nx": 2, - "ny": 2, - "nz": 2, - "tol": 1e-12, - }, - ) - - # Shape contract: must be a dict with the three top-level keys - assert isinstance(result, dict), f"verify() must return a dict; got {type(result)!r}" - assert "kind" in result, f"result must have 'kind' key; got {result!r}" - assert "passed" in result, f"result must have 'passed' key; got {result!r}" - assert "details" in result, f"result must have 'details' key; got {result!r}" - - # kind echoes back the input - assert result["kind"] == "patch_test", ( - f"result['kind'] must be 'patch_test'; got {result['kind']!r}" - ) - - # passed must be a plain bool - assert isinstance(result["passed"], bool), ( - f"result['passed'] must be bool; got {type(result['passed'])!r}" - ) - - # The patch test should pass on a regular mesh with unit material - assert result["passed"], f"Patch test FAILED — details: {result['details']!r}" - - # details shape: verify the mandatory diagnostic fields are present - details = result["details"] - assert isinstance(details, dict), f"result['details'] must be a dict; got {type(details)!r}" - for key in ( - "error", - "tol", - "interior_force_max", - "boundary_force_sum", - "n_nodes", - "n_elements", - ): - assert key in details, f"details must contain '{key}'; got keys {list(details)!r}" - - # Numeric sanity on error field - assert isinstance(details["error"], float), ( - f"details['error'] must be float; got {type(details['error'])!r}" - ) - assert details["error"] < 1e-12, f"Patch-test error {details['error']:.3e} must be < 1e-12" - - @pytest.mark.integration - @pytest.mark.slow - def test_verify_patch_test_custom_strain(self): - """verify('patch_test') accepts an explicit strain tensor in params.""" - from mechdsl.integration import verify - - # Hydrostatic strain — should also pass the patch test - strain = [[1e-4, 0.0, 0.0], [0.0, 1e-4, 0.0], [0.0, 0.0, 1e-4]] - result = verify("patch_test", {"lam": 1.0, "mu": 1.0, "strain": strain}) - - assert result["kind"] == "patch_test" - assert isinstance(result["passed"], bool) - assert result["passed"], f"Patch test with hydrostatic strain failed: {result['details']}" - - @pytest.mark.integration - @pytest.mark.slow - def test_verify_result_is_json_friendly(self): - """The result dict from verify('patch_test') round-trips through json.dumps.""" - import json - - from mechdsl.integration import verify - - result = verify("patch_test", {"lam": 1.0, "mu": 1.0, "nx": 1, "ny": 1, "nz": 1}) - - # Should not raise - serialised = json.dumps(result) - recovered = json.loads(serialised) - assert recovered["kind"] == "patch_test" - - # ----------------------------------------------------------------------- - # Subprocess confirmation: verify present in module + module still Taichi-free - # ----------------------------------------------------------------------- - - @pytest.mark.unit - def test_no_ti_init_on_import_includes_verify_symbol(self): - """Subprocess: importing mechdsl.integration (including verify symbol) stays - Taichi-free — complements the P1-1 subprocess test.""" - script = ( - "import sys; " - "from mechdsl.integration import capabilities, model_catalog, verify; " - "capabilities(); " - "model_catalog(); " - # Do NOT call verify() — we are testing the import path only. - "taichi_loaded = 'taichi' in sys.modules; " - "print('taichi_loaded:', taichi_loaded); " - "sys.exit(1 if taichi_loaded else 0)" - ) - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - timeout=60, - ) - assert result.returncode == 0, ( - "Importing mechdsl.integration.verify triggered ti.init.\n" - f"stdout: {result.stdout!r}\n" - f"stderr: {result.stderr!r}" - ) - assert "taichi_loaded: False" in result.stdout, ( - f"Expected 'taichi_loaded: False' in subprocess stdout.\n" - f"stdout: {result.stdout!r}\n" - f"stderr: {result.stderr!r}" - ) - - # ----------------------------------------------------------------------- - # AC[new]: verify("benchmark", ...) — new benchmark kind - # ----------------------------------------------------------------------- - - @pytest.mark.integration - def test_benchmark_kind_accepted_by_kind_guard(self): - """'benchmark' is in _VERIFY_KINDS — it is accepted by the kind guard.""" - from mechdsl.integration import _VERIFY_KINDS - - assert "benchmark" in _VERIFY_KINDS, ( - f"'benchmark' must be in _VERIFY_KINDS; got {_VERIFY_KINDS!r}" - ) - - @pytest.mark.integration - def test_benchmark_missing_name_raises_value_error(self): - """verify('benchmark', {}) raises ValueError (fast, no Taichi) for missing name.""" - from mechdsl.integration import verify - - with pytest.raises(ValueError): - verify("benchmark", {}) - - @pytest.mark.integration - def test_benchmark_unknown_name_raises_value_error(self): - """verify('benchmark', {'name': 'unknown'}) raises ValueError.""" - from mechdsl.integration import verify - - with pytest.raises(ValueError, match="unknown"): - verify("benchmark", {"name": "unknown"}) - - @pytest.mark.integration - @pytest.mark.slow - def test_verify_benchmark_cantilever_result_shape(self): - """verify('benchmark', {'name':'cantilever'}) returns the normalised dict. - - AC[new]: benchmark kind returns {kind, passed, details} with the - documented details keys. Marked slow because it runs a full solve. - """ - import json - - from mechdsl.integration import verify - - result = verify("benchmark", {"name": "cantilever"}) - - # Top-level shape - assert isinstance(result, dict) - assert result["kind"] == "benchmark" - assert isinstance(result["passed"], bool) - assert isinstance(result["details"], dict) - - # details keys - details = result["details"] - for key in ("benchmark", "newton_iters", "wallclock_s", "n_nodes", "extras_keys"): - assert key in details, f"details must contain '{key}'; got {list(details)!r}" - - assert details["benchmark"] == "cantilever" - assert isinstance(details["newton_iters"], int) - assert isinstance(details["wallclock_s"], float) - assert isinstance(details["n_nodes"], int) and details["n_nodes"] > 0 - assert isinstance(details["extras_keys"], list) - - # Result must be JSON-friendly - json.dumps(result) # must not raise - - # The benchmark must report passed=True - assert result["passed"], f"Cantilever benchmark FAILED — details: {details!r}" diff --git a/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-5.py b/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-5.py deleted file mode 100644 index 6f7d033..0000000 --- a/packages/mechdsl-core/tests/plan_tests/akms_executable_bridge/test_P1-5.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Tests for Task P1-5: test_integration_surface.py + README façade note. - -These are lightweight task-scoped acceptance checks that complement the -canonical holistic test in -``packages/mechdsl-core/tests/test_integration_surface.py``. - -AC[0]: All façade tests pass (smoke: all five functions are importable from - mechdsl.integration and present in __all__). -AC[1]: no-ti.init assertion present and passing (fast subprocess guard that - the Taichi-free invariant holds after importing all five symbols). -""" - -from __future__ import annotations - -import subprocess -import sys - -import pytest - - -class TestTaskP1_5: - """Acceptance checks for Task P1-5: test_integration_surface.py + README façade note.""" - - # ----------------------------------------------------------------------- - # AC[0]: Full façade surface — smoke import of all five functions - # ----------------------------------------------------------------------- - - @pytest.mark.integration - def test_full_facade_surface_all_five_functions_importable(self): - """All five façade entry points are importable from mechdsl.integration and in __all__. - - AC[0]: All façade tests pass under `uv run pytest ... -q`. - - This is the minimal smoke check: verifies the module is coherent and - all five symbols are exported. The holistic contract tests live in - ``tests/test_integration_surface.py``. - """ - import mechdsl.integration as mod - from mechdsl.integration import ( # noqa: F401 - capabilities, - compile_from_sources, - model_catalog, - transpile_algorithm, - verify, - ) - - five_functions = ( - "capabilities", - "compile_from_sources", - "model_catalog", - "transpile_algorithm", - "verify", - ) - for name in five_functions: - assert hasattr(mod, name), f"{name!r} not found on mechdsl.integration" - assert callable(getattr(mod, name)), f"{name!r} must be callable" - assert name in mod.__all__, ( - f"{name!r} missing from mechdsl.integration.__all__; got {mod.__all__!r}" - ) - - # Also confirm 'integration' is re-exported from the top-level package. - import mechdsl - - assert "integration" in mechdsl.__all__, ( - f"'integration' missing from mechdsl.__all__; got {mechdsl.__all__!r}" - ) - - # ----------------------------------------------------------------------- - # AC[1]: no-ti.init guard — thin subprocess check - # ----------------------------------------------------------------------- - - @pytest.mark.integration - def test_ti_init_guard(self): - """Importing all five symbols and calling the Taichi-free four must not load Taichi. - - AC[1]: no-ti.init assertion present and passing. - - Uses a fresh-interpreter subprocess — the canonical form of this guard. - The comprehensive version (covering compile_from_sources and - transpile_algorithm individually) lives in - ``tests/test_integration_surface.py::TestTaichiFreeInvariant``. - """ - script = ( - "import sys; " - "from mechdsl.integration import (" - " capabilities, model_catalog, compile_from_sources, " - " transpile_algorithm, verify" - "); " - # Call only the Taichi-free four (not verify, which is allowed to pay the cost). - "capabilities(); " - "model_catalog(); " - "loaded = 'taichi' in sys.modules; " - "print('taichi_loaded:', loaded); " - "sys.exit(1 if loaded else 0)" - ) - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - timeout=60, - ) - assert result.returncode == 0, ( - "Importing mechdsl.integration (all five symbols) + calling " - "capabilities()/model_catalog() triggered Taichi load.\n" - f"stdout: {result.stdout!r}\n" - f"stderr: {result.stderr!r}" - ) - assert "taichi_loaded: False" in result.stdout, ( - f"Expected 'taichi_loaded: False' in subprocess stdout.\n" - f"stdout: {result.stdout!r}\n" - f"stderr: {result.stderr!r}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/__init__.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/__init__.py deleted file mode 100644 index 058d84a..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Plan tests for constitutive_latex phase tasks.""" diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P1-1.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P1-1.py deleted file mode 100644 index fd8fe60..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P1-1.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Tests for Task P1-1: Anisotropic invariants i4/i5 + closed-form derivatives. - -Covers: i4(C,a)=a·C·a and i5(C,a)=a·C²·a (fiber direction a) plus their -closed-form derivatives dI4/dC=a⊗a and dI5/dC=a⊗(C·a)+(C·a)⊗a (symmetrised). -""" - -from __future__ import annotations - -import pytest -import sympy as sp - -from mechdsl.symbolic.invariants import ( - i4, - i4_derivative, - i5, - i5_derivative, -) - - -class TestTaskP1_1: - """Tests for Task P1-1: Anisotropic invariants i4/i5 + derivatives. AC covered: 1,2,3.""" - - @pytest.mark.unit - def test_i4_i5_of_identity_with_axial_fiber(self) -> None: - """Verifies: i4 and i5 of identity C with axial fiber direction. - AC: i4/i5 and their derivatives match component-wise sympy.diff at numeric C. - Passes when: i4(I, e_x) == 1 and i5(I, e_x) == 1.""" - eye = sp.eye(3) - e_x = sp.Matrix([1, 0, 0]) - e_y = sp.Matrix([0, 1, 0]) - - # Axial fiber along x: C=I so I4 = e_x · I · e_x = 1, I5 = e_x · I² · e_x = 1 - assert i4(eye, e_x) == 1 - assert i5(eye, e_x) == 1 - - # Non-axial fiber along y: same result for identity - assert i4(eye, e_y) == 1 - assert i5(eye, e_y) == 1 - - @pytest.mark.unit - def test_i4_i5_derivatives_vs_sympy_diff(self) -> None: - """Verifies: Closed-form dI4/dC and dI5/dC match component-wise sympy.diff. - AC: i4/i5 and their derivatives match component-wise sympy.diff at numeric C. - Passes when: all components equal to numerical tolerance after substitution.""" - # Build a generic (non-symmetric) symbolic matrix so each component is - # independent — this is the correct oracle for the closed-form derivatives. - c = sp.symbols("c0:9", real=True) - C = sp.Matrix(3, 3, c) - a = sp.Matrix([1, 0, 0]) # axial fiber keeps expressions compact - - subs = {c[k]: v for k, v in enumerate([4, 1, 2, 0, 5, 1, 3, 2, 6])} - - # --- i4 --- - scalar_i4 = i4(C, a) - closed_i4 = i4_derivative(C, a) - for r in range(3): - for c_idx in range(3): - diffed = sp.diff(scalar_i4, C[r, c_idx]) - assert sp.simplify((closed_i4[r, c_idx] - diffed).subs(subs)) == 0, ( - f"dI4/dC[{r},{c_idx}] mismatch" - ) - - # --- i5 --- - scalar_i5 = i5(C, a) - closed_i5 = i5_derivative(C, a) - for r in range(3): - for c_idx in range(3): - diffed = sp.diff(scalar_i5, C[r, c_idx]) - assert sp.simplify((closed_i5[r, c_idx] - diffed).subs(subs)) == 0, ( - f"dI5/dC[{r},{c_idx}] mismatch" - ) - - @pytest.mark.unit - def test_uniaxial_fiber_stretch_i4_equals_lambda_squared(self) -> None: - """Verifies: Uniaxial fiber stretch case where i4 = λ_fiber². - AC: Known fiber-stretch case: i4 of a unit-axial fiber under stretch λ equals λ². - Passes when: i4(diag(λ, 1, 1), e_x) == λ².""" - lam = sp.Symbol("lambda", positive=True) - F = sp.diag(lam, 1, 1) - C = F.T @ F # = diag(λ², 1, 1) - e_x = sp.Matrix([1, 0, 0]) - - result = i4(C, e_x) - assert sp.simplify(result - lam**2) == 0 diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P1-2.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P1-2.py deleted file mode 100644 index 2aa3517..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P1-2.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Tests for Task P1-2: Coordinate-system consolidation (absorb constkit into convected.py). - -Covers the new additive capability absorbed from constkit `coordinates.py`: -- cylindrical_basis / spherical_basis -> metric_from_bases give the textbook metrics. -- reciprocal_bases + verify_biorthogonality (g_I . g^J = delta). -- christoffel_from_bases composes the authoritative metric-based christoffel_symbols. -- covariant_derivative_tensor2 "covariant"/"mixed" variants, plus a guarantee that the - default "contravariant" path is byte-for-byte unchanged. -""" - -from __future__ import annotations - -import pytest -import sympy as sp - -from mechdsl.symbolic.convected import ( - christoffel_from_bases, - christoffel_symbols, - covariant_derivative_tensor2, - cylindrical_basis, - metric_from_bases, - reciprocal_bases, - spherical_basis, - verify_biorthogonality, -) - - -class TestTaskP1_2: - """Tests for Task P1-2: absorbed curvilinear constructors + tensor cov-deriv variants.""" - - # --- cylindrical / spherical metric oracles --------------------------------- - - @pytest.mark.unit - def test_cylindrical_basis_metric_is_diag_1_r2_1(self) -> None: - """Verifies: cylindrical_basis -> metric_from_bases == diag(1, r^2, 1). - Passes when: the computed metric equals the textbook cylindrical metric.""" - r = sp.Symbol("r", positive=True) - phi = sp.Symbol("phi") - g = metric_from_bases(cylindrical_basis(r, phi)) - expected = sp.diag(1, r**2, 1) - assert sp.simplify(g - expected) == sp.zeros(3) - - @pytest.mark.unit - def test_spherical_basis_metric_is_diag_1_R2_R2sin2(self) -> None: - """Verifies: spherical_basis -> metric_from_bases == diag(1, R^2, R^2 sin^2 theta). - Passes when: the computed metric equals the textbook spherical metric.""" - R = sp.Symbol("R", positive=True) - theta = sp.Symbol("theta", positive=True) - phi = sp.Symbol("phi") - g = metric_from_bases(spherical_basis(R, theta, phi)) - expected = sp.diag(1, R**2, R**2 * sp.sin(theta) ** 2) - assert sp.simplify(g - expected) == sp.zeros(3) - - # --- biorthogonality -------------------------------------------------------- - - @pytest.mark.unit - def test_biorthogonality_cylindrical_pair_true(self) -> None: - """Verifies: reciprocal_bases of cylindrical g_I satisfies g_I . g^J = delta. - Passes when: verify_biorthogonality returns True for the cov/contra pair.""" - r = sp.Symbol("r", positive=True) - phi = sp.Symbol("phi") - cov = cylindrical_basis(r, phi) - contra = reciprocal_bases(cov) - assert verify_biorthogonality(cov, contra) is True - - @pytest.mark.unit - def test_biorthogonality_spherical_pair_true(self) -> None: - """Verifies: reciprocal_bases of spherical g_I satisfies g_I . g^J = delta. - Passes when: verify_biorthogonality returns True for the cov/contra pair.""" - R = sp.Symbol("R", positive=True) - theta = sp.Symbol("theta", positive=True) - phi = sp.Symbol("phi") - cov = spherical_basis(R, theta, phi) - contra = reciprocal_bases(cov) - assert verify_biorthogonality(cov, contra) is True - - @pytest.mark.unit - def test_biorthogonality_failure_raises(self) -> None: - """Verifies: a deliberately wrong contravariant set fails the delta check. - Passes when: verify_biorthogonality raises AssertionError.""" - r = sp.Symbol("r", positive=True) - phi = sp.Symbol("phi") - cov = cylindrical_basis(r, phi) - bad_contra = cov # covariant != contravariant for r != 1 - with pytest.raises(AssertionError, match="Biorthogonality failed"): - verify_biorthogonality(cov, bad_contra) - - # --- christoffel_from_bases composes the authoritative implementation ------- - - @pytest.mark.regression - def test_christoffel_from_bases_matches_metric_based(self) -> None: - """Verifies: christoffel_from_bases == christoffel_symbols(metric_from_bases(...)). - Guards the no-duplication contract (composition, not reimplementation). - Passes when: every component agrees.""" - r = sp.Symbol("r", positive=True) - th = sp.Symbol("theta", positive=True) - z = sp.Symbol("z") - coords = (r, th, z) - cov = cylindrical_basis(r, th) - - from_bases = christoffel_from_bases(cov, coords) - from_metric = christoffel_symbols(metric_from_bases(cov), coords) - - for k in range(3): - for i in range(3): - for j in range(3): - assert sp.simplify(from_bases[k, i, j] - from_metric[k, i, j]) == 0 - - @pytest.mark.unit - def test_christoffel_from_bases_cylindrical_closed_form(self) -> None: - """Verifies: cylindrical Christoffels from bases match the hand values. - Gamma^r_{th,th} = -r, Gamma^th_{r,th} = Gamma^th_{th,r} = 1/r, rest 0.""" - r = sp.Symbol("r", positive=True) - th = sp.Symbol("theta", positive=True) - z = sp.Symbol("z") - coords = (r, th, z) - gamma = christoffel_from_bases(cylindrical_basis(r, th), coords) - - assert sp.simplify(gamma[0, 1, 1] - (-r)) == 0 - assert sp.simplify(gamma[1, 0, 1] - 1 / r) == 0 - assert sp.simplify(gamma[1, 1, 0] - 1 / r) == 0 - non_zero = {(0, 1, 1), (1, 0, 1), (1, 1, 0)} - for k in range(3): - for i in range(3): - for j in range(3): - if (k, i, j) not in non_zero: - assert sp.simplify(gamma[k, i, j]) == 0 - - # --- covariant_derivative_tensor2 variants --------------------------------- - - @pytest.mark.regression - def test_tensor2_contravariant_default_unchanged(self) -> None: - """Verifies: the default path equals an explicit variant='contravariant' call, - and matches the legacy inline formula. Guards the load-bearing default path.""" - r = sp.Symbol("r", positive=True) - th = sp.Symbol("theta", positive=True) - z = sp.Symbol("z") - theta = (r, th, z) - gamma = christoffel_symbols(sp.diag(1, r**2, 1), theta) - T = sp.Matrix([[r, 0, 0], [0, sp.S.Zero, 0], [0, 0, 0]]) - - default = covariant_derivative_tensor2(T, gamma, theta) - explicit = covariant_derivative_tensor2(T, gamma, theta, variant="contravariant") - - # default == explicit contravariant - for I in range(3): - for J in range(3): - for K in range(3): - assert default[I, J, K] == explicit[I, J, K] - - # default matches the legacy inline contravariant formula exactly - for I in range(3): - for J in range(3): - for K in range(3): - val = sp.diff(T[J, K], theta[I]) - for L in range(3): - val = val + gamma[J, I, L] * T[L, K] + gamma[K, I, L] * T[J, L] - assert default[I, J, K] == val - - @pytest.mark.unit - def test_tensor2_variants_reduce_to_partial_in_cartesian(self) -> None: - """Verifies: in Cartesian (zero Christoffels) every variant reduces to - the ordinary partial derivative dT[J,K]/dtheta^I.""" - x, y, z = sp.symbols("x y z") - theta = (x, y, z) - gamma = christoffel_symbols(sp.eye(3), theta) - # general symbolic tensor field - T = sp.Matrix([[sp.Function(f"T{a}{b}")(x, y, z) for b in range(3)] for a in range(3)]) - - for variant in ("contravariant", "covariant", "mixed"): - result = covariant_derivative_tensor2(T, gamma, theta, variant=variant) - for I in range(3): - for J in range(3): - for K in range(3): - expected = sp.diff(T[J, K], theta[I]) - assert sp.simplify(result[I, J, K] - expected) == 0 - - @pytest.mark.unit - def test_tensor2_covariant_variant_cylindrical_sanity(self) -> None: - """Verifies: covariant rank-2 cov-derivative in cylindrical coords matches - the hand-derived formula nabla_I T_{JK} = dT_{JK} - Gamma^L_{IJ}T_{LK} - Gamma^L_{IK}T_{JL}. - Uses T_{JK} = diag(r, 0, 0).""" - r = sp.Symbol("r", positive=True) - th = sp.Symbol("theta", positive=True) - z = sp.Symbol("z") - theta = (r, th, z) - gamma = christoffel_symbols(sp.diag(1, r**2, 1), theta) - T = sp.Matrix([[r, 0, 0], [0, sp.S.Zero, 0], [0, 0, 0]]) - - result = covariant_derivative_tensor2(T, gamma, theta, variant="covariant") - - # Reference hand formula - for I in range(3): - for J in range(3): - for K in range(3): - val = sp.diff(T[J, K], theta[I]) - for L in range(3): - val = val - gamma[L, I, J] * T[L, K] - gamma[L, I, K] * T[J, L] - assert sp.simplify(result[I, J, K] - val) == 0 - - # Spot-check a known component: nabla_r T_rr = dT_rr/dr = 1 - assert sp.simplify(result[0, 0, 0] - 1) == 0 - - @pytest.mark.unit - def test_tensor2_mixed_variant_cylindrical_sanity(self) -> None: - """Verifies: mixed rank-2 cov-derivative matches the hand formula - nabla_I T^J_K = dT^J_K + Gamma^J_{IL}T^L_K - Gamma^L_{IK}T^J_L.""" - r = sp.Symbol("r", positive=True) - th = sp.Symbol("theta", positive=True) - z = sp.Symbol("z") - theta = (r, th, z) - gamma = christoffel_symbols(sp.diag(1, r**2, 1), theta) - T = sp.Matrix([[r, 0, 0], [0, sp.S.Zero, 0], [0, 0, 0]]) - - result = covariant_derivative_tensor2(T, gamma, theta, variant="mixed") - - for I in range(3): - for J in range(3): - for K in range(3): - val = sp.diff(T[J, K], theta[I]) - for L in range(3): - val = val + gamma[J, I, L] * T[L, K] - gamma[L, I, K] * T[J, L] - assert sp.simplify(result[I, J, K] - val) == 0 - - @pytest.mark.unit - def test_tensor2_invalid_variant_raises(self) -> None: - """Verifies: an unsupported variant raises ValueError.""" - r = sp.Symbol("r", positive=True) - th = sp.Symbol("theta", positive=True) - z = sp.Symbol("z") - theta = (r, th, z) - gamma = christoffel_symbols(sp.diag(1, r**2, 1), theta) - T = sp.diag(r, 0, 0) - with pytest.raises(ValueError, match="variant must be"): - covariant_derivative_tensor2(T, gamma, theta, variant="bogus") diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P1-3.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P1-3.py deleted file mode 100644 index 5cd4845..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P1-3.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Tests for Task P1-3: Kinematics expansion (isochoric quantities + principal stretches first-class). - -These tests verify that KinematicsResult exposes isochoric quantities (F_bar, C_bar, -Ibar1, Ibar2, J) and principal stretches as first-class accessors, sourced from -invariants.py (isochoric_split, principal_stretches). - -Acceptance criteria covered: -- AC1: Isochoric quantities and stretches sourced from invariants.py (no re-implementation) -- AC2: Existing kinematics tests pass unchanged -- AC3: det(F_bar)==1, Ibar1(I)==3, prod(stretches)==J verified -""" - -import functools - -import pytest -import sympy as sp - -from mechdsl.symbolic.kinematics import compute_from_displacement_gradient - - -class TestTaskP1_3: - """Tests for Task P1-3: Kinematics expansion (isochoric + stretches first-class). - - AC covered: 1, 3. - """ - - @pytest.mark.unit - def test_ibar1_at_identity_equals_3(self): - """Verifies: Ibar1 (first isochoric invariant of C_bar) equals 3 at identity. - - AC: det(F_bar)==1, Ibar1(I)==3, prod(stretches)==J verified. - - Passes when: KinematicsResult.ibar1 returns 3 for identity deformation. - """ - result = compute_from_displacement_gradient(sp.zeros(3)) - assert sp.simplify(result.Ibar1 - 3) == 0 - - @pytest.mark.unit - def test_det_fbar_equals_1(self): - """Verifies: det(F_bar) = 1 for any deformation (volume-preserving). - - AC: det(F_bar)==1, Ibar1(I)==3, prod(stretches)==J verified. - - Passes when: KinematicsResult.F_bar has unit determinant. - """ - # Test at identity - result_id = compute_from_displacement_gradient(sp.zeros(3)) - assert sp.simplify(result_id.F_bar.det() - 1) == 0 - - # Test at a non-trivial deformation: uniaxial stretch with lambda > 0 - lam = sp.Symbol("lambda", positive=True) - grad_u = sp.Matrix([[lam - 1, 0, 0], [0, 0, 0], [0, 0, 0]]) - result_stretch = compute_from_displacement_gradient(grad_u) - assert sp.simplify(result_stretch.F_bar.det() - 1) == 0 - - @pytest.mark.unit - def test_product_of_principal_stretches_equals_J(self): - """Verifies: Product of principal stretches equals J (determinant of F). - - AC: det(F_bar)==1, Ibar1(I)==3, prod(stretches)==J verified. - - Passes when: prod(KinematicsResult.principal_stretches) == KinematicsResult.J. - """ - # Use uniaxial stretch: principal stretches are lambda, 1, 1 => product = lambda = J - lam = sp.Symbol("lambda", positive=True) - grad_u = sp.Matrix([[lam - 1, 0, 0], [0, 0, 0], [0, 0, 0]]) - result = compute_from_displacement_gradient(grad_u) - - product = functools.reduce(lambda a, b: a * b, result.principal_stretches) - diff = sp.simplify(sp.expand(product - result.J)) - assert diff == 0, f"prod(stretches) - J = {diff}" - - @pytest.mark.unit - def test_principal_stretches_preserve_multiplicity(self): - """Verifies: principal_stretches returns one entry per spatial dimension. - - AC: stretches sourced from invariants.py without dropping eigenvalue - multiplicity. A uniaxial state has the repeated stretch 1 with - multiplicity 2, so the list must have length 3 (not 2). Guards the - spectral-model (Ogden, P4-2) contract that needs exactly 3 stretches. - - Passes when: len == 3 for both a repeated-eigenvalue (uniaxial) and a - distinct-eigenvalue (triaxial) deformation, and the product equals J in - both cases. - """ - l1, l2, l3 = sp.symbols("l1 l2 l3", positive=True) - - # Repeated eigenvalues: uniaxial -> stretches {lambda, 1, 1}. - uni = compute_from_displacement_gradient(sp.Matrix([[l1 - 1, 0, 0], [0, 0, 0], [0, 0, 0]])) - assert len(uni.principal_stretches) == 3 - - # Distinct eigenvalues: triaxial. - tri = compute_from_displacement_gradient(sp.diag(l1 - 1, l2 - 1, l3 - 1)) - assert len(tri.principal_stretches) == 3 - product = functools.reduce(lambda a, b: a * b, tri.principal_stretches) - assert sp.simplify(sp.expand(product - tri.J)) == 0 diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P2-1.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P2-1.py deleted file mode 100644 index 2991d53..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P2-1.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Tests for Task P2-1: Invariant-authoring contract + energy.py named-invariant binding. - -Verifies that a strain energy authored in named invariants (\\bar I_1, \\bar I_2, J, I_4, I_5) -derives S = 2 dPsi/dC and C = 4 d²Psi/dC dC via the existing dPsi/dE core (C = 2E + I, so -d/dE = 2 d/dC), without nrpylatex ever parsing \\det or \\log. - -Acceptance criteria: -- AC-1: Invariant symbol → definition substitution on C = 2E + I -- AC-2: S = 2 dPsi/dC matches reference for a known invariant energy -- AC-3: Unsupported invariant form rejects with phase pointer -""" - -from __future__ import annotations - -import numpy as np -import pytest -import sympy as sp - -from mechdsl.symbolic import invariants -from mechdsl.symbolic.energy import ( - EnergyDerivationError, - _bind_invariants, - _c_from_strain, - _strain_grid, - derive_from_energy, -) - - -def _zero_strain_grid() -> tuple[tuple[sp.Symbol, ...], ...]: - """A full EDD grid with no strain symbols present in psi (=> created).""" - return _strain_grid(sp.Integer(0)) - - -class TestTaskP2_1: - """Tests for Task P2-1: Invariant-authoring contract + energy.py binding. AC covered: 1-3.""" - - @pytest.mark.unit - def test_invariant_symbol_substitution_on_c(self): - """Verifies: Named invariant symbols (\\bar I_1, \\bar I_2, J, I_4, I_5) substitute - to their definitions evaluated on C = 2E + I. - AC: AC-1. - Passes when: A simple energy Psi = f(\\bar I_1) is rewritten as Psi = f(expr_in_C).""" - strain = _zero_strain_grid() - c = _c_from_strain(strain) - - # At E = 0, C = I, so the invariant definitions take their reference values. - zero = {strain[i][j]: 0 for i in range(3) for j in range(3)} - - # Ibar1 = I1 * I3^{-1/3} -> tr(I) * 1 = 3 at C = I. - ibar1 = _bind_invariants(sp.Symbol("Ibar1"), strain, "") - assert ibar1 != sp.Symbol("Ibar1"), "Ibar1 must be substituted, not left bare" - assert sp.Symbol("Ibar1") not in ibar1.free_symbols - assert sp.simplify(ibar1.subs(zero)) == 3 - - # Ibar2 = I2 * I3^{-2/3} -> i2(I) = 3 at C = I. - ibar2 = _bind_invariants(sp.Symbol("Ibar2"), strain, "") - assert sp.simplify(ibar2.subs(zero)) == 3 - - # Jdet = sqrt(det C) -> 1 at C = I. - jdet = _bind_invariants(sp.Symbol("Jdet"), strain, "") - assert sp.simplify(jdet.subs(zero)) == 1 - - # The substituted expression must equal the invariants.py definition on C = 2E + I - # (no independent re-derivation): Ibar1 == i1(C) * i3(C)^{-1/3}, symbolically. - ibar1_ref = invariants.i1(c) * invariants.i3(c) ** sp.Rational(-1, 3) - assert sp.simplify(ibar1 - ibar1_ref) == 0 - - # Bare material parameters (not invariants) are left untouched. - passthrough = _bind_invariants(sp.Symbol("mu") * sp.Symbol("kappa"), strain, "") - assert passthrough == sp.Symbol("mu") * sp.Symbol("kappa") - - @pytest.mark.unit - def test_derived_stress_matches_reference_invariant_energy(self): - """Verifies: An energy authored in named invariants derives S(C) matching - a hand-computed reference via the C = 2E + I binding. - AC: AC-2. - Passes when: Derived S_IJ = 2 dPsi/dC reproduces a known closed-form stress - to within numerical tolerance (< 1e-9 relative error).""" - # Single-term volumetric energy: Psi = (kappa/2)(J - 1)^2. - # S = 2 dPsi/dC = 2 * kappa(J-1) dJ/dC, dJ/dC = (J/2) C^{-1} - # => S = kappa (J - 1) J C^{-1} (hand-derived closed form). - src = r""" - % declare metric gDD --dim 3 - % declare EDD --dim 3 - % declare \kappa --const - \Psi = \frac{\kappa}{2} (\mathrm{Jdet} - 1)^2 - """ - model = derive_from_energy(src) - - # No nrpylatex parse of \det / \log occurred: the parsed psi carried only - # the bare invariant symbol (acceptance criterion #2). - kappa = next(s for s in model.pk2.free_symbols if s.name == "kappa") - strain = model.strain_symbols - - kap_val = 1.5e5 - rng = np.random.default_rng(20260604) - for _ in range(25): - a = rng.standard_normal((3, 3)) * 0.1 - e = 0.5 * (a + a.T) # symmetric Green-Lagrange strain - subs: dict = {kappa: kap_val} - for i in range(3): - for j in range(3): - subs[strain[i][j]] = float(e[i, j]) - - s_derived = np.array( - [[float(model.pk2[i, j].subs(subs)) for j in range(3)] for i in range(3)] - ) - - c_num = 2.0 * e + np.eye(3) - j_det = np.sqrt(np.linalg.det(c_num)) - c_inv = np.linalg.inv(c_num) - s_ref = kap_val * (j_det - 1.0) * j_det * c_inv - - assert np.allclose(s_derived, s_ref, atol=0.0, rtol=1e-9) - - @pytest.mark.unit - def test_isochoric_invariant_stress_matches_reference(self): - """Verifies: The barred (isochoric) invariant binding carries the J^{-2/3} - volumetric coupling correctly (exponents -1/3 / -2/3). - AC: AC-2 (isochoric exponent guard).""" - # Psi = c (Ibar1 - 3), Ibar1 = I1 I3^{-1/3}. - # S = 2 c dIbar1/dC = 2 c I3^{-1/3}(I - (1/3) I1 C^{-1}) (hand-derived). - src = r""" - % declare metric gDD --dim 3 - % declare EDD --dim 3 - % declare \mu --const - \Psi = \mu (\mathrm{Ibar1} - 3) - """ - model = derive_from_energy(src) - mu = next(s for s in model.pk2.free_symbols if s.name == "mu") - strain = model.strain_symbols - - mu_val = 8.0e4 - rng = np.random.default_rng(424242) - for _ in range(25): - a = rng.standard_normal((3, 3)) * 0.1 - e = 0.5 * (a + a.T) - subs: dict = {mu: mu_val} - for i in range(3): - for j in range(3): - subs[strain[i][j]] = float(e[i, j]) - - s_derived = np.array( - [[float(model.pk2[i, j].subs(subs)) for j in range(3)] for i in range(3)] - ) - - c_num = 2.0 * e + np.eye(3) - i1 = np.trace(c_num) - i3 = np.linalg.det(c_num) - c_inv = np.linalg.inv(c_num) - s_ref = 2.0 * mu_val * i3 ** (-1.0 / 3.0) * (np.eye(3) - (1.0 / 3.0) * i1 * c_inv) - - assert np.allclose(s_derived, s_ref, atol=0.0, rtol=1e-9) - - @pytest.mark.unit - def test_unsupported_invariant_rejects_with_phase_pointer(self): - """Verifies: Unsupported invariant forms reject at derivation time with a - plan-phase pointer. - AC: AC-3. - Passes when: EnergyDerivationError raised with message containing a phase - reference (e.g., 'Phase 5').""" - # Fiber invariant I4 — recognised but needs a fiber direction (Phase 5 / P5-1). - fiber_src = r""" - % declare metric gDD --dim 3 - % declare EDD --dim 3 - \Psi = \mathrm{I4f} - """ - with pytest.raises(EnergyDerivationError) as fiber_exc: - derive_from_energy(fiber_src) - msg = str(fiber_exc.value) - assert "I4f" in msg - assert "Phase 5" in msg or "P5-1" in msg - - # An invariant-shaped but unknown symbol is also rejected with a phase pointer - # rather than silently treated as a free parameter. - unknown_src = r""" - % declare metric gDD --dim 3 - % declare EDD --dim 3 - \Psi = \mathrm{Ibar7} - """ - with pytest.raises(EnergyDerivationError) as unknown_exc: - derive_from_energy(unknown_src) - unknown_msg = str(unknown_exc.value) - assert "Ibar7" in unknown_msg - assert "Phase 5" in unknown_msg or "P5-1" in unknown_msg - # The rejection locates the offending invariant by source line: \mathrm{Ibar7} - # sits on line 4 of unknown_src (line 1 is the leading newline). - assert "line 4" in unknown_msg, unknown_msg - - @pytest.mark.unit - def test_misspelled_invariant_does_not_silently_pass_as_parameter(self): - """Verifies: a symbol that LOOKS like an invariant but does not match the - invariant-name regex (Jdet2, Ibar1x, Jbar) is rejected, not left as a bare - free parameter that silently contributes zero stress. - AC: AC-3 (IR discipline — unrecognised constructs must raise). - Passes when: EnergyDerivationError names the offending symbol.""" - for bad in ("Jdet2", "Ibar1x", "Jbar"): - src = ( - "% declare metric gDD --dim 3\n" - "% declare EDD --dim 3\n" - "% declare \\mu --const\n" - rf"\Psi = \frac{{\mu}}{{2}} (\mathrm{{{bad}}} - 3)" - ) - with pytest.raises(EnergyDerivationError) as exc: - derive_from_energy(src) - assert bad in str(exc.value), f"{bad}: {exc.value}" - - @pytest.mark.unit - def test_undeclared_parameter_is_rejected(self): - """Verifies: an undeclared scalar (neither a --const param, a strain - component, nor a supported invariant) is rejected rather than silently - treated as a free constant. - AC: AC-3. - Passes when: EnergyDerivationError names the undeclared symbol.""" - src = ( - "% declare metric gDD --dim 3\n" - "% declare EDD --dim 3\n" - r"\Psi = \mathrm{Ibar1} \cdot \mathrm{undeclared}" - ) - with pytest.raises(EnergyDerivationError) as exc: - derive_from_energy(src) - assert "undeclared" in str(exc.value) diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P2-2.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P2-2.py deleted file mode 100644 index b2d55a0..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P2-2.py +++ /dev/null @@ -1,300 +0,0 @@ -"""Tests for Task P2-2: neo_hookean_energy.tex + differential & AD oracle. - -Covers: LaTeX-derived Neo-Hookean energy (Ψ in named invariants Ibar1, Jdet) -matches the hand-coded oracle (models/neo_hookean.py) at random deformations -(differential oracle < 1e-9); passes the spec AD-oracle (symbolic S vs -autodiff of Ψ, rel-err < 1e-10); tangent passes symmetry; unsupported -invariant forms reject with phase pointer. - -Energy authored: - Ψ = (μ/2)(Ī₁ − 3) + (κ/2)(J − 1)² - -where Ī₁ = I₁ · I₃^{−1/3} and J = √(det C), matching models/neo_hookean.py -exactly (classical compressible NH, isochoric-volumetric split). -""" - -from __future__ import annotations - -import pathlib - -import numpy as np -import pytest -import sympy as sp - -from mechdsl.symbolic.energy import EnergyDerivationError, derive_from_energy -from mechdsl.symbolic.models.neo_hookean import ( - NeoHookeanMaterial, - material_tangent_4th, - pk2_stress, -) - -# --------------------------------------------------------------------------- -# Fixtures and shared setup -# --------------------------------------------------------------------------- - -_EXAMPLES_DIR = pathlib.Path(__file__).parents[5] / "dev" / "examples" -_NH_TEX = _EXAMPLES_DIR / "neo_hookean_energy.tex" - -# Material parameters shared across all tests (must be identical on both sides -# of every comparison so the differential oracle is fair). -_MU = 80.0 -_KAPPA = 160.0 -_N_SAMPLES = 25 -_SEED = 20260604 - - -def _green_lagrange_from_F(F: np.ndarray) -> np.ndarray: - """E = ½(FᵀF − I).""" - return 0.5 * (F.T @ F - np.eye(3)) - - -def _random_F_list(n: int, seed: int) -> list[np.ndarray]: - """Generate *n* random F matrices with positive Jacobian (near-identity).""" - rng = np.random.default_rng(seed) - Fs: list[np.ndarray] = [] - while len(Fs) < n: - A = rng.standard_normal((3, 3)) * 0.1 - F = np.eye(3) + A - if np.linalg.det(F) > 0.1: # keep well-conditioned deformations - Fs.append(F) - return Fs - - -@pytest.fixture(scope="module") -def nh_model(): - """Parsed + derived Neo-Hookean EnergyModel (module-scoped for speed).""" - latex = _NH_TEX.read_text() - return derive_from_energy(latex) - - -@pytest.fixture(scope="module") -def param_subs(nh_model): - """Numeric substitution map: sanitised/raw symbols → μ, κ values.""" - subs: dict = {} - for sym in nh_model.pk2.free_symbols: - if sym.name == "mu": - subs[sym] = _MU - elif sym.name == "kappa": - subs[sym] = _KAPPA - assert len(subs) == 2, ( - f"expected exactly 'mu' and 'kappa' in pk2 free symbols, " - f"got {[s.name for s in nh_model.pk2.free_symbols]}" - ) - return subs - - -@pytest.fixture(scope="module") -def F_list(): - """Shared list of random F matrices for reproducibility.""" - return _random_F_list(_N_SAMPLES, _SEED) - - -# --------------------------------------------------------------------------- -# Helper: evaluate symbolic S at a concrete E -# --------------------------------------------------------------------------- - - -def _eval_pk2(model, param_subs, E: np.ndarray) -> np.ndarray: - """Evaluate the symbolic pk2 at strain E with numeric params.""" - strain = model.strain_symbols - subs = dict(param_subs) - for i in range(3): - for j in range(3): - subs[strain[i][j]] = float(E[i, j]) - return np.array([[float(model.pk2[i, j].subs(subs)) for j in range(3)] for i in range(3)]) - - -def _eval_tangent(model, param_subs, E: np.ndarray) -> np.ndarray: - """Evaluate the symbolic tangent at strain E with numeric params.""" - strain = model.strain_symbols - subs = dict(param_subs) - for i in range(3): - for j in range(3): - subs[strain[i][j]] = float(E[i, j]) - C_der = np.empty((3, 3, 3, 3)) - for i in range(3): - for j in range(3): - for k in range(3): - for el in range(3): - C_der[i, j, k, el] = float(model.tangent[i, j, k, el].subs(subs)) - return C_der - - -# --------------------------------------------------------------------------- -# Test class -# --------------------------------------------------------------------------- - - -class TestTaskP2_2: - """Tests for Task P2-2: neo_hookean_energy.tex + differential & AD oracle. - AC covered: 1, 2, 3, 4.""" - - # ------------------------------------------------------------------ - # AC-1: Derived S matches oracle at N random deformation gradients - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_derived_stress_matches_oracle_at_random_deformations( - self, nh_model, param_subs, F_list - ) -> None: - """LaTeX-derived S matches models/neo_hookean.py at 25 random F. - - AC: Derived S and C match neo_hookean.py < 1e-9 at random strains. - Passes when: rel_err < 1e-9 on N random deformation gradients. - """ - mat = NeoHookeanMaterial(mu=_MU, kappa=_KAPPA) - - for F in F_list: - E = _green_lagrange_from_F(F) - S_derived = _eval_pk2(nh_model, param_subs, E) - S_oracle = pk2_stress(mat, E) - - norm_S = np.linalg.norm(S_oracle) - if norm_S > 1e-12: - rel_err = np.linalg.norm(S_derived - S_oracle) / norm_S - else: - rel_err = np.linalg.norm(S_derived - S_oracle) - - assert rel_err < 1e-9, ( - f"Stress mismatch: rel_err={rel_err:.3e} (threshold 1e-9)\n" - f" S_derived={S_derived}\n S_oracle={S_oracle}" - ) - - # ------------------------------------------------------------------ - # AC-2: Derived C matches oracle at N random deformation gradients - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_derived_tangent_matches_oracle_at_random_deformations( - self, nh_model, param_subs, F_list - ) -> None: - """LaTeX-derived C_IJKL matches oracle at 25 random F. - - AC: Derived S and C match neo_hookean.py < 1e-9 at random strains. - Passes when: rel_err < 1e-9 on material tangent. - """ - mat = NeoHookeanMaterial(mu=_MU, kappa=_KAPPA) - - for F in F_list: - E = _green_lagrange_from_F(F) - C_derived = _eval_tangent(nh_model, param_subs, E) - C_oracle = material_tangent_4th(mat, E) - - norm_C = np.linalg.norm(C_oracle) - rel_err = np.linalg.norm(C_derived - C_oracle) / (norm_C if norm_C > 1e-12 else 1.0) - - assert rel_err < 1e-9, f"Tangent mismatch: rel_err={rel_err:.3e} (threshold 1e-9)" - - # ------------------------------------------------------------------ - # AC-3: Spec AD-oracle — symbolic S vs autodiff of Ψ - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_spec_ad_oracle_symbolic_vs_autodiff(self, nh_model, param_subs, F_list) -> None: - """Symbolic S agrees with an INDEPENDENT numerical derivative of Ψ. - - AC: Spec AD-oracle passes (symbolic S vs autodiff of Ψ). - - The symbolic ``pk2`` is the symmetrised ∂Ψ/∂E produced by - ``derive_from_energy``. This oracle differentiates Ψ a *different* way — - a central finite difference of the lambdified energy w.r.t. each strain - component — so it actually exercises the invariant binding and the - C = 2E + I chain rule (a re-run of ``sympy.diff`` would be tautological, - always zero error, and could not catch a binding mistake). Central FD - is accurate to O(h²); the tight 1e-9 check lives in the differential - oracle vs ``neo_hookean.py``, so 1e-6 is the right bar here. - """ - strain = nh_model.strain_symbols - flat_syms = [strain[i][j] for i in range(3) for j in range(3)] - # Numeric energy as a function of the nine E components (params fixed). - psi_num = sp.lambdify(flat_syms, nh_model.psi.subs(param_subs), modules="numpy") - index_pairs = [(i, j) for i in range(3) for j in range(3)] - h = 1e-6 - - for F in F_list[:10]: - E = _green_lagrange_from_F(F) - e = [float(E[i, j]) for i in range(3) for j in range(3)] - - # Central FD of Ψ w.r.t. each independent E component -> raw ∂Ψ/∂E. - dpsi = np.zeros((3, 3)) - for idx, (i, j) in enumerate(index_pairs): - ep, em = list(e), list(e) - ep[idx] += h - em[idx] -= h - dpsi[i, j] = (psi_num(*ep) - psi_num(*em)) / (2.0 * h) - # Symmetrise to match pk2 = ½(∂Ψ/∂E_ij + ∂Ψ/∂E_ji). - S_fd = 0.5 * (dpsi + dpsi.T) - - S_symbolic = _eval_pk2(nh_model, param_subs, E) - norm_S = np.linalg.norm(S_symbolic) - rel_err = np.linalg.norm(S_symbolic - S_fd) / (norm_S if norm_S > 1e-12 else 1.0) - - assert rel_err < 1e-6, ( - f"AD oracle (numeric FD of Ψ) mismatch: rel_err={rel_err:.3e} " - f"(threshold 1e-6)\n S_symbolic={S_symbolic}\n S_fd={S_fd}" - ) - - # ------------------------------------------------------------------ - # AC-4a: Tangent minor symmetry C_IJKL = C_IJLK - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_tangent_minor_symmetry(self, nh_model, param_subs, F_list) -> None: - """Derived tangent satisfies C_IJKL = C_IJLK (minor symmetry). - - AC: Tangent passes minor/major symmetry checks. - Passes when: C[i,j,k,l] == C[i,j,l,k] within 1e-10. - """ - for F in F_list[:3]: - E = _green_lagrange_from_F(F) - C_derived = _eval_tangent(nh_model, param_subs, E) - # Minor symmetry: C_IJKL = C_IJLK (swap last two indices) - np.testing.assert_allclose( - C_derived, - C_derived.transpose(0, 1, 3, 2), - atol=1e-10, - err_msg="Minor symmetry C_IJKL != C_IJLK violated", - ) - - # ------------------------------------------------------------------ - # AC-4b: Tangent major symmetry C_IJKL = C_KLIJ - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_tangent_major_symmetry(self, nh_model, param_subs, F_list) -> None: - """Derived tangent satisfies C_IJKL = C_KLIJ (major symmetry). - - AC: Tangent passes minor/major symmetry checks. - Passes when: C[i,j,k,l] == C[k,l,i,j] within 1e-10. - """ - for F in F_list[:3]: - E = _green_lagrange_from_F(F) - C_derived = _eval_tangent(nh_model, param_subs, E) - # Major symmetry: C_IJKL = C_KLIJ (swap first and last pairs) - np.testing.assert_allclose( - C_derived, - C_derived.transpose(2, 3, 0, 1), - atol=1e-10, - err_msg="Major symmetry C_IJKL != C_KLIJ violated", - ) - - # ------------------------------------------------------------------ - # AC-5: Unsupported invariant form rejects with phase pointer - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_unsupported_invariant_form_rejects_with_phase_pointer(self) -> None: - """Unsupported invariant (e.g. \\mathrm{Ibar7}) rejects with plan-phase message. - - AC: Unsupported invariant form rejects with phase-pointed message. - Passes when: EnergyDerivationError is raised with a phase reference. - """ - # Author an energy with a fictitious invariant Ibar7 (not in registry) - bad_latex = r""" -% declare metric gDD --dim 3 -% declare EDD --dim 3 -% declare \mu --const -\Psi = \frac{\mu}{2} \left( \mathrm{Ibar7} - 3 \right) -""" - with pytest.raises(EnergyDerivationError, match=r"[Pp]hase"): - derive_from_energy(bad_latex) diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P3-1.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P3-1.py deleted file mode 100644 index 2338f2e..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P3-1.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Tests for Task P3-1: Replace taichi_printer string-dispatch + flow energy through IR. - -Covers: Derived-energy carrier round-trips through ProblemIR/ArtifactBundle, -taichi_printer emits derived branch when energy present (not just advisory -LatexSemantics), named-model fallback (svk/j2_power_law/lemaitre) still works. - -Task P3-1 structural rewiring on the happy path (Neo-Hookean) to de-risk -the production path for all later models. Focus: IR discipline (no layer bypass), -immutability of ProblemIR/ArtifactBundle, construction-time validation. - -Acceptance criteria: - 1. Derived energy flows frontend -> ProblemIR -> codegen (no advisory-only path) - 2. Named-model fallback (svk/j2/lemaitre) unchanged - 3. Existing codegen/golden tests unaffected (P3-3 gate) - 4. IR immutability + construction-time validation preserved -""" - -from __future__ import annotations - -import dataclasses -from pathlib import Path - -import pytest - -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.codegen.taichi_printer import ( - EmissionContext, - emit, - emit_constitutive_update, -) -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import localise_and_optimize -from mechdsl.symbolic.energy import EnergyModel, derive_from_energy - -pytestmark = pytest.mark.integration - -# Neo-Hookean strain-energy authored in named invariants (P2-2). The derived -# EnergyModel is the input P3-1 wires through ProblemIR -> ArtifactBundle -> -# codegen. Derivation runs nrpylatex + sympy, so it is module-scoped. -_NEO_HOOKEAN_TEX = ( - Path(__file__).resolve().parents[5] / "dev" / "examples" / "neo_hookean_energy.tex" -) - - -@pytest.fixture(scope="module") -def neo_hookean_energy() -> EnergyModel: - """Derive the Neo-Hookean EnergyModel once for the module.""" - return derive_from_energy(_NEO_HOOKEAN_TEX.read_text()) - - -def _boundaries() -> tuple[BoundaryCondition, ...]: - return ( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ) - - -def _make_ir(*, material: MaterialSpec, derived_energy: EnergyModel | None = None) -> ProblemIR: - return ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=material, - boundaries=_boundaries(), - derived_energy=derived_energy, - ) - - -def _emit(problem_ir: ProblemIR) -> str: - loc_result, plans = localise_and_optimize(problem_ir) - bundle = ArtifactBundle.from_pipeline(problem_ir, loc_result, plans) - return emit(bundle) - - -def _emit_constitutive(problem_ir: ProblemIR) -> str: - """Emit only the constitutive ``@ti.func`` block — the surface P3-1 rewires. - - The full force / tangent kernels are not part of the P3-1 dispatch rewire - (derived-energy force/tangent emission is later wiring), so the focused - assertions target the constitutive block alone. - """ - loc_result, plans = localise_and_optimize(problem_ir) - bundle = ArtifactBundle.from_pipeline(problem_ir, loc_result, plans) - ctx = EmissionContext() - emit_constitutive_update(ctx, bundle) - return ctx.get_source() - - -class TestTaskP3_1: - """Tests for Task P3-1: taichi_printer dispatch + flow energy through IR. - - AC covered: 1 (derived energy flow), 2 (fallback unchanged), 3, 4. - """ - - # ------------------------------------------------------------------ - # AC 1 / AC 4: derived-energy carrier on ProblemIR - # ------------------------------------------------------------------ - - def test_derived_energy_carrier_in_problem_ir(self, neo_hookean_energy: EnergyModel): - """The derived EnergyModel is carried as a real ProblemIR field. - - AC 1: derived energy flows frontend -> ProblemIR (not advisory only). - """ - ir = _make_ir( - material=MaterialSpec(model="neo_hookean", params={"mu": 1.0, "kappa": 1.0}), - derived_energy=neo_hookean_energy, - ) - assert ir.derived_energy is neo_hookean_energy - assert ir.derived_energy.pk2.shape == (3, 3) - - def test_problem_ir_derived_energy_defaults_to_none(self): - """ProblemIR without the new arg works and defaults to None (back-compat). - - AC 4: the ~61 existing constructors pass no new argument -> unaffected. - """ - ir = _make_ir(material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3})) - assert ir.derived_energy is None - - def test_problem_ir_immutability_preserved_with_derived_energy( - self, neo_hookean_energy: EnergyModel - ): - """ProblemIR stays frozen with the new field set. - - AC 4: IR immutability preserved. - """ - ir = _make_ir( - material=MaterialSpec(model="neo_hookean", params={"mu": 1.0, "kappa": 1.0}), - derived_energy=neo_hookean_energy, - ) - with pytest.raises(dataclasses.FrozenInstanceError): - ir.derived_energy = None # type: ignore[misc] - - def test_problem_ir_validates_derived_energy_at_construction(self): - """A malformed carrier is rejected at construction, not at emission. - - AC 4: construction-time validation preserved (IR discipline). - """ - - class _Bogus: - """Matches none of the recognised model shapes (invariant / spectral / fiber).""" - - with pytest.raises(ValueError, match="must be a recognised constitutive model"): - _make_ir( - material=MaterialSpec(model="neo_hookean", params={"mu": 1.0, "kappa": 1.0}), - derived_energy=_Bogus(), # type: ignore[arg-type] - ) - - def test_derived_energy_excluded_from_problem_ir_to_dict(self, neo_hookean_energy: EnergyModel): - """to_dict stays JSON-able: the SymPy-bearing carrier is NOT serialised. - - AC 1: the carrier is the Python-object channel, not the JSON surface. - """ - import json - - ir = _make_ir( - material=MaterialSpec(model="neo_hookean", params={"mu": 1.0, "kappa": 1.0}), - derived_energy=neo_hookean_energy, - ) - d = ir.to_dict() - assert "derived_energy" not in d - json.dumps(d) # must not raise (no SymPy in the dict) - - # ------------------------------------------------------------------ - # AC 1: derived-energy channel through ArtifactBundle - # ------------------------------------------------------------------ - - def test_artifact_bundle_carries_derived_energy(self, neo_hookean_energy: EnergyModel): - """ArtifactBundle.from_pipeline pulls the carrier off the ProblemIR. - - AC 1: derived energy reaches codegen through the real bundle channel. - """ - ir = _make_ir( - material=MaterialSpec(model="neo_hookean", params={"mu": 1.0, "kappa": 1.0}), - derived_energy=neo_hookean_energy, - ) - loc_result, plans = localise_and_optimize(ir) - bundle = ArtifactBundle.from_pipeline(ir, loc_result, plans) - assert bundle.derived_energy is neo_hookean_energy - - def test_bundle_json_path_unaffected_when_derived_energy_present( - self, neo_hookean_energy: EnergyModel - ): - """The JSON path (to_dict / content_hash) ignores the carrier. - - AC 1/3: golden JSON surface and content hash are unchanged whether or - not a derived energy rides on the bundle. - """ - ir = _make_ir( - material=MaterialSpec(model="neo_hookean", params={"mu": 1.0, "kappa": 1.0}), - derived_energy=neo_hookean_energy, - ) - loc_result, plans = localise_and_optimize(ir) - with_energy = ArtifactBundle.from_pipeline(ir, loc_result, plans) - without_energy = dataclasses.replace(with_energy, derived_energy=None) - - assert "derived_energy" not in with_energy.to_dict() - assert with_energy.to_dict() == without_energy.to_dict() - assert with_energy.content_hash() == without_energy.content_hash() - - # ------------------------------------------------------------------ - # AC 1: taichi_printer emits the derived branch - # ------------------------------------------------------------------ - - def test_taichi_printer_emits_derived_branch_when_energy_present( - self, neo_hookean_energy: EnergyModel - ): - """emit() routes through energy_emitter when a derived energy is present. - - AC 1: derived energy through codegen (no name-string switch, no - advisory-only path). The emitted constitutive func is derived, not the - hand-coded SVK/J2 branch. - """ - ir = _make_ir( - material=MaterialSpec(model="neo_hookean", params={"mu": 1.0, "kappa": 1.0}), - derived_energy=neo_hookean_energy, - ) - block = _emit_constitutive(ir) - assert "derived from LaTeX energy" in block - assert "def constitutive_update(" in block - assert "PK2 stress derived from a LaTeX strain-energy density" in block - # The hand-coded SVK closed-form must NOT be the constitutive func body: - # this is the derived path, not the name-string switch. - assert "S = lam * tr_E * I3 + 2.0 * mu * E" not in block - # And the full emit() still routes through the derived branch. - assert "derived from LaTeX energy" in _emit(ir) - - def test_derived_branch_admits_model_outside_named_emit_allowlist( - self, neo_hookean_energy: EnergyModel - ): - """A derived energy lets emit() accept a model name the named-emit - allow-list (svk/j2/lemaitre) would otherwise reject. - - AC 1: the derived branch is the real production path, not gated by the - legacy name allow-list. - """ - ir = _make_ir( - material=MaterialSpec(model="neo_hookean", params={"mu": 1.0, "kappa": 1.0}), - derived_energy=neo_hookean_energy, - ) - # neo_hookean is NOT in the emit() named allow-list; the derived energy - # must carry it through. - source = _emit(ir) - assert "def constitutive_update(" in source - - # ------------------------------------------------------------------ - # AC 2: named-model fallback unchanged - # ------------------------------------------------------------------ - - def test_named_model_fallback_svk_unchanged(self): - """SVK dispatch (no derived energy) still emits the hand-coded closed form. - - AC 2: fallback unchanged. - """ - ir = _make_ir(material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3})) - block = _emit_constitutive(ir) - assert "def constitutive_update(" in block - assert "S = lam * tr_E * I3 + 2.0 * mu * E" in block - assert "derived from LaTeX energy" not in block - - def test_named_model_fallback_j2_power_law_unchanged(self): - """J2 power-law dispatch (no derived energy) still emits the radial return. - - AC 2: fallback unchanged. - """ - ir = _make_ir( - material=MaterialSpec( - model="j2_power_law", - params={"E": 200e3, "nu": 0.3, "sigma_y0": 250.0, "K": 100.0, "n": 0.2}, - ) - ) - source = _emit(ir) - assert "def constitutive_update_plastic(" in source - assert "derived from LaTeX energy" not in source - - def test_named_model_fallback_lemaitre_unchanged(self): - """Lemaitre dispatch (no derived energy) still emits J2 + damage wrapper. - - AC 2: fallback unchanged. - """ - ir = _make_ir( - material=MaterialSpec( - model="lemaitre", - params={ - "E": 200e3, - "nu": 0.3, - "sigma_y0": 250.0, - "K": 100.0, - "n": 0.2, - "S": 1.0, - "s": 1.0, - "D_crit": 0.5, - }, - ) - ) - source = _emit(ir) - assert "def constitutive_update_plastic(" in source - assert "derived from LaTeX energy" not in source - - def test_emit_still_rejects_unsupported_model_without_derived_energy(self): - """The emit() name allow-list still rejects unknown models when no - derived energy is present. - - AC 2: the fallback guard is intact for the named-model path. - """ - # `perzyna` is accepted by ProblemIR construction but is outside the - # Taichi emit allow-list and has no derived energy -> must reject. - ir = _make_ir( - material=MaterialSpec( - model="perzyna", - params={"E": 200e3, "nu": 0.3, "sigma_y0": 250.0, "eta": 1.0, "m": 1.0}, - ) - ) - loc_result, plans = localise_and_optimize(ir) - bundle = ArtifactBundle.from_pipeline(ir, loc_result, plans) - with pytest.raises(ValueError, match="Unsupported material model"): - emit(bundle) diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P3-2.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P3-2.py deleted file mode 100644 index 59f6ab2..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P3-2.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Tests for Task P3-2: clear rank-4 rejection in bridge.py for tangent emission. - -Covers: -1. Rank-4 tangent moduli C_IJKL pass bridge.py without rejection (allows routing to tangent-emission path). -2. Over-budget tangent unroll (> 512 lines/@ti.func) is rejected with documented JIT-budget message. -3. Unsupported higher ranks (rank > 4) still reject with phase-pointer message. - Also covers rank-1 and rank-3 (genuinely unsupported odd/low ranks). - -The task clears the rank-4 tensor rejection in symbolic/bridge.py (~line 154) -for tangent-moduli emission, gated by the JIT budget counter -(codegen/einsum_optimizer.py). Before P3-2, rank-4 was uniformly rejected. -After P3-2, rank-4 tangent is accepted and routed to tangent emission, -but JIT budget enforcement prevents unbounded unrolls. -""" - -from __future__ import annotations - -import nrpylatex -import pytest -from sympy import Function, Symbol - -from mechdsl.codegen.einsum_optimizer import ( - MAX_LINES_TI_FUNC, - BudgetExceededError, - optimize_all, - optimize_contraction, -) -from mechdsl.symbolic.bridge import BridgeError, SymbolicNode, convert - - -def _make_indexed_symbol(name: str, rank: int, dimension: int = 3) -> nrpylatex.IndexedSymbol: - """Construct a synthetic nrpylatex.IndexedSymbol of the given rank. - - nrpylatex infers rank from the number of index-suffix characters in the - Symbol name (UU = rank-2, UUUU = rank-4, etc.). We mirror the pattern - used in test_p4_2.py. - """ - suffix = "U" * rank - sym_name = name + suffix - func = Function("Tensor")(Symbol(sym_name, real=True)) - return nrpylatex.IndexedSymbol(func, dimension=dimension) - - -class TestTaskP3_2: - """Tests for Task P3-2: clear rank-4 rejection in bridge.py for tangent emission. - AC covered: 1, 2, 3.""" - - @pytest.mark.unit - def test_rank_4_tangent_accepted_by_bridge(self) -> None: - """Rank-4 tangent moduli C_IJKL pass bridge.py without the prior rejection. - - AC: Rank-4 tangent passes bridge.py without prior rejection. - Passes when: convert() accepts rank-4 indexed symbol and returns a - SymbolicNode with kind='tensor4' and rank=4. - """ - sym4 = _make_indexed_symbol("C", rank=4, dimension=3) - node = convert("CUUUU", sym4, classification=None) - - assert isinstance(node, SymbolicNode) - assert node.kind == "tensor4" - assert node.rank == 4 - assert node.name == "CUUUU" - assert node.raw is sym4 - - @pytest.mark.unit - def test_over_budget_tangent_unroll_rejected_with_budget_message(self) -> None: - """Over-budget (>512 lines) tangent unroll rejected with documented JIT-budget message. - - AC: Over-budget tangent unroll rejects with documented JIT-budget message. - - Strategy: drive optimize_all() with a rank-4 contraction that the - optimizer estimates as requiring more than MAX_LINES_ABSOLUTE lines. - The 3x3x3x3 C_IJKL S_KL contraction is comfortably within budget on - its own; we replicate it enough times to breach the absolute ceiling - and confirm BudgetExceededError is raised. The error message must - contain the budget fraction and the "OVER BUDGET" / ceiling marker. - - For the per-function (Tier 3) path we also confirm that a single - contraction that yields > MAX_LINES_TI_FUNC lines sets - within_budget=False and "OVER BUDGET" in budget_detail. - """ - # --- per-function budget: a large physics contraction --- - # 6x6x6x6 rank-4 contraction: estimated lines should be large enough - # to trigger Tier 3 (> 512) given the heuristic in estimate_unrolled_lines. - result = optimize_contraction("ijkl,kl->ij", [(6, 6, 6, 6), (6, 6)]) - # A 6x6x6x6 contraction is deterministically over the per-@ti.func limit, - # so this is an unconditional guarantee — assert it directly (not behind - # an `if`) so the AC stays locked even if the heuristic shifts. - assert result.estimated_lines > MAX_LINES_TI_FUNC, result.estimated_lines - assert result.within_budget is False - assert "OVER BUDGET" in result.budget_detail - - # --- absolute ceiling: replicate a contraction until ceiling is breached --- - # Use a small rank-4 contraction and repeat it until cumulative lines - # exceed MAX_LINES_ABSOLUTE (5000). - small = optimize_contraction("ijkl,kl->ij", [(3, 3, 3, 3), (3, 3)]) - # Build a list big enough to exceed 5000 lines total - from mechdsl.codegen.einsum_optimizer import MAX_LINES_ABSOLUTE - - copies_for_absolute = (MAX_LINES_ABSOLUTE // max(small.estimated_lines, 1)) + 2 - - specs = [("ijkl,kl->ij", [(3, 3, 3, 3), (3, 3)])] * copies_for_absolute - with pytest.raises(BudgetExceededError) as excinfo: - optimize_all(specs) - - err_msg = str(excinfo.value) - # Must contain the ceiling value and "exceeded" language - assert ( - str(MAX_LINES_ABSOLUTE) in err_msg - or "ceiling" in err_msg.lower() - or "exceeded" in err_msg.lower() - ) - - @pytest.mark.unit - def test_unsupported_higher_rank_still_rejected_with_phase_pointer(self) -> None: - """Unsupported higher-rank constructs (rank 1, 3, 5) still reject with phase pointer. - - AC: Genuinely-unsupported higher-rank constructs still reject with a - phase pointer (post_recovery_plan Phase 4). - """ - for bad_rank in (1, 3, 5): - sym = _make_indexed_symbol("T", rank=bad_rank, dimension=3) - with pytest.raises(BridgeError) as excinfo: - convert(f"T{'U' * bad_rank}", sym, classification=None) - msg = str(excinfo.value) - assert "post_recovery_plan Phase 4" in msg, ( - f"rank-{bad_rank} error missing phase pointer: {msg!r}" - ) - assert ( - f"rank-{bad_rank}" in msg.lower() - or f"rank {bad_rank}" in msg.lower() - or str(bad_rank) in msg - ), f"rank-{bad_rank} error missing rank number: {msg!r}" diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P3-3.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P3-3.py deleted file mode 100644 index 98fd3cf..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P3-3.py +++ /dev/null @@ -1,569 +0,0 @@ -"""Tests for Task P3-3: E2E Neo-Hookean through the production pipeline + JIT verify. - -This is the GATE that unblocks Phases 4 & 5. It proves a LaTeX-authored -Neo-Hookean strain energy flows through the *real* production codegen path - - ProblemIR(derived_energy=...) - -> localise_and_optimize - -> ArtifactBundle.from_pipeline - -> taichi_printer.emit_constitutive_update - -and that the JIT-compiled ``constitutive_update`` @ti.func reproduces the -hand-coded oracle ``models/neo_hookean.py`` (``pk2_stress``) to < 1e-8 at N -random well-conditioned deformation gradients. - -Acceptance criteria: -- AC-1: LaTeX NH compiles through the real pipeline to Taichi matching - neo_hookean.py < 1e-8 (stress). -- AC-2: Existing SVK/J2/Lemaitre emission and golden tests still pass. -- AC-3: Generated constitutive @ti.func ≤ 512 lines (JIT budget). -- AC-4: JIT-run (@pytest.mark.slow) passes with the generated kernel. - -Scope reality (layer A vs layer B). The production emission path -(``ProblemIR.derived_energy`` carrier -> bundle channel -> emitter) was wired -by P3-1; this task proves it end-to-end with a JIT-run match. The -``compile_latex`` *façade* producer (auto-deriving ``derived_energy`` from an -energy block authored inside a ``% mechanics`` problem) is a separate frontend -feature (energy-block capture in ``parse_compile_context``) and is documented -as remaining wiring — see the module docstring of this file and the task report. - -Implementation pattern: mirrors test_energy_codegen_svk.py -- The constitutive @ti.func is taken from the PRODUCTION emission - (``emit_constitutive_update``), not the isolated ``energy_emitter``. -- Material parameters are passed via ``ti.types.argpack`` (cached across calls, - per the established slice convention — NOT 0-d fields). -- The generated module is written to a real file so Taichi's source - introspection can find the kernel, then imported with importlib. -""" - -from __future__ import annotations - -import importlib.util -from pathlib import Path - -import numpy as np -import pytest - -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.codegen.einsum_optimizer import MAX_LINES_TI_FUNC -from mechdsl.codegen.energy_emitter import emit_tangent_func -from mechdsl.codegen.taichi_printer import ( - EmissionContext, - emit, - emit_constitutive_update, -) -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import localise_and_optimize -from mechdsl.symbolic.energy import EnergyModel, derive_from_energy -from mechdsl.symbolic.models.neo_hookean import ( - NeoHookeanMaterial, - material_tangent_voigt, - pk2_stress, -) - -_EXAMPLES_DIR = Path(__file__).resolve().parents[5] / "dev" / "examples" -_NH_TEX = _EXAMPLES_DIR / "neo_hookean_energy.tex" - -# Material parameters (must match across all comparisons). mu = shear modulus, -# kappa = bulk modulus — both well inside the > 0 validity domain. -_MU = 80.0 -_KAPPA = 160.0 - -# Number of random deformation gradients sampled in the numeric comparisons. -_N_SAMPLES = 15 - - -@pytest.fixture(scope="module") -def neo_hookean_energy() -> EnergyModel: - """Derive the Neo-Hookean EnergyModel once for the module (nrpylatex+sympy).""" - return derive_from_energy(_NH_TEX.read_text()) - - -def _param_names(model: EnergyModel) -> list[str]: - """Sorted derived parameter names (everything in the PK2 stress that is not - a strain component). For Neo-Hookean this is ``['kappa', 'mu']`` — note this - differs from SVK's ``(F, aleph, mu)``.""" - return sorted(s.name for s in model.pk2.free_symbols if not s.name.startswith("EDD")) - - -def _make_nh_ir(derived_energy: EnergyModel) -> ProblemIR: - """Build the production ProblemIR carrying the derived Neo-Hookean energy.""" - return ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="neo_hookean", params={"mu": _MU, "kappa": _KAPPA}), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - derived_energy=derived_energy, - ) - - -def _emit_constitutive_block(model: EnergyModel) -> str: - """Emit the constitutive ``@ti.func`` block through the REAL pipeline: - ProblemIR.derived_energy -> localise_and_optimize -> ArtifactBundle -> - emit_constitutive_update. This is the production surface P3-3 gates, NOT a - direct call into the isolated energy_emitter.""" - ir = _make_nh_ir(model) - loc_result, plans = localise_and_optimize(ir) - bundle = ArtifactBundle.from_pipeline(ir, loc_result, plans) - ctx = EmissionContext() - emit_constitutive_update(ctx, bundle) - return ctx.get_source() - - -def _extract_ti_func(block: str) -> list[str]: - """Return the lines of the emitted ``constitutive_update`` @ti.func, from the - ``@ti.func`` decorator through the closing ``return S`` — the unrolled body - that the Taichi JIT budget counts.""" - lines = block.splitlines() - start = next(i for i, ln in enumerate(lines) if ln.strip() == "@ti.func") - end = next(i for i, ln in enumerate(lines) if ln.strip() == "return S") - return lines[start : end + 1] - - -def _build_runner_module(model: EnergyModel, block: str, path: Path) -> str: - """Compose a self-contained Taichi module around the PRODUCTION-emitted - constitutive @ti.func: an argpack of the material parameters (cached across - calls per the Taichi argpack contract) and a kernel that forwards them. - - The @ti.func is lifted verbatim from the production ``emit_constitutive_update`` - output, so this exercises the real emitted source — not a re-emission. Written - to a real file so Taichi's source introspection can find the kernel. - """ - func_lines = _extract_ti_func(block) - func_src = "\n".join(func_lines) - names = _param_names(model) - pack_fields = ", ".join(f"{n}=ti.f64" for n in names) - forward = ", ".join(f"params.{n}" for n in names) - src = ( - "import math\n" - "import taichi as ti\n\n" - f"ParamPack = ti.types.argpack({pack_fields})\n" - "F_in = ti.Matrix.field(3, 3, ti.f64, shape=())\n" - "S_out = ti.Matrix.field(3, 3, ti.f64, shape=())\n\n" - f"{func_src}\n\n" - "@ti.kernel\n" - "def run(params: ParamPack):\n" - f" S_out[None] = constitutive_update(F_in[None], {forward})\n" - ) - path.write_text(src) - return src - - -def _extract_tangent_ti_func(block: str) -> list[str]: - """Return the lines of the emitted ``tangent_update`` @ti.func, from the - ``@ti.func`` decorator through the closing ``return D`` — the unrolled body - the Taichi JIT budget counts for the 6x6 Voigt tangent.""" - lines = block.splitlines() - start = next(i for i, ln in enumerate(lines) if ln.strip() == "@ti.func") - end = next(i for i, ln in enumerate(lines) if ln.strip() == "return D") - return lines[start : end + 1] - - -def _build_tangent_runner_module(model: EnergyModel, block: str, path: Path) -> str: - """Compose a self-contained Taichi module around the emitted tangent - @ti.func: an argpack of the material parameters and a kernel that forwards - them, returning the 6x6 Voigt tangent. Mirrors :func:`_build_runner_module`. - - The tangent @ti.func is lifted verbatim from ``emit_tangent_func``, so this - exercises the real emitted source. Written to a real file so Taichi's source - introspection can find the kernel. - """ - func_lines = _extract_tangent_ti_func(block) - func_src = "\n".join(func_lines) - names = _param_names(model) - pack_fields = ", ".join(f"{n}=ti.f64" for n in names) - forward = ", ".join(f"params.{n}" for n in names) - src = ( - "import math\n" - "import taichi as ti\n\n" - f"ParamPack = ti.types.argpack({pack_fields})\n" - "F_in = ti.Matrix.field(3, 3, ti.f64, shape=())\n" - "D_out = ti.Matrix.field(6, 6, ti.f64, shape=())\n\n" - f"{func_src}\n\n" - "@ti.kernel\n" - "def run_tangent(params: ParamPack):\n" - f" D_out[None] = tangent_update(F_in[None], {forward})\n" - ) - path.write_text(src) - return src - - -class TestTaskP3_3: - """Tests for Task P3-3: E2E Neo-Hookean through the real pipeline + JIT verify. - AC covered: 1, 2, 3, 4.""" - - # ------------------------------------------------------------------ - # math.* -> ti.* rewrite: known calls/constants translate, unknown fails loud - # ------------------------------------------------------------------ - - @pytest.mark.unit - def test_to_taichi_math_translates_calls_and_constants(self): - """pycode math.* tokens rewrite to Taichi; unknown math.* fails loudly. - - Guards the single translation point Phase 4/5 energies rely on: known - functions (sqrt) and constants (pi, e) map to their Taichi forms - (ti.pi does NOT exist — the constants live under ti.math), and an - unregistered math.* raises rather than emitting non-compiling source. - """ - from mechdsl.codegen.energy_emitter import _to_taichi_math - - assert _to_taichi_math("math.sqrt(math.pi * x)") == "ti.sqrt(ti.math.pi * x)" - assert _to_taichi_math("math.e") == "ti.math.e" - # Overlapping names must not collide (math.tan vs math.tanh). - assert _to_taichi_math("math.tanh(math.tan(x))") == "ti.tanh(ti.tan(x))" - with pytest.raises(NotImplementedError, match=r"math\.gamma"): - _to_taichi_math("math.gamma(x)") - - # ------------------------------------------------------------------ - # AC-1a: Emitted source is structurally sound (fast check, no JIT) - # ------------------------------------------------------------------ - - @pytest.mark.unit - def test_emitted_neo_hookean_source_is_structurally_sound( - self, neo_hookean_energy: EnergyModel - ): - """Verifies: The derived Neo-Hookean energy emits valid Taichi source - through the production pipeline. - AC: AC-1 (compiles to Taichi). - Passes when: emitted block carries the derived banner, the derived - signature ``(F, kappa, mu)``, one assignment per stress component, and - no leftover host ``math.`` call (Taichi rejects those inside @ti.func).""" - block = _emit_constitutive_block(neo_hookean_energy) - - # Routed through the derived branch, not the named-model SVK/J2 switch. - assert "derived from LaTeX energy" in block - assert "@ti.func" in block - # Derived signature differs from SVK's (F, aleph, mu). - assert "def constitutive_update(F, kappa, mu):" in block - # One assignment per stress component. - for i in range(3): - for j in range(3): - assert f"S[{i}, {j}] =" in block - # The volumetric (Jdet) term emits a square root: it must be the Taichi - # intrinsic, never the host math.sqrt that crashes the JIT. - assert "ti.sqrt" in block - assert "math.sqrt" not in block - - # ------------------------------------------------------------------ - # AC-1b: Symbolic derivation matches oracle at random F (fast, no JIT) - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_generated_neo_hookean_func_matches_oracle_fast(self, neo_hookean_energy: EnergyModel): - """Verifies: Symbolic derivation of Neo-Hookean from LaTeX matches the - oracle without paying the JIT cost. - AC: AC-1 (< 1e-8 stress match). - Passes when: lambdified derived PK2 stress agrees with neo_hookean.py - ``pk2_stress`` at N random F to < 1e-8.""" - import sympy as sp - - model = neo_hookean_energy - strain = model.strain_symbols - params = sorted( - (s for s in model.pk2.free_symbols if not s.name.startswith("EDD")), - key=lambda s: s.name, - ) - flat_strain = [strain[i][j] for i in range(3) for j in range(3)] - pk2_fn = sp.lambdify((*flat_strain, *params), model.pk2, "numpy") - - mat = NeoHookeanMaterial(mu=_MU, kappa=_KAPPA) - param_vals = {"kappa": _KAPPA, "mu": _MU} - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - E = 0.5 * (F.T @ F - np.eye(3)) - S_derived = np.array( - pk2_fn( - *(E[i, j] for i in range(3) for j in range(3)), - *(param_vals[p.name] for p in params), - ), - dtype=np.float64, - ) - S_oracle = pk2_stress(mat, E) - scale = max(1.0, float(np.max(np.abs(S_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(S_derived - S_oracle)) / scale)) - assert max_rel < 1e-8, f"derived vs oracle max rel-err {max_rel:.3e} >= 1e-8" - - # ------------------------------------------------------------------ - # AC-3: Emitted constitutive @ti.func respects the JIT budget (≤ 512 lines) - # ------------------------------------------------------------------ - - @pytest.mark.unit - def test_emitted_constitutive_func_within_jit_budget(self, neo_hookean_energy: EnergyModel): - """Verifies: The generated constitutive @ti.func stays within the - 512-line JIT budget (07-CONVENTIONS §9). - AC: AC-3 (≤ 512 lines). - Passes when: the unrolled @ti.func (decorator..return S) is ≤ - MAX_LINES_TI_FUNC lines. - - Note: the production derived branch emits only the PK2-stress - ``constitutive_update`` (no tangent @ti.func yet — derived-tangent - emission is Phase 4/5 wiring), so the budget assertion targets the - emitted stress func, which is the @ti.func the JIT compiles today.""" - block = _emit_constitutive_block(neo_hookean_energy) - func_lines = _extract_ti_func(block) - assert len(func_lines) <= MAX_LINES_TI_FUNC, ( - f"emitted constitutive @ti.func has {len(func_lines)} lines, " - f"exceeding the {MAX_LINES_TI_FUNC}-line JIT budget" - ) - - # ------------------------------------------------------------------ - # AC-4: JIT-run against oracle (slow, requires Taichi) — the gate - # ------------------------------------------------------------------ - - @pytest.mark.slow - def test_generated_neo_hookean_kernel_jit_matches_oracle( - self, neo_hookean_energy: EnergyModel, tmp_path - ): - """Verifies: the PRODUCTION-emitted Neo-Hookean constitutive @ti.func - JIT-compiles and reproduces the oracle at random F. - AC: AC-1 (< 1e-8) + AC-4 (JIT-run passes). - - This is the gate: the @ti.func is taken from the real pipeline - (``emit_constitutive_update`` off an ``ArtifactBundle.from_pipeline``), - wrapped in an argpack runner, JIT-compiled, and matched against - ``models/neo_hookean.py`` ``pk2_stress`` to < 1e-8.""" - ti = pytest.importorskip("taichi") - ti.init(arch=ti.cpu, default_fp=ti.f64) - - block = _emit_constitutive_block(neo_hookean_energy) - module_path = tmp_path / "generated_nh.py" - _build_runner_module(neo_hookean_energy, block, module_path) - - spec = importlib.util.spec_from_file_location("generated_nh", module_path) - assert spec and spec.loader - gen = importlib.util.module_from_spec(spec) - spec.loader.exec_module(gen) - - params = gen.ParamPack(kappa=_KAPPA, mu=_MU) - mat = NeoHookeanMaterial(mu=_MU, kappa=_KAPPA) - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - gen.F_in[None] = F.tolist() - gen.run(params) - S_generated = gen.S_out[None].to_numpy() - - E = 0.5 * (F.T @ F - np.eye(3)) - S_oracle = pk2_stress(mat, E) - scale = max(1.0, float(np.max(np.abs(S_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(S_generated - S_oracle)) / scale)) - assert np.allclose(S_generated, S_oracle, atol=1e-8, rtol=1e-10) - assert max_rel < 1e-8, f"JIT NH vs oracle max rel-err {max_rel:.3e} >= 1e-8" - - # ------------------------------------------------------------------ - # AC-5a: Emitted tangent @ti.func is structurally sound + within budget - # ------------------------------------------------------------------ - - @pytest.mark.unit - def test_emitted_tangent_source_is_sound_and_within_budget( - self, neo_hookean_energy: EnergyModel - ): - """Verifies: the derived Neo-Hookean tangent emits a valid 6x6 Voigt - @ti.func that stays within the 512-line JIT budget. - AC: tangent emission (6x6 Voigt) + ≤ MAX_LINES_TI_FUNC. - Passes when: emitted block carries the derived signature ``(F, kappa, - mu)``, one assignment per Voigt entry (6x6 = 36), the volumetric - ``ti.sqrt`` (never host ``math.sqrt``), and the unrolled @ti.func is ≤ - the budget.""" - block = emit_tangent_func(neo_hookean_energy) - - assert "@ti.func" in block - assert "def tangent_update(F, kappa, mu):" in block - assert "D = ti.Matrix.zero(ti.f64, 6, 6)" in block - # One assignment per Voigt tangent entry (full 6x6, not just upper tri). - for a in range(6): - for b in range(6): - assert f"D[{a}, {b}] =" in block - assert "return D" in block - # Volumetric term carries a square root: must be the Taichi intrinsic. - assert "ti.sqrt" in block - assert "math.sqrt" not in block - - func_lines = _extract_tangent_ti_func(block) - assert len(func_lines) <= MAX_LINES_TI_FUNC, ( - f"emitted tangent @ti.func has {len(func_lines)} lines, " - f"exceeding the {MAX_LINES_TI_FUNC}-line JIT budget" - ) - - # ------------------------------------------------------------------ - # AC-5b: Derived tangent matches oracle 6x6 Voigt at random F (fast, no JIT) - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_derived_tangent_matches_oracle_voigt_fast(self, neo_hookean_energy: EnergyModel): - """Verifies: the symbolic rank-4 tangent derived from LaTeX, reduced to - 6x6 Voigt the SAME way as the oracle (``tangent_to_voigt_66``), matches - ``neo_hookean.py`` ``material_tangent_voigt`` without paying the JIT cost. - AC: derived C_IJKL matches oracle < 1e-8 (compared in Voigt 6x6). - Passes when: lambdified derived tangent agrees with the oracle 6x6 Voigt - at N random F to < 1e-8.""" - import sympy as sp - - from mechdsl.symbolic.voigt import tangent_to_voigt_66 - - model = neo_hookean_energy - strain = model.strain_symbols - params = sorted( - { - s - for i in range(3) - for j in range(3) - for k in range(3) - for el in range(3) - for s in model.tangent[i, j, k, el].free_symbols - if not s.name.startswith("EDD") - }, - key=lambda s: s.name, - ) - flat_strain = [strain[i][j] for i in range(3) for j in range(3)] - # Lambdify the full rank-4 tangent; sympy returns a nested list -> array. - tan_fn = sp.lambdify((*flat_strain, *params), model.tangent.tolist(), "numpy") - - mat = NeoHookeanMaterial(mu=_MU, kappa=_KAPPA) - param_vals = {"kappa": _KAPPA, "mu": _MU} - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - E = 0.5 * (F.T @ F - np.eye(3)) - C4_derived = np.array( - tan_fn( - *(E[i, j] for i in range(3) for j in range(3)), - *(param_vals[p.name] for p in params), - ), - dtype=np.float64, - ) - D_derived = tangent_to_voigt_66(C4_derived) - D_oracle = material_tangent_voigt(mat, E) - scale = max(1.0, float(np.max(np.abs(D_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(D_derived - D_oracle)) / scale)) - assert max_rel < 1e-8, f"derived vs oracle tangent max rel-err {max_rel:.3e} >= 1e-8" - - # ------------------------------------------------------------------ - # AC-5c: JIT-run tangent against oracle (slow, requires Taichi) - # ------------------------------------------------------------------ - - @pytest.mark.slow - def test_generated_tangent_kernel_jit_matches_oracle( - self, neo_hookean_energy: EnergyModel, tmp_path - ): - """Verifies: the emitted Neo-Hookean tangent @ti.func JIT-compiles and - reproduces the oracle 6x6 Voigt tangent at random F. - AC: derived tangent < 1e-8 (JIT-run) + ≤ 512-line budget. - - The @ti.func is taken from ``emit_tangent_func`` off the real-pipeline - ``EnergyModel`` (``derive_from_energy(neo_hookean_energy.tex)``), wrapped - in the same argpack runner pattern as the stress JIT test, JIT-compiled, - and matched against ``neo_hookean.py`` ``material_tangent_voigt`` at the - SAME N random F used by the stress test (identical seed/sampling).""" - ti = pytest.importorskip("taichi") - ti.init(arch=ti.cpu, default_fp=ti.f64) - - block = emit_tangent_func(neo_hookean_energy) - # Budget assertion on the @ti.func the JIT actually compiles. - assert len(_extract_tangent_ti_func(block)) <= MAX_LINES_TI_FUNC - - module_path = tmp_path / "generated_nh_tangent.py" - _build_tangent_runner_module(neo_hookean_energy, block, module_path) - - spec = importlib.util.spec_from_file_location("generated_nh_tangent", module_path) - assert spec and spec.loader - gen = importlib.util.module_from_spec(spec) - spec.loader.exec_module(gen) - - params = gen.ParamPack(kappa=_KAPPA, mu=_MU) - mat = NeoHookeanMaterial(mu=_MU, kappa=_KAPPA) - # SAME seed and sampling as the stress JIT test -> identical F sequence. - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - gen.F_in[None] = F.tolist() - gen.run_tangent(params) - D_generated = gen.D_out[None].to_numpy() - - E = 0.5 * (F.T @ F - np.eye(3)) - D_oracle = material_tangent_voigt(mat, E) - scale = max(1.0, float(np.max(np.abs(D_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(D_generated - D_oracle)) / scale)) - assert np.allclose(D_generated, D_oracle, atol=1e-8, rtol=1e-10) - assert max_rel < 1e-8, f"JIT NH tangent vs oracle max rel-err {max_rel:.3e} >= 1e-8" - - # ------------------------------------------------------------------ - # AC-2: Existing named-model emission unchanged (SVK / J2 / Lemaitre) - # ------------------------------------------------------------------ - - @staticmethod - def _emit_named(material: MaterialSpec) -> str: - ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=material, - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - ) - loc_result, plans = localise_and_optimize(ir) - bundle = ArtifactBundle.from_pipeline(ir, loc_result, plans) - return emit(bundle) - - @pytest.mark.integration - def test_existing_svk_emission_still_passes(self): - """Verifies: SVK named-model emission is unchanged (no derived energy -> - hand-coded closed form, no ti.sqrt rewrite touches it). - AC: AC-2 (SVK/J2/Lemaitre golden tests still pass).""" - source = self._emit_named(MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3})) - assert "def constitutive_update(" in source - assert "S = lam * tr_E * I3 + 2.0 * mu * E" in source - assert "derived from LaTeX energy" not in source - - @pytest.mark.integration - def test_existing_j2_emission_still_passes(self): - """Verifies: J2 power-law plasticity emission unchanged. - AC: AC-2 (SVK/J2/Lemaitre golden tests still pass).""" - source = self._emit_named( - MaterialSpec( - model="j2_power_law", - params={"E": 200e3, "nu": 0.3, "sigma_y0": 250.0, "K": 100.0, "n": 0.2}, - ) - ) - assert "def constitutive_update_plastic(" in source - assert "derived from LaTeX energy" not in source - - @pytest.mark.integration - def test_existing_lemaitre_emission_still_passes(self): - """Verifies: Lemaitre viscoplasticity (J2 + damage) emission unchanged. - AC: AC-2 (SVK/J2/Lemaitre golden tests still pass).""" - source = self._emit_named( - MaterialSpec( - model="lemaitre", - params={ - "E": 200e3, - "nu": 0.3, - "sigma_y0": 250.0, - "K": 100.0, - "n": 0.2, - "S": 1.0, - "s": 1.0, - "D_crit": 0.5, - }, - ) - ) - assert "def constitutive_update_plastic(" in source - assert "derived from LaTeX energy" not in source diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P4-1.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P4-1.py deleted file mode 100644 index e3bbf64..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P4-1.py +++ /dev/null @@ -1,358 +0,0 @@ -"""Tests for Task P4-1: Mooney-Rivlin through the wired pipeline + NH reduction. - -A LaTeX-authored Mooney-Rivlin strain energy - - Psi = C1(Ibar1 - 3) + C2(Ibar2 - 3) + (kappa/2)(Jdet - 1)^2 - -flows through the *real* production codegen path - - ProblemIR(derived_energy=...) - -> localise_and_optimize - -> ArtifactBundle.from_pipeline - -> taichi_printer.emit_constitutive_update - -and the derived PK2 stress / rank-4 tangent reproduce the hand-coded oracle -``models/mooney_rivlin.py`` to < 1e-8 at N random deformation gradients. - -Authoring (see ``dev/examples/mooney_rivlin_energy.tex``): the three named -invariants ``Ibar1``, ``Ibar2``, ``Jdet`` are all already registered in -``symbolic/energy.py`` (``Ibar2`` added in P2-1), so P4-1 is pure repetition -of the proven P3-3 Neo-Hookean path — no engine change. The Mooney -coefficients are authored as greek tokens (nrpylatex scans only known -commands): ``\\alpha == C1``, ``\\beta == C2`` (sanitised to ``aleph``, -mirroring SVK's ``\\lambda -> aleph``), ``\\kappa == bulk``. - -Acceptance criteria: -- AC-1: Mooney-Rivlin matches mooney_rivlin.py < 1e-8 (stress); tangent within - documented method tolerance (here exact, < 1e-8). -- AC-2: Reduces to Neo-Hookean (mu = 2*C1) when C2 = 0. -- AC-3: AD oracle + tangent symmetry pass. - -Implementation pattern mirrors test_P3-3.py: the JIT test lifts the constitutive -``@ti.func`` verbatim from the PRODUCTION emission (``emit_constitutive_update`` -off an ``ArtifactBundle.from_pipeline``), wraps it in an argpack runner written -to a real file, JIT-compiles, and matches the oracle. -""" - -from __future__ import annotations - -import importlib.util -from pathlib import Path - -import numpy as np -import pytest -import sympy as sp - -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.codegen.einsum_optimizer import MAX_LINES_TI_FUNC -from mechdsl.codegen.taichi_printer import ( - EmissionContext, - emit_constitutive_update, -) -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import localise_and_optimize -from mechdsl.symbolic.energy import EnergyModel, derive_from_energy -from mechdsl.symbolic.models.mooney_rivlin import ( - MooneyRivlinMaterial, - material_tangent_voigt, - pk2_stress, -) -from mechdsl.symbolic.models.neo_hookean import ( - NeoHookeanMaterial, -) -from mechdsl.symbolic.models.neo_hookean import ( - pk2_stress as nh_pk2_stress, -) -from mechdsl.symbolic.voigt import tangent_to_voigt_66 - -_EXAMPLES_DIR = Path(__file__).resolve().parents[5] / "dev" / "examples" -_MR_TEX = _EXAMPLES_DIR / "mooney_rivlin_energy.tex" - -# Material parameters (must match across every comparison). C1, C2 are the -# Mooney coefficients (authored as alpha, beta); kappa the bulk modulus. -_C1 = 0.6 -_C2 = 0.2 -_KAPPA = 50.0 - -_N_SAMPLES = 15 - - -@pytest.fixture(scope="module") -def mooney_rivlin_energy() -> EnergyModel: - """Derive the Mooney-Rivlin EnergyModel once (nrpylatex + sympy).""" - return derive_from_energy(_MR_TEX.read_text()) - - -def _sorted_params(model: EnergyModel) -> list[sp.Symbol]: - """Derived parameter symbols (free symbols of the PK2 stress that are not - strain components), sorted by sanitised name — the SAME order the emitter - uses for the ``constitutive_update`` signature.""" - return sorted( - (s for s in model.pk2.free_symbols if not s.name.startswith("EDD")), - key=lambda s: s.name, - ) - - -def _value_by_sanitised(model: EnergyModel, *, c2: float = _C2) -> dict[str, float]: - """Map each sanitised parameter name to its numeric value, resolving the - sanitised->original-LaTeX rename (``aleph`` -> ``beta`` == C2). ``c2`` is a - knob so the C2=0 reduction test can zero the second coefficient.""" - by_original = {"alpha": _C1, "beta": c2, "kappa": _KAPPA} - out: dict[str, float] = {} - for sym in _sorted_params(model): - original = model.parameters.get(sym, sym.name) - out[sym.name] = by_original[original] - return out - - -def _lambdified_stress(model: EnergyModel): - params = _sorted_params(model) - strain = model.strain_symbols - flat = [strain[i][j] for i in range(3) for j in range(3)] - return sp.lambdify((*flat, *params), model.pk2, "numpy"), params - - -def _lambdified_tangent(model: EnergyModel): - params = _sorted_params(model) - strain = model.strain_symbols - flat = [strain[i][j] for i in range(3) for j in range(3)] - return sp.lambdify((*flat, *params), model.tangent.tolist(), "numpy"), params - - -def _emit_constitutive_block(model: EnergyModel) -> str: - """Emit the constitutive ``@ti.func`` through the REAL pipeline: - ProblemIR.derived_energy -> localise_and_optimize -> ArtifactBundle -> - emit_constitutive_update (the production surface P4-1 proves, not a direct - call into the isolated energy_emitter).""" - ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec( - model="mooney_rivlin", params={"alpha": _C1, "beta": _C2, "kappa": _KAPPA} - ), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - derived_energy=model, - ) - loc_result, plans = localise_and_optimize(ir) - bundle = ArtifactBundle.from_pipeline(ir, loc_result, plans) - ctx = EmissionContext() - emit_constitutive_update(ctx, bundle) - return ctx.get_source() - - -def _extract_ti_func(block: str) -> list[str]: - lines = block.splitlines() - start = next(i for i, ln in enumerate(lines) if ln.strip() == "@ti.func") - end = next(i for i, ln in enumerate(lines) if ln.strip() == "return S") - return lines[start : end + 1] - - -def _build_runner_module(model: EnergyModel, block: str, path: Path) -> str: - """Compose a self-contained Taichi module around the PRODUCTION-emitted - constitutive @ti.func, mirroring test_P3-3.py. Params pass via an argpack - in the same sorted order as the emitted signature.""" - func_src = "\n".join(_extract_ti_func(block)) - names = [s.name for s in _sorted_params(model)] - pack_fields = ", ".join(f"{n}=ti.f64" for n in names) - forward = ", ".join(f"params.{n}" for n in names) - src = ( - "import math\n" - "import taichi as ti\n\n" - f"ParamPack = ti.types.argpack({pack_fields})\n" - "F_in = ti.Matrix.field(3, 3, ti.f64, shape=())\n" - "S_out = ti.Matrix.field(3, 3, ti.f64, shape=())\n\n" - f"{func_src}\n\n" - "@ti.kernel\n" - "def run(params: ParamPack):\n" - f" S_out[None] = constitutive_update(F_in[None], {forward})\n" - ) - path.write_text(src) - return src - - -class TestTaskP4_1: - """Tests for Task P4-1: Mooney-Rivlin derive/emit/diff + NH reduction. - AC covered: 1, 2, 3.""" - - # ------------------------------------------------------------------ - # AC-1a: Emitted source is structurally sound (fast, no JIT) - # ------------------------------------------------------------------ - - @pytest.mark.unit - def test_emitted_mooney_rivlin_source_is_structurally_sound( - self, mooney_rivlin_energy: EnergyModel - ): - """Verifies: the derived Mooney-Rivlin energy emits valid Taichi source - through the production pipeline. - AC: AC-1 (compiles to Taichi). - Passes when: the block carries the derived banner, a constitutive - signature, one assignment per stress component, the volumetric - ``ti.sqrt`` (never host ``math.sqrt``), and stays within the JIT budget.""" - block = _emit_constitutive_block(mooney_rivlin_energy) - - assert "derived from LaTeX energy" in block - assert "@ti.func" in block - # Pin the exact derived signature (sorted sanitised param names; beta is - # sanitised to aleph) so a wrong param name/order is caught structurally. - names = ", ".join(s.name for s in _sorted_params(mooney_rivlin_energy)) - assert f"def constitutive_update(F, {names}):" in block - for i in range(3): - for j in range(3): - assert f"S[{i}, {j}] =" in block - assert "ti.sqrt" in block - assert "math.sqrt" not in block - assert len(_extract_ti_func(block)) <= MAX_LINES_TI_FUNC - - # ------------------------------------------------------------------ - # AC-1b: Symbolic stress matches oracle at random F (fast, no JIT) - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_derived_mooney_rivlin_matches_oracle_stress(self, mooney_rivlin_energy: EnergyModel): - """Verifies: symbolic PK2 stress derived from LaTeX matches the oracle. - AC: AC-1 (< 1e-8 stress). - Passes when: lambdified derived stress agrees with mooney_rivlin.py - ``pk2_stress`` at N random F to < 1e-8.""" - model = mooney_rivlin_energy - pk2_fn, params = _lambdified_stress(model) - vals = _value_by_sanitised(model) - pvals = [vals[p.name] for p in params] - - mat = MooneyRivlinMaterial(C1=_C1, C2=_C2, kappa=_KAPPA) - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - E = 0.5 * (F.T @ F - np.eye(3)) - args = [E[i, j] for i in range(3) for j in range(3)] - S_derived = np.array(pk2_fn(*args, *pvals), dtype=np.float64) - S_oracle = pk2_stress(mat, E) - scale = max(1.0, float(np.max(np.abs(S_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(S_derived - S_oracle)) / scale)) - assert max_rel < 1e-8, f"derived vs oracle stress max rel-err {max_rel:.3e} >= 1e-8" - - # ------------------------------------------------------------------ - # AC-2: C2 = 0 reduces to Neo-Hookean (mu = 2*C1) - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_c2_zero_reduces_to_neo_hookean(self, mooney_rivlin_energy: EnergyModel): - """Verifies: Mooney-Rivlin with C2 = 0 reduces to compressible - Neo-Hookean with mu = 2*C1 (same volumetric term, kappa shared). - AC: AC-2. - Passes when: derived stress with C2 = 0 equals neo_hookean.py - ``pk2_stress`` (mu = 2*C1) at N random F to < 1e-8.""" - model = mooney_rivlin_energy - pk2_fn, params = _lambdified_stress(model) - vals0 = _value_by_sanitised(model, c2=0.0) - pvals0 = [vals0[p.name] for p in params] - - nh = NeoHookeanMaterial(mu=2.0 * _C1, kappa=_KAPPA) - rng = np.random.default_rng(424242) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - E = 0.5 * (F.T @ F - np.eye(3)) - args = [E[i, j] for i in range(3) for j in range(3)] - S_derived = np.array(pk2_fn(*args, *pvals0), dtype=np.float64) - S_nh = nh_pk2_stress(nh, E) - scale = max(1.0, float(np.max(np.abs(S_nh)))) - max_rel = max(max_rel, float(np.max(np.abs(S_derived - S_nh)) / scale)) - assert max_rel < 1e-8, f"C2=0 vs Neo-Hookean max rel-err {max_rel:.3e} >= 1e-8" - - # ------------------------------------------------------------------ - # AC-3: Derived rank-4 tangent matches oracle (Voigt) + symmetry - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_derived_tangent_matches_oracle_and_is_symmetric( - self, mooney_rivlin_energy: EnergyModel - ): - """Verifies: the symbolic rank-4 tangent derived from LaTeX, reduced to - 6x6 Voigt the SAME way as the oracle (``tangent_to_voigt_66``), matches - ``mooney_rivlin.py`` ``material_tangent_voigt`` and is minor-/major- - symmetric. - AC: AC-3 (AD oracle + tangent symmetry). - Passes when: derived tangent matches the oracle 6x6 Voigt < 1e-8 and the - rank-4 tangent satisfies minor (IJ, KL) and major (IJKL=KLIJ) symmetry.""" - model = mooney_rivlin_energy - tan_fn, params = _lambdified_tangent(model) - vals = _value_by_sanitised(model) - pvals = [vals[p.name] for p in params] - - mat = MooneyRivlinMaterial(C1=_C1, C2=_C2, kappa=_KAPPA) - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - E = 0.5 * (F.T @ F - np.eye(3)) - args = [E[i, j] for i in range(3) for j in range(3)] - C4 = np.array(tan_fn(*args, *pvals), dtype=np.float64) - - # Minor symmetry: C_IJKL == C_JIKL == C_IJLK. - assert np.allclose(C4, C4.transpose(1, 0, 2, 3), atol=1e-10) - assert np.allclose(C4, C4.transpose(0, 1, 3, 2), atol=1e-10) - # Major symmetry: C_IJKL == C_KLIJ. - assert np.allclose(C4, C4.transpose(2, 3, 0, 1), atol=1e-10) - - D_derived = tangent_to_voigt_66(C4) - D_oracle = material_tangent_voigt(mat, E) - scale = max(1.0, float(np.max(np.abs(D_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(D_derived - D_oracle)) / scale)) - assert max_rel < 1e-8, f"derived vs oracle tangent max rel-err {max_rel:.3e} >= 1e-8" - - # ------------------------------------------------------------------ - # AC-1c: JIT-run the production-emitted kernel against the oracle (slow) - # ------------------------------------------------------------------ - - @pytest.mark.slow - def test_generated_mooney_rivlin_kernel_jit_matches_oracle( - self, mooney_rivlin_energy: EnergyModel, tmp_path - ): - """Verifies: the PRODUCTION-emitted Mooney-Rivlin constitutive @ti.func - JIT-compiles and reproduces the oracle at random F. - AC: AC-1 (< 1e-8) + JIT-run. - The @ti.func is taken from the real pipeline (``emit_constitutive_update`` - off an ``ArtifactBundle.from_pipeline``), wrapped in an argpack runner, - JIT-compiled, and matched against ``models/mooney_rivlin.py`` ``pk2_stress``.""" - ti = pytest.importorskip("taichi") - ti.init(arch=ti.cpu, default_fp=ti.f64) - - model = mooney_rivlin_energy - block = _emit_constitutive_block(model) - module_path = tmp_path / "generated_mr.py" - _build_runner_module(model, block, module_path) - - spec = importlib.util.spec_from_file_location("generated_mr", module_path) - assert spec and spec.loader - gen = importlib.util.module_from_spec(spec) - spec.loader.exec_module(gen) - - vals = _value_by_sanitised(model) - params = gen.ParamPack(**vals) - mat = MooneyRivlinMaterial(C1=_C1, C2=_C2, kappa=_KAPPA) - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - gen.F_in[None] = F.tolist() - gen.run(params) - S_generated = gen.S_out[None].to_numpy() - - E = 0.5 * (F.T @ F - np.eye(3)) - S_oracle = pk2_stress(mat, E) - scale = max(1.0, float(np.max(np.abs(S_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(S_generated - S_oracle)) / scale)) - assert np.allclose(S_generated, S_oracle, atol=1e-8, rtol=1e-10) - assert max_rel < 1e-8, f"JIT MR vs oracle max rel-err {max_rel:.3e} >= 1e-8" diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P4-2.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P4-2.py deleted file mode 100644 index 7cdfe6c..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P4-2.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Tests for Task P4-2: Ogden via the spectral / principal-stretch path. - -A LaTeX-authored two-term Ogden strain energy (``dev/examples/ogden_energy.tex``) - - Psi = (mu1/a1)(lbar1^a1 + lbar2^a1 + lbar3^a1 - 3) - + (mu2/a2)(lbar1^a2 + lbar2^a2 + lbar3^a2 - 3) - + (kappa/2)(Jdet - 1)^2 - -is parsed and derived through the new spectral path -``symbolic/spectral_energy.derive_from_spectral_energy``: the bars and Jdet are -substituted in terms of three independent stretch symbols, Psi is differentiated -w.r.t. the stretches to give the principal PK2 stresses S_i = (1/l_i) dPsi/dl_i, -and S(E) is assembled numerically by eigendecomposition. The tangent is -central-difference FD of that stress, matching ``models/ogden.py``. - -Acceptance criteria: -- AC-1: Ogden matches ogden.py < 1e-8 (stress). -- AC-2: Tangent within ogden.py's documented FD-method tolerance. -- AC-3: Spectral path handled (eigenvalue derivatives, repeated/near-degenerate - stretches) without breaking the invariant path. - -The eigenvalue derivatives are singular at repeated stretches only in the -closed-form tangent; the spectral *stress* reassembly used here has no -eigenvalue-difference denominators, so it is robust at equal stretches (S_i = S_j -in the degenerate subspace; the eigenvector ambiguity cancels in the projector -sum). Taichi JIT emission of the spectral path (eigendecomposition in @ti.func) -is not in the MVP backend and is intentionally not exercised here. -""" - -from __future__ import annotations - -from pathlib import Path - -import numpy as np -import pytest - -from mechdsl.symbolic.energy import ( - EnergyDerivationError, - derive_from_energy, -) -from mechdsl.symbolic.models.neo_hookean import ( - NeoHookeanMaterial, -) -from mechdsl.symbolic.models.neo_hookean import ( - pk2_stress as nh_pk2_stress, -) -from mechdsl.symbolic.models.ogden import ( - OgdenMaterial, -) -from mechdsl.symbolic.models.ogden import ( - material_tangent_voigt as ogden_tangent_voigt, -) -from mechdsl.symbolic.models.ogden import ( - pk2_stress as ogden_pk2_stress, -) -from mechdsl.symbolic.spectral_energy import ( - SpectralEnergyModel, - derive_from_spectral_energy, -) - -_EXAMPLES_DIR = Path(__file__).resolve().parents[5] / "dev" / "examples" -_OGDEN_TEX = _EXAMPLES_DIR / "ogden_energy.tex" - -# Two-term compressible Ogden parameters. Authored greek -> oracle slot: -# mu == mu1, alpha == alpha1, nu == mu2, eta == alpha2, kappa == bulk. -_MUS = (1.3, -0.2) -_ALPHAS = (1.8, -2.0) -_KAPPA = 50.0 - -# original LaTeX parameter name -> numeric value -_BY_ORIGINAL = { - "mu": _MUS[0], - "alpha": _ALPHAS[0], - "nu": _MUS[1], - "eta": _ALPHAS[1], - "kappa": _KAPPA, -} - -_N_SAMPLES = 15 -# ogden.py computes its tangent by central-difference FD (eps=1e-6); the derived -# tangent uses the identical scheme on a mathematically-identical stress, so the -# two FD tangents agree well within the method's documented tolerance. -_FD_TANGENT_TOL = 1e-6 - - -@pytest.fixture(scope="module") -def ogden_energy() -> SpectralEnergyModel: - """Derive the two-term Ogden SpectralEnergyModel once for the module.""" - return derive_from_spectral_energy(_OGDEN_TEX.read_text()) - - -def _param_values(model: SpectralEnergyModel) -> dict[str, float]: - """Map each sanitised parameter symbol name to its numeric value via the - sanitised->original-LaTeX rename (no sanitisation expected here, all clean).""" - out: dict[str, float] = {} - for sym in model.param_symbols: - original = model.parameters.get(sym, sym.name) - out[sym.name] = _BY_ORIGINAL[original] - return out - - -def _E_from_F(F: np.ndarray) -> np.ndarray: - return 0.5 * (F.T @ F - np.eye(3)) - - -class TestTaskP4_2: - """Tests for Task P4-2: Ogden spectral derive/emit/diff (FD tangent). - AC covered: 1, 2, 3.""" - - # ------------------------------------------------------------------ - # AC-1: derived spectral stress matches ogden.py at random F - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_derived_ogden_matches_oracle_stress(self, ogden_energy: SpectralEnergyModel): - """Verifies: PK2 stress derived from the LaTeX Ogden energy via the - spectral path matches the hand-coded oracle. - AC: AC-1 (< 1e-8 stress). - Passes when: spectral S(E) agrees with ogden.py ``pk2_stress`` at N random - well-conditioned F to < 1e-8.""" - model = ogden_energy - pvals = _param_values(model) - mat = OgdenMaterial(mus=_MUS, alphas=_ALPHAS, kappa=_KAPPA) - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - E = _E_from_F(F) - S_derived = model.pk2_stress(E, pvals) - S_oracle = ogden_pk2_stress(mat, E) - scale = max(1.0, float(np.max(np.abs(S_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(S_derived - S_oracle)) / scale)) - assert max_rel < 1e-8, f"derived vs ogden.py stress max rel-err {max_rel:.3e} >= 1e-8" - - @pytest.mark.integration - def test_zero_stress_at_identity(self, ogden_energy: SpectralEnergyModel): - """Verifies: the spectral stress vanishes at F = I (lambda_i = 1, J = 1). - AC: AC-1 (physical consistency). - Passes when: S(E=0) is numerically zero.""" - model = ogden_energy - S = model.pk2_stress(np.zeros((3, 3)), _param_values(model)) - assert np.max(np.abs(S)) < 1e-9, f"stress at identity not zero: {np.max(np.abs(S)):.3e}" - - # ------------------------------------------------------------------ - # AC-2: FD tangent matches ogden.py's FD tangent within tolerance - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_ogden_fd_tangent_within_tolerance(self, ogden_energy: SpectralEnergyModel): - """Verifies: the derived FD material tangent (6x6 Voigt) matches - ``ogden.py`` ``material_tangent_voigt``. - AC: AC-2 (within documented FD-method tolerance). - Passes when: derived tangent agrees with the oracle 6x6 Voigt to within - the FD tolerance at N random F (compared the SAME way, unscaled shears).""" - model = ogden_energy - pvals = _param_values(model) - mat = OgdenMaterial(mus=_MUS, alphas=_ALPHAS, kappa=_KAPPA) - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - E = _E_from_F(F) - D_derived = model.material_tangent_voigt(E, pvals) - D_oracle = ogden_tangent_voigt(mat, E) - scale = max(1.0, float(np.max(np.abs(D_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(D_derived - D_oracle)) / scale)) - assert max_rel < _FD_TANGENT_TOL, ( - f"derived vs ogden.py tangent max rel-err {max_rel:.3e} >= {_FD_TANGENT_TOL}" - ) - - # ------------------------------------------------------------------ - # AC-3a: robust at repeated / near-degenerate principal stretches - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_repeated_eigenvalue_robustness(self, ogden_energy: SpectralEnergyModel): - """Verifies: the spectral stress is finite and matches the oracle at - repeated and near-degenerate stretches (where a closed-form spectral - tangent would be singular). - AC: AC-3 (eigenvalue path handled). - Passes when: hydrostatic (all stretches equal), two-equal, and - near-degenerate diagonal F all match ogden.py < 1e-8 and the hydrostatic - stress is isotropic.""" - model = ogden_energy - pvals = _param_values(model) - mat = OgdenMaterial(mus=_MUS, alphas=_ALPHAS, kappa=_KAPPA) - - cases = { - "hydrostatic": np.diag([1.1, 1.1, 1.1]), - "two-equal": np.diag([1.2, 1.05, 1.05]), - "near-degenerate": np.diag([1.1, 1.1 + 1e-7, 1.05]), - } - for name, F in cases.items(): - E = _E_from_F(F) - S_derived = model.pk2_stress(E, pvals) - S_oracle = ogden_pk2_stress(mat, E) - assert np.all(np.isfinite(S_derived)), f"{name}: non-finite stress" - scale = max(1.0, float(np.max(np.abs(S_oracle)))) - rel = float(np.max(np.abs(S_derived - S_oracle)) / scale) - assert rel < 1e-8, f"{name}: derived vs oracle {rel:.3e} >= 1e-8" - - # Hydrostatic stretch -> isotropic stress (S = s * I). - E_hydro = _E_from_F(cases["hydrostatic"]) - S_hydro = model.pk2_stress(E_hydro, pvals) - off_diag = S_hydro - np.diag(np.diag(S_hydro)) - assert np.max(np.abs(off_diag)) < 1e-9, "hydrostatic stress not diagonal" - assert np.allclose(np.diag(S_hydro), S_hydro[0, 0], atol=1e-9), ( - "hydrostatic stress not isotropic" - ) - - # ------------------------------------------------------------------ - # AC-3b: the invariant / component path is unbroken by the spectral addition - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_invariant_path_unbroken(self): - """Verifies: adding the spectral path did not break the named-invariant - derivation (Neo-Hookean still derives + matches its oracle). - AC: AC-3 (without breaking the invariant path). - Passes when: a Neo-Hookean energy authored in named invariants still - derives through ``derive_from_energy`` and matches neo_hookean.py < 1e-8.""" - import sympy as sp - - nh_tex = (_EXAMPLES_DIR / "neo_hookean_energy.tex").read_text() - model = derive_from_energy(nh_tex) - params = sorted( - (s for s in model.pk2.free_symbols if not s.name.startswith("EDD")), - key=lambda s: s.name, - ) - strain = model.strain_symbols - flat = [strain[i][j] for i in range(3) for j in range(3)] - pk2_fn = sp.lambdify((*flat, *params), model.pk2, "numpy") - - mu, kappa = 80.0, 160.0 - by_name = {"mu": mu, "kappa": kappa} - pvals = [by_name[p.name] for p in params] - mat = NeoHookeanMaterial(mu=mu, kappa=kappa) - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(10): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - E = _E_from_F(F) - args = [E[i, j] for i in range(3) for j in range(3)] - S_derived = np.array(pk2_fn(*args, *pvals), dtype=np.float64) - S_oracle = nh_pk2_stress(mat, E) - scale = max(1.0, float(np.max(np.abs(S_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(S_derived - S_oracle)) / scale)) - assert max_rel < 1e-8, f"invariant path regressed: NH rel-err {max_rel:.3e} >= 1e-8" - - # ------------------------------------------------------------------ - # IR discipline: the two derivation paths reject each other's energies - # ------------------------------------------------------------------ - - @pytest.mark.unit - def test_spectral_path_rejects_non_spectral_energy(self): - """Verifies: feeding an invariant-authored energy (no principal-stretch - symbol) to the spectral path raises with a pointer to derive_from_energy. - AC: AC-3 (IR discipline — unsupported construct raises, not guesses).""" - nh_tex = (_EXAMPLES_DIR / "neo_hookean_energy.tex").read_text() - with pytest.raises(EnergyDerivationError, match="derive_from_energy"): - derive_from_spectral_energy(nh_tex) - - @pytest.mark.unit - def test_invariant_path_rejects_spectral_symbols(self): - """Verifies: feeding a principal-stretch energy to the invariant path - raises (lbar symbols are unresolved there) rather than silently - producing a wrong (zero-contribution) result. - AC: AC-3 (IR discipline).""" - ogden_tex = _OGDEN_TEX.read_text() - with pytest.raises(EnergyDerivationError): - derive_from_energy(ogden_tex) - - @pytest.mark.unit - def test_mistyped_stretch_symbol_is_rejected(self): - """Verifies: a mistyped principal stretch (``lbar4`` — not 1/2/3, and not - a declared --const) raises instead of being silently absorbed as a - phantom parameter that contributes nothing to the stress. - AC: AC-3 (IR discipline — unsupported construct must raise).""" - bad_tex = ( - "% declare metric gDD --dim 3\n" - "% declare EDD --dim 3\n" - "% declare \\mu \\alpha \\kappa --const\n" - r"\Psi = \frac{\mu}{\alpha}\left(\mathrm{lbar1}^{\alpha} + " - r"\mathrm{lbar2}^{\alpha} + \mathrm{lbar4}^{\alpha} - 3\right) + " - r"\frac{\kappa}{2}\left(\mathrm{Jdet} - 1\right)^{2}" - ) - with pytest.raises(EnergyDerivationError, match="lbar4"): - derive_from_spectral_energy(bad_tex) diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P5-1.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P5-1.py deleted file mode 100644 index 7a578b2..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P5-1.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Tests for Task P5-1: fiber-direction per-element field-data plumbing. - -A ``% mechanics fiber --family "x, y, z"`` directive declares fiber direction(s) -as per-element FIELD data (distinct from scalar ``% mechanics material`` params), -which flows frontend -> ProblemIR.fiber_field (a FiberFieldSpec) -> ElementIR -.fiber_field with no layer bypass. Malformed declarations reject with a -line-numbered, phase-pointed message; the carry is immutable and validated at -construction. - -``build_context(fiber_data=...)`` (programmatic) + the HGO-requires-fiber gate -already existed; P5-1 adds the LaTeX *directive* and the ProblemIR/Element IR -field-data carry. - -Acceptance criteria: -- AC-1: Fiber direction(s) parse as per-element field data, distinct from scalar params. -- AC-2: Field data flows frontend -> ProblemIR -> Element IR (no layer bypass). -- AC-3: Malformed fiber declaration rejects with line number + phase pointer. -- AC-4: IR immutability + construction-time validation preserved. -""" - -from __future__ import annotations - -import dataclasses - -import pytest - -from mechdsl.frontend.directives import ParseError -from mechdsl.frontend.parser import parse -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - FiberFieldSpec, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import localise - - -def _hgo_source(*fiber_lines: str) -> str: - """A minimal HGO LaTeX problem with the given `% mechanics fiber` lines.""" - head = ( - "% mechanics dim 3\n" - "% mechanics cell hex8\n" - "% mechanics formulation total_lagrangian\n" - "% mechanics material hgo --mu 1.0 --k1 1.0 --k2 1.0 --kappa 100.0 " - "--fiber_dispersion 0.0\n" - '% mechanics boundary fix --type dirichlet --components "0 1 2"\n' - "% mechanics boundary load --type neumann --traction t_bar\n" - ) - return head + "".join(line if line.endswith("\n") else line + "\n" for line in fiber_lines) - - -class TestTaskP5_1: - """Tests for Task P5-1: fiber-direction per-element field-data plumbing. - AC covered: 1, 2, 3, 4.""" - - # ------------------------------------------------------------------ - # AC-1: the directive parses to per-element field data, distinct from params - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_fiber_directive_parses_to_per_element_data(self): - """Verifies: `% mechanics fiber --family` parses to fiber field data on - ProblemIR.fiber_field (a FiberFieldSpec), distinct from MaterialSpec.params. - AC: AC-1. - Passes when: two fiber families round-trip into FiberFieldSpec.families and - the directions are NOT present in material.params.""" - ctx = parse( - _hgo_source( - '% mechanics fiber --family "1, 0, 0"', '% mechanics fiber --family "0, 1, 0"' - ) - ) - assert ctx["fiber_families"][0]["direction"] == (1.0, 0.0, 0.0) - assert ctx["fiber_families"][1]["direction"] == (0.0, 1.0, 0.0) - - ir = ProblemIR.from_context(ctx) - assert isinstance(ir.fiber_field, FiberFieldSpec) - assert ir.fiber_field.families == ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0)) - assert ir.fiber_field.n_families == 2 - # Field data, NOT a scalar material param. - assert "family" not in ir.material.params - assert all(not isinstance(v, tuple) for v in ir.material.params.values()) - - # ------------------------------------------------------------------ - # AC-2: field data flows frontend -> ProblemIR -> Element IR (no bypass) - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_fiber_data_flows_problemir_to_element_ir(self): - """Verifies: fiber field data flows frontend -> ProblemIR -> Element IR. - AC: AC-2. - Passes when: localise(ir).element_ir.fiber_field equals the declared - family directions (lossless, no layer bypass).""" - ctx = parse( - _hgo_source( - '% mechanics fiber --family "1, 0, 0"', '% mechanics fiber --family "0, 1, 0"' - ) - ) - ir = ProblemIR.from_context(ctx) - result = localise(ir) - assert result.element_ir.fiber_field == ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0)) - - @pytest.mark.unit - def test_isotropic_problem_has_no_fiber_field(self): - """Verifies: a problem with no fiber directive carries fiber_field=None - through ProblemIR and Element IR (isotropic path unaffected). - AC: AC-2 (negative case).""" - ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - ) - assert ir.fiber_field is None - assert localise(ir).element_ir.fiber_field is None - - # ------------------------------------------------------------------ - # AC-3: malformed fiber declaration rejects with line number + phase pointer - # ------------------------------------------------------------------ - - @pytest.mark.unit - def test_malformed_fiber_wrong_length_rejected(self): - """Verifies: a fiber family with != 3 components rejects with a line number. - AC: AC-3.""" - with pytest.raises(ParseError, match=r"line \d+.*exactly 3 components"): - parse(_hgo_source('% mechanics fiber --family "1, 0"')) - - @pytest.mark.unit - def test_malformed_fiber_nonnumeric_rejected(self): - """Verifies: a non-numeric fiber family rejects with a line number. - AC: AC-3.""" - with pytest.raises(ParseError, match=r"line \d+.*numeric 3-vector"): - parse(_hgo_source('% mechanics fiber --family "1, x, 0"')) - - @pytest.mark.unit - def test_missing_family_option_rejected_with_phase_pointer(self): - """Verifies: `% mechanics fiber` without --family rejects with a phase - pointer (P5-1). - AC: AC-3.""" - with pytest.raises(ParseError, match=r"requires --family.*P5-1"): - parse(_hgo_source("% mechanics fiber")) - - @pytest.mark.unit - def test_zero_direction_rejected(self): - """Verifies: a zero fiber direction rejects (not a valid direction). - AC: AC-3.""" - with pytest.raises(ParseError, match=r"nonzero direction"): - parse(_hgo_source('% mechanics fiber --family "0, 0, 0"')) - - # ------------------------------------------------------------------ - # AC-4: IR immutability + construction-time validation - # ------------------------------------------------------------------ - - @pytest.mark.unit - def test_fiber_field_spec_is_immutable_and_validated(self): - """Verifies: FiberFieldSpec is frozen and validates its families at - construction (nonzero 3-vectors). - AC: AC-4.""" - spec = FiberFieldSpec(families=((1.0, 0.0, 0.0),)) - with pytest.raises(dataclasses.FrozenInstanceError): - spec.families = ((0.0, 1.0, 0.0),) # type: ignore[misc] - - with pytest.raises(ValueError, match="3-vector"): - FiberFieldSpec(families=((1.0, 0.0),)) # type: ignore[arg-type] - with pytest.raises(ValueError, match="nonzero"): - FiberFieldSpec(families=((0.0, 0.0, 0.0),)) - with pytest.raises(ValueError, match="at least one fiber family"): - FiberFieldSpec(families=()) - - @pytest.mark.unit - def test_fiber_field_round_trips_through_serialization(self): - """Verifies: a ProblemIR carrying fiber_field serialises and rebuilds it - (and a fiber-less IR omits the key, keeping legacy goldens byte-identical). - AC: AC-4 (immutability/validation preserved across round-trip).""" - ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="hgo", params={"mu": 1.0}), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - fiber_field=FiberFieldSpec(families=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0))), - ) - d = ir.to_dict() - assert d["fiber_field"] == {"families": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]} - rebuilt = ProblemIR.from_dict(d) - assert rebuilt.fiber_field == ir.fiber_field - - # Fiber-less IR: the key is omitted (golden stability). - iso = ProblemIR.from_dict({k: v for k, v in d.items() if k != "fiber_field"}) - assert iso.fiber_field is None - assert "fiber_field" not in iso.to_dict() diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P5-2.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P5-2.py deleted file mode 100644 index d1769c6..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P5-2.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Tests for Task P5-2: HGO energy via i4/i5 + diff-test (fiber gating). - -A LaTeX-authored HGO energy (``dev/examples/hgo_energy.tex``) - - Psi = (mu/2)(Ibar1 - 3) + (kappa/2)(Jdet - 1)^2 - + (k1/2k2)(exp(k2 (Ibar4 - 1)^2) - 1) - -is derived through ``symbolic/anisotropic_energy.derive_from_anisotropic_energy``: -the isotropic+volumetric part is differentiated w.r.t. E (the proven Neo-Hookean -path), and the fiber template binds ``Ibar4 -> I3^{-1/3}(a . C . a)`` with -symbolic fiber components and is differentiated to the active-branch fiber -stress. The model applies the fiber template to each declared fiber direction, -gating each by the Macaulay bracket ```` (active only in tension, -Ibar4 > 1), and differential-tested vs ``models/hgo.py`` (fiber_dispersion=0) to -< 1e-8. The tangent is FD, matching the oracle. - -Acceptance criteria: -- AC-1: HGO matches hgo.py < 1e-8 at random strains with fiber directions supplied. -- AC-2: Fiber-gating (Ibar4 > 1) branch correct; gated-off branch returns - isotropic-only stress. -- AC-3: Tangent within FD tolerance (codegen emission of the fiber gather + - gated exponential is not in the MVP Taichi backend and is deferred). -""" - -from __future__ import annotations - -from pathlib import Path - -import numpy as np -import pytest - -from mechdsl.symbolic.anisotropic_energy import ( - AnisotropicEnergyModel, - derive_from_anisotropic_energy, -) -from mechdsl.symbolic.energy import EnergyDerivationError, derive_from_energy -from mechdsl.symbolic.models.hgo import ( - HGOMaterial, -) -from mechdsl.symbolic.models.hgo import ( - material_tangent_voigt as hgo_tangent_voigt, -) -from mechdsl.symbolic.models.hgo import ( - pk2_stress as hgo_pk2_stress, -) - -_EXAMPLES_DIR = Path(__file__).resolve().parents[5] / "dev" / "examples" -_HGO_TEX = _EXAMPLES_DIR / "hgo_energy.tex" - -_MU, _K1, _K2, _KAPPA = 30.0, 5.0, 8.0, 200.0 -_PARAMS = {"mu": _MU, "k1": _K1, "k2": _K2, "kappa": _KAPPA} -# fiber_dispersion = 0 -> E_fi = Ibar4 - 1, exactly the authored energy. -_MAT = HGOMaterial(mu=_MU, k1=_K1, k2=_K2, kappa=_KAPPA, fiber_dispersion=0.0) -_N_SAMPLES = 20 -_FD_TANGENT_TOL = 1e-6 - - -def _unit(v: np.ndarray) -> np.ndarray: - return v / np.linalg.norm(v) - - -def _E(F: np.ndarray) -> np.ndarray: - return 0.5 * (F.T @ F - np.eye(3)) - - -@pytest.fixture(scope="module") -def hgo_energy() -> AnisotropicEnergyModel: - """Derive the HGO AnisotropicEnergyModel once for the module.""" - return derive_from_anisotropic_energy(_HGO_TEX.read_text()) - - -class TestTaskP5_2: - """Tests for Task P5-2: HGO energy via i4/i5 + diff-test (fiber gating). - AC covered: 1, 2, 3.""" - - # ------------------------------------------------------------------ - # AC-1: derived HGO stress matches oracle at random F (mixed gating) - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_derived_hgo_matches_oracle_two_families(self, hgo_energy: AnisotropicEnergyModel): - """Verifies: derived HGO stress (two fiber families, per-fiber gating) - matches the oracle at N random F. - AC: AC-1 + AC-2 (gating) + two families combine. - Passes when: derived S agrees with hgo.py (fiber_dispersion=0) to < 1e-8 - across random states that exercise both gated-on and gated-off fibers.""" - model = hgo_energy - a1, a2 = np.array([1.0, 0.0, 0.0]), _unit(np.array([0.3, 1.0, 0.2])) - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.08 * rng.standard_normal((3, 3)) - E = _E(F) - S_derived = model.pk2_stress(E, (a1, a2), _PARAMS) - S_oracle = hgo_pk2_stress(_MAT, E, (a1, a2)) - scale = max(1.0, float(np.max(np.abs(S_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(S_derived - S_oracle)) / scale)) - assert max_rel < 1e-8, f"derived vs hgo.py stress max rel-err {max_rel:.3e} >= 1e-8" - - @pytest.mark.integration - def test_zero_stress_at_identity(self, hgo_energy: AnisotropicEnergyModel): - """Verifies: stress vanishes at F = I (Ibar1=3, Jdet=1, Ibar4=1 -> fibers - gated off, E_fi=0). - AC: AC-1 (physical consistency).""" - model = hgo_energy - S = model.pk2_stress( - np.zeros((3, 3)), (np.array([1.0, 0, 0]), np.array([0, 1.0, 0])), _PARAMS - ) - assert np.max(np.abs(S)) < 1e-9 - - # ------------------------------------------------------------------ - # AC-2: gated-on vs gated-off branches - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_fiber_gated_on_stiffens_along_fiber(self, hgo_energy: AnisotropicEnergyModel): - """Verifies: stretching ALONG the fiber (Ibar4 > 1) activates the fiber - term — the derived stress matches the oracle's gated-on branch and is - strictly stiffer than the isotropic-only stress along the fiber axis. - AC: AC-2 (gated-on).""" - model = hgo_energy - a1 = np.array([1.0, 0.0, 0.0]) - # Isochoric uniaxial stretch along x (fiber a1): lambda = 1.3 in tension. - F = np.diag([1.3, 1.0 / np.sqrt(1.3), 1.0 / np.sqrt(1.3)]) - E = _E(F) - flat = [E[i, j] for i in range(3) for j in range(3)] - assert float(model._ibar4_fn(*flat, *a1)) > 1.0, "fiber should be in tension" - - # Both sides use the same two fiber directions (here both = a1). - S_derived = model.pk2_stress(E, (a1, a1), _PARAMS) - S_oracle = hgo_pk2_stress(_MAT, E, (a1, a1)) - scale = max(1.0, float(np.max(np.abs(S_oracle)))) - assert float(np.max(np.abs(S_derived - S_oracle)) / scale) < 1e-8 - - # The active fiber adds stress beyond the isotropic-only response along x. - S_iso_only = model.pk2_stress(E, (), _PARAMS) - assert S_derived[0, 0] > S_iso_only[0, 0] + 1e-6, "active fiber must stiffen along its axis" - - @pytest.mark.integration - def test_gated_off_returns_isotropic_only(self, hgo_energy: AnisotropicEnergyModel): - """Verifies: when every fiber is in compression (Ibar4 <= 1) the fiber - terms are gated off and the stress equals the isotropic+volumetric - (Neo-Hookean) part — and matches the oracle (which also returns 0 fiber). - AC: AC-2 (gated-off).""" - model = hgo_energy - # Compress along x so a fiber aligned with x has Ibar4 < 1. - a1 = np.array([1.0, 0.0, 0.0]) - F = np.diag([0.8, 1.0 / np.sqrt(0.8), 1.0 / np.sqrt(0.8)]) - E = _E(F) - flat = [E[i, j] for i in range(3) for j in range(3)] - assert float(model._ibar4_fn(*flat, *a1)) <= 1.0, "fiber should be compressed" - - S_derived = model.pk2_stress(E, (a1, a1), _PARAMS) - # Isotropic-only: no fibers supplied -> pure iso+vol stress. - S_iso_only = model.pk2_stress(E, (), _PARAMS) - assert np.allclose(S_derived, S_iso_only, atol=1e-10), "gated-off must equal iso-only" - # And matches the oracle (fibers gated off there too). - S_oracle = hgo_pk2_stress(_MAT, E, (a1, a1)) - scale = max(1.0, float(np.max(np.abs(S_oracle)))) - assert float(np.max(np.abs(S_derived - S_oracle)) / scale) < 1e-8 - - # ------------------------------------------------------------------ - # AC-1b: isotropic part equals Neo-Hookean (mu, kappa) when fibers off - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_isotropic_part_is_neo_hookean(self, hgo_energy: AnisotropicEnergyModel): - """Verifies: the derived HGO isotropic+volumetric stress (no active - fibers) equals the Neo-Hookean oracle with the same (mu, kappa). - AC: AC-1 (the iso split is the proven NH path).""" - from mechdsl.symbolic.models.neo_hookean import NeoHookeanMaterial - from mechdsl.symbolic.models.neo_hookean import pk2_stress as nh_pk2 - - model = hgo_energy - nh = NeoHookeanMaterial(mu=_MU, kappa=_KAPPA) - rng = np.random.default_rng(7) - max_rel = 0.0 - for _ in range(10): - F = np.eye(3) + 0.05 * rng.standard_normal((3, 3)) - E = _E(F) - S_iso = model.pk2_stress(E, (), _PARAMS) - S_nh = nh_pk2(nh, E) - scale = max(1.0, float(np.max(np.abs(S_nh)))) - max_rel = max(max_rel, float(np.max(np.abs(S_iso - S_nh)) / scale)) - assert max_rel < 1e-8, f"HGO iso part vs Neo-Hookean rel-err {max_rel:.3e}" - - # ------------------------------------------------------------------ - # AC-3: FD tangent matches the oracle within tolerance - # ------------------------------------------------------------------ - - @pytest.mark.integration - def test_derived_tangent_matches_oracle(self, hgo_energy: AnisotropicEnergyModel): - """Verifies: the derived FD tangent (6x6 Voigt) matches hgo.py's FD - tangent within tolerance. - AC: AC-3. - Passes when: derived tangent agrees with the oracle 6x6 Voigt to within - the FD tolerance at N random F (both compared via tangent_to_voigt_66).""" - model = hgo_energy - a1, a2 = np.array([1.0, 0.0, 0.0]), _unit(np.array([0.3, 1.0, 0.2])) - rng = np.random.default_rng(20260604) - max_rel = 0.0 - for _ in range(_N_SAMPLES): - F = np.eye(3) + 0.08 * rng.standard_normal((3, 3)) - E = _E(F) - D_derived = model.material_tangent_voigt(E, (a1, a2), _PARAMS) - D_oracle = hgo_tangent_voigt(_MAT, E, (a1, a2)) - scale = max(1.0, float(np.max(np.abs(D_oracle)))) - max_rel = max(max_rel, float(np.max(np.abs(D_derived - D_oracle)) / scale)) - assert max_rel < _FD_TANGENT_TOL, ( - f"derived vs hgo.py tangent max rel-err {max_rel:.3e} >= {_FD_TANGENT_TOL}" - ) - - # ------------------------------------------------------------------ - # IR discipline: the two derivation paths reject each other's energies - # ------------------------------------------------------------------ - - @pytest.mark.unit - def test_anisotropic_path_rejects_isotropic_energy(self): - """Verifies: an isotropic energy (no Ibar4) fed to the anisotropic path - raises with a pointer to derive_from_energy. - AC: AC-3 (IR discipline).""" - nh_tex = (_EXAMPLES_DIR / "neo_hookean_energy.tex").read_text() - with pytest.raises(EnergyDerivationError, match="derive_from_energy"): - derive_from_anisotropic_energy(nh_tex) - - @pytest.mark.unit - def test_isotropic_path_rejects_fiber_invariant(self): - """Verifies: the HGO energy (with Ibar4) fed to the isotropic path raises - (Ibar4 is a fiber invariant, unsupported there) rather than silently - producing a wrong result. - AC: AC-3 (IR discipline).""" - with pytest.raises(EnergyDerivationError): - derive_from_energy(_HGO_TEX.read_text()) diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P6-1.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P6-1.py deleted file mode 100644 index 034b85a..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P6-1.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Tests for Task P6-1: J2 isotropic power-law return-map via algo2code. - -The isotropic power-law hardening return-map (``sigma_y(alpha) = sigy0 + K -alpha^n``) is authored as algpseudocode in ``dev/algorithms/radial_return_j2.tex`` -and transpiled to Taichi by algo2code. This file differential-tests the -transpiled scalar return-map against the hand-written ``models/j2_power_law.py`` -oracle on a monotonic uniaxial path (< 1e-8), and pins transpile determinism. - -Two INDEPENDENT computation paths are compared (NOT the same code run twice): - -- Path (a): the algo2code-TRANSPILED scalar Newton loop. Exercised via - ``mechdsl.lib.plasticity._radial_return_algo2code``, whose plastic-multiplier - solve is the function emitted by ``transpile_radial_return_j2()`` (a fixed - ``for k in range(1, max_iter)`` loop generated from the .tex), wrapped with - Python tensor orchestration. -- Path (b): the hand-written ``j2_power_law.radial_return`` oracle, whose own - ``while``-style Newton loop (with a convergence break) solves the same scalar - problem independently. - -The scalar return-map solve in path (a) comes solely from the transpiled module; -in path (b) it comes from the oracle's own Newton loop. The two share no scalar -solver. (Phase 2 was bitten by a tautological oracle that re-ran one code path; -this test deliberately drives two distinct solvers.) - -Acceptance criteria: -- AC-1: Isotropic variant matches j2_power_law.py on a monotonic path < 1e-8. -- AC-2: Generated code within JIT budget (covered by algo2code codegen test). -- AC-3: Transpile is deterministic (golden-stable). -""" - -from __future__ import annotations - -import numpy as np -import pytest - -from algo2code.library.radial_return_j2 import transpile_radial_return_j2 -from mechdsl.lib.plasticity import ( - FEATURE_FLAG_ENV, - _radial_return_algo2code, -) -from mechdsl.symbolic.models.j2_power_law import ( - J2PowerLawMaterial, -) -from mechdsl.symbolic.models.j2_power_law import ( - radial_return as oracle_radial_return, -) - -# Acceptance tolerance from the plan (line 206) and AC-1. -ORACLE_TOL = 1e-8 - - -def _material() -> J2PowerLawMaterial: - """Power-law isotropic-hardening J2 material: sigma_y = 250 + 500*alpha^0.5.""" - return J2PowerLawMaterial(E=200_000.0, nu=0.3, sigma_y0=250.0, K=500.0, n=0.5) - - -def _uniaxial_strain(eps_axial: float) -> np.ndarray: - """Isochoric-style uniaxial Green-Lagrange strain (axial + lateral).""" - return np.diag([eps_axial, -0.5 * eps_axial, -0.5 * eps_axial]).astype(float) - - -class TestTaskP6_1: - """Tests for Task P6-1: J2 isotropic power-law return-map via algo2code. - - AC covered here: AC-1 (oracle match), AC-3 (deterministic transpile). - AC-2 (JIT budget / transpile validity) covered by - packages/algo2code/tests/test_radial_return_codegen.py. - """ - - @pytest.mark.integration - def test_monotonic_path_matches_j2_power_law_oracle( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Verifies: generated stress/tangent matches models/j2_power_law.py on a - monotonic uniaxial path. AC-1. Passes when: max abs diff < 1e-8. - - Path (a) drives ``_radial_return_algo2code`` (the algo2code-transpiled - scalar Newton loop); path (b) drives ``oracle_radial_return`` (the - hand-written oracle's own Newton loop). The two scalar solvers are - independent — only the surrounding NumPy tensor algebra is shared — so a - non-zero error is observable if the transpiled loop ever diverges. - """ - # Ensure path (a) really routes through the algo2code transpile, not the - # imported fallback. - monkeypatch.delenv(FEATURE_FLAG_ENV, raising=False) - - mat = _material() - - # Independent accumulated-plastic-strain history per path: each path - # consumes ONLY its own alpha_new, so a divergence in either scalar - # solver compounds and is detectable. - alpha_algo = 0.0 - alpha_oracle = 0.0 - - # Monotonically increasing uniaxial axial strain: elastic for the first - # several steps, then sustained plastic flow with growing delta_lambda. - saw_elastic = False - saw_plastic = False - max_diff = 0.0 - for i in range(1, 21): - eps = 1e-4 * i # strictly increasing → monotonic path - E = _uniaxial_strain(eps) - - # Path (a): algo2code-transpiled scalar return-map. - res_algo = _radial_return_algo2code(mat, E, alpha_algo) - # Path (b): hand-written oracle. - res_oracle = oracle_radial_return(mat, E, alpha_oracle) - - saw_elastic = saw_elastic or not res_oracle.is_plastic - saw_plastic = saw_plastic or res_oracle.is_plastic - - # Plastic-flag agreement is a hard requirement at every step. - assert res_algo.is_plastic == res_oracle.is_plastic, ( - f"step {i}: is_plastic disagreement " - f"(algo={res_algo.is_plastic}, oracle={res_oracle.is_plastic})" - ) - - # PK2 stress parity. - stress_diff = float(np.max(np.abs(res_algo.stress - res_oracle.stress))) - # Algorithmic tangent parity. - tangent_diff = float(np.max(np.abs(res_algo.tangent - res_oracle.tangent))) - dl_diff = abs(res_algo.delta_lambda - res_oracle.delta_lambda) - alpha_diff = abs(res_algo.alpha_new - res_oracle.alpha_new) - - step_max = max(stress_diff, tangent_diff, dl_diff, alpha_diff) - max_diff = max(max_diff, step_max) - - assert step_max < ORACLE_TOL, ( - f"step {i} (eps={eps:.2e}): max abs diff {step_max:.3e} >= " - f"{ORACLE_TOL:.1e} " - f"(stress={stress_diff:.3e}, tangent={tangent_diff:.3e}, " - f"dl={dl_diff:.3e}, alpha={alpha_diff:.3e})" - ) - - alpha_algo = res_algo.alpha_new - alpha_oracle = res_oracle.alpha_new - - # The path must genuinely cross the yield surface — otherwise the test - # would never exercise the plastic branch of the transpiled loop. - assert saw_elastic, "monotonic path never had an elastic step" - assert saw_plastic, "monotonic path never yielded — plastic branch untested" - assert max_diff < ORACLE_TOL - - @pytest.mark.integration - def test_transpile_is_deterministic(self) -> None: - """Verifies: transpiling the radial_return_j2.tex twice yields - byte-identical output. AC-3. Passes when: outputs are golden-stable.""" - first = transpile_radial_return_j2(backend="taichi") - second = transpile_radial_return_j2(backend="taichi") - assert first == second, "transpile output is not deterministic (byte-unstable)" - # Sanity: the emitted module actually contains the power-law entry point. - assert "def radial_return_j2(" in first, ( - "transpiled module missing radial_return_j2 entry point" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P6-2.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P6-2.py deleted file mode 100644 index b264417..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P6-2.py +++ /dev/null @@ -1,373 +0,0 @@ -"""Tests for Task P6-2: J2 kinematic linear hardening + numpy reference (cyclic). - -The J2 linear kinematic (Prager) hardening return-map is authored as -algpseudocode in ``dev/algorithms/radial_return_j2_kinematic.tex`` and transpiled -to Taichi by algo2code. The yield surface *translates* (back-stress ``beta``) -rather than *expands*: yield is on the RELATIVE stress ``xi = dev(S) - beta`` with -a constant radius ``sigma_y0``. There is NO existing mechdsl oracle for kinematic -hardening, so a small, independent numpy 1D-cyclic reference lives in -``packages/mechdsl-core/tests/ref/ref_j2_kinematic.py``. - -Two INDEPENDENT computation paths are compared (NOT the same code run twice): - -- Path (a): the algo2code-TRANSPILED scalar plastic-multiplier solve, wrapped by - the 3D tensor orchestration in ``mechdsl.lib.plasticity_kinematic`` (deviatoric - split, relative stress, von Mises of ``xi``, Prager tensor back-stress + plastic - strain update, algorithmic tangent). This is a finite-strain SVK radial return. -- Path (b): the hand-written 1D bilinear kinematic model in - ``ref_j2_kinematic.py`` — classical scalar (sigma, eps) plasticity. It shares no - code with path (a): different state (scalar q/ep vs tensor beta/Ep), different - algebra (1D Hooke vs deviatoric tensor return), no shared scalar solver. - -The two paths agree only because they encode the same *physics*, integrated by -different algebra — the genuine differential test the plan (lines 200, 207) calls -for. (Phase 2 was bitten by a tautological oracle re-running one code path; this -test deliberately drives two distinct integrators.) - -Acceptance criteria: -- AC-1: Kinematic variant matches the numpy reference on a cyclic path. -- AC-2: Bauschinger effect demonstrated on the cyclic path. -- AC-3: Generated code within JIT budget. -""" - -from __future__ import annotations - -import ast - -import numpy as np -import pytest - -from algo2code.library.radial_return_j2_kinematic import ( - transpile_radial_return_j2_kinematic, -) -from mechdsl.lib.plasticity_kinematic import ( - J2KinematicMaterial, - radial_return_kinematic, -) -from tests.ref.ref_j2_kinematic import ( - Bilinear1D, - analytic_bilinear_landmarks, - simulate_uniaxial_cyclic, -) - -# Material parameters shared across tests. -_E = 200_000.0 -_NU = 0.3 -_MU = _E / (2.0 * (1.0 + _NU)) -_SIGMA_Y0 = 250.0 -_H_KIN = 20_000.0 - -# Cyclic-path differential-test tolerance. -# -# Both integrators are EXACT for the bilinear kinematic response: each strain -# increment is a single closed-form return step (the consistency residual is -# linear in dl), and within each monotone segment the response is path-history- -# independent. So the 3D deviatoric return and the 1D bilinear agree to machine -# precision regardless of step count (verified: max diff 0.0 at 200 / 800 / 3200 -# steps; the relative-stress norm ||xi||_eq sits at exactly sigma_y0 on every -# plastic step, confirming the return map truly returns to the surface). -# -# The tolerance is therefore tight — 1e-6 MPa, ~4e-9 of the ~300 MPa peak. A -# WRONG transpile (mis-scaled dl) or a wrong flow-normal normalisation would -# leave ||xi||_eq off the yield surface and shift the curve by tens of MPa, -# blowing past this bound. This is a strict, non-tautological gate: the two paths -# share no code, yet a physics error in either surfaces immediately. -_CYCLIC_TOL_MPA = 1e-6 - -JIT_BUDGET_LINES_PER_TI_FUNC = 512 # 07-CONVENTIONS.md - - -def _material() -> J2KinematicMaterial: - return J2KinematicMaterial(E=_E, nu=_NU, sigma_y0=_SIGMA_Y0, H_kin=_H_KIN) - - -def _reference() -> Bilinear1D: - """1D bilinear analog of the 3D kinematic model on a deviatoric uniaxial path. - - Effective deviatoric parameters: E_1d = 3*mu (signed von-Mises trial slope vs - the axial deviatoric strain), H_1d = H_kin (Prager), Y = sigma_y0. - """ - return Bilinear1D(E=3.0 * _MU, H=_H_KIN, sigma_y0=_SIGMA_Y0) - - -def _cyclic_strain_path(eps_peak: float = 0.004, n_fwd: int = 800) -> np.ndarray: - """Uniaxial cyclic strain amplitudes: load -> reverse -> reload.""" - fwd = np.linspace(0.0, eps_peak, n_fwd) - rev = np.linspace(eps_peak, -eps_peak, 2 * n_fwd) - reload_ = np.linspace(-eps_peak, eps_peak, 2 * n_fwd) - return np.concatenate([fwd, rev[1:], reload_[1:]]) - - -def _drive_3d_eq_stress(path: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """Drive the 3D orchestration on a deviatoric uniaxial path. - - For ``E = diag(e, -e/2, -e/2)`` (traceless) the elastic predictor is purely - deviatoric, with no volumetric / lateral-strain coupling. The comparable - signed von-Mises equivalent stress is recovered as ``1.5 * dev(S)[0,0]`` (for - a uniaxial deviatoric tensor the axial component scales to the equivalent - stress by 3/2), giving the exact 1D analog the reference integrates. - - Returns - ------- - (signed_eq_stress, is_plastic) arrays aligned with ``path``. - """ - mat = _material() - Ep = np.zeros((3, 3)) - beta = np.zeros((3, 3)) - eq_stress = np.empty(path.size, dtype=np.float64) - plastic = np.empty(path.size, dtype=bool) - for i, e in enumerate(path): - E_strain = np.diag([e, -0.5 * e, -0.5 * e]).astype(float) - res = radial_return_kinematic(mat, E_strain, Ep, beta) - Ep = res.plastic_strain - beta = res.back_stress - s_dev = res.stress - (np.trace(res.stress) / 3.0) * np.eye(3) - eq_stress[i] = 1.5 * s_dev[0, 0] - plastic[i] = res.is_plastic - return eq_stress, plastic - - -class TestTaskP6_2J2Kinematic: - """Tests for Task P6-2: J2 kinematic linear hardening + numpy reference. - - AC covered: AC-1 (reference match), AC-2 (Bauschinger), AC-3 (JIT budget). - - Class name carries the ``J2`` token so the plan's verification command - ``pytest -k 'j2 and (kinematic or cyclic)'`` selects these tests. - """ - - @pytest.mark.integration - def test_numpy_reference_cross_validates_analytically(self) -> None: - """Verifies: the self-authored numpy reference matches a hand-computable - analytical cyclic segment. AC-1 (guards a wrong oracle). Passes when: the - simulated bilinear response reproduces the closed-form landmarks - (first-yield, post-yield tangent, forward back-stress, reverse-yield). - - This runs FIRST conceptually: it is what makes the reference trustworthy - ground truth before it is used to validate the transpile. - """ - model = _reference() - eps_peak = 0.004 - landmarks = analytic_bilinear_landmarks(model, eps_peak=eps_peak) - - # Sanity: the peak strain is genuinely past yield (otherwise the landmark - # formulas, which assume plastic flow, are vacuous). - assert eps_peak > landmarks["eps_yield"], "peak strain below yield" - - # Forward monotonic load to the peak. - fwd = np.linspace(0.0, eps_peak, 4000) - res = simulate_uniaxial_cyclic(model, fwd) - - # (1) First-yield stress == sigma_y0 (constant radius, no isotropic part). - first_plastic = int(np.argmax(res["is_plastic"])) - assert res["is_plastic"][first_plastic], "path never yielded" - sigma_at_yield = res["stress"][first_plastic - 1] - assert abs(sigma_at_yield - landmarks["sigma_yield"]) < 1.0, ( - f"first-yield stress {sigma_at_yield:.3f} != {landmarks['sigma_yield']:.3f}" - ) - - # (2) Post-yield tangent modulus E_t = E*H/(E+H), fit on the plastic - # segment. - plastic_mask = res["is_plastic"] - slope = np.polyfit(fwd[plastic_mask], res["stress"][plastic_mask], 1)[0] - assert abs(slope - landmarks["E_tangent"]) < 1e-6 * landmarks["E_tangent"], ( - f"E_t {slope:.6f} != analytic {landmarks['E_tangent']:.6f}" - ) - - # (3) Forward peak stress and back-stress. - assert abs(res["stress"][-1] - landmarks["sigma_peak"]) < 1e-8, ( - f"peak stress {res['stress'][-1]:.6f} != {landmarks['sigma_peak']:.6f}" - ) - assert abs(res["back_stress"][-1] - landmarks["back_stress_peak"]) < 1e-8, ( - f"peak back-stress {res['back_stress'][-1]:.6f} != {landmarks['back_stress_peak']:.6f}" - ) - - # (4) Reverse-yield stress == q_f - sigma_y0 (Bauschinger center shift). - full = np.concatenate([fwd, np.linspace(eps_peak, -eps_peak, 8000)[1:]]) - res_full = simulate_uniaxial_cyclic(model, full) - n_fwd = fwd.size - reverse_plastic = [ - i - for i in range(n_fwd, full.size) - if res_full["is_plastic"][i] and res_full["stress"][i] < landmarks["sigma_peak"] - 1.0 - ] - assert reverse_plastic, "reverse branch never re-yielded" - ri = reverse_plastic[0] - sigma_reverse = res_full["stress"][ri] - assert abs(sigma_reverse - landmarks["sigma_reverse"]) < 1.0, ( - f"reverse-yield stress {sigma_reverse:.3f} != analytic {landmarks['sigma_reverse']:.3f}" - ) - - @pytest.mark.integration - def test_cyclic_path_matches_numpy_reference(self) -> None: - """Verifies: generated kinematic stress matches the new numpy 1D-cyclic - reference on a loading/unloading/reverse path. AC-1. Passes when: max abs - diff within tolerance. - - Path (a) drives ``radial_return_kinematic`` (algo2code-transpiled scalar - solve + 3D Prager tensor orchestration); path (b) drives - ``simulate_uniaxial_cyclic`` (independent 1D bilinear). They share no code. - """ - path = _cyclic_strain_path() - - # Path (a): 3D orchestration. - eq_stress_3d, plastic_3d = _drive_3d_eq_stress(path) - - # Path (b): independent 1D reference. - ref = simulate_uniaxial_cyclic(_reference(), path) - stress_1d = ref["stress"] - - # The path must genuinely yield in both forward and reverse — otherwise - # the plastic branch of the transpiled solve is never exercised. - assert plastic_3d.any(), "3D path never yielded" - assert (~plastic_3d).any(), "3D path never had an elastic step" - - max_diff = float(np.max(np.abs(eq_stress_3d - stress_1d))) - assert max_diff < _CYCLIC_TOL_MPA, ( - f"max |3D - 1D| = {max_diff:.4f} MPa >= {_CYCLIC_TOL_MPA} MPa " - f"(3D peak {eq_stress_3d.max():.2f}, 1D peak {stress_1d.max():.2f})" - ) - - @pytest.mark.integration - def test_bauschinger_effect_present(self) -> None: - """Verifies: reverse-yield magnitude is below the forward yield magnitude - on the cyclic path (Bauschinger). AC-2. Passes when: reverse yield < - forward yield — which the isotropic model cannot show. - - Asserted on path (a), the algo2code-transpiled 3D kinematic path, so the - Bauschinger signal is a property of the generated code, not just the - reference. - """ - path = _cyclic_strain_path() - eq_stress, plastic = _drive_3d_eq_stress(path) - - # Forward yield magnitude: the equivalent stress at first yield equals - # sigma_y0 (constant radius). Take the forward peak as the reference - # forward magnitude. - n_fwd = 800 - forward_peak = float(np.max(eq_stress[:n_fwd])) - assert forward_peak > _SIGMA_Y0, "forward path never exceeded initial yield" - - # Reverse re-yield: first plastic step on the reverse branch where the - # (now negative) equivalent stress drops below the forward peak. - reverse_yield_indices = [ - i - for i in range(n_fwd, path.size) - if plastic[i] and eq_stress[i] < forward_peak - 1.0 and eq_stress[i] < 0.0 - ] - assert reverse_yield_indices, "reverse branch never re-yielded" - reverse_yield_stress = abs(eq_stress[reverse_yield_indices[0]]) - - # Bauschinger: the material re-yields in reverse at a magnitude BELOW the - # initial forward yield stress sigma_y0. (Isotropic hardening would push - # reverse yield ABOVE sigma_y0.) - assert reverse_yield_stress < _SIGMA_Y0, ( - f"no Bauschinger effect: reverse-yield |sigma| = " - f"{reverse_yield_stress:.2f} >= forward yield {_SIGMA_Y0:.2f}" - ) - - @pytest.mark.integration - def test_multiaxial_shear_state_returns_to_yield_surface(self) -> None: - """Verifies the 3D tensor return on a GENERAL multi-axial strain state - with shear, not just the uniaxial deviatoric path the cyclic test uses. - - A uniaxial path keeps the back-stress diagonal, so off-diagonal (shear) - errors in the Prager update or the consistency return can hide. Driving a - symmetric strain with non-zero shear components exercises every tensor - component. Two oracle-free invariants must hold on each plastic step: - - 1. Consistency: the return lands ON the (constant) yield surface, i.e. - ``||dev(S) - beta||_eq == sigma_y0`` to machine precision. A wrong - shear-component update would leave the relative stress off the surface. - 2. The back-stress ``beta`` and plastic strain ``Ep`` stay symmetric and - deviatoric (traceless) — J2 plastic flow is isochoric. - - The algorithmic tangent must retain minor and major symmetry. And the - path must develop genuinely non-zero off-diagonal back-stress, otherwise - the shear directions were never exercised. - """ - mat = _material() - - def _dev(t: np.ndarray) -> np.ndarray: - return t - (np.trace(t) / 3.0) * np.eye(3) - - def _eq(s: np.ndarray) -> float: - return float(np.sqrt(1.5 * np.tensordot(s, s, axes=2))) - - # Symmetric strain direction with normal AND shear components. - direction = np.array( - [ - [1.0, 0.45, 0.20], - [0.45, -0.30, 0.55], - [0.20, 0.55, -0.70], - ] - ) - assert np.allclose(direction, direction.T), "strain direction must be symmetric" - - Ep = np.zeros((3, 3)) - beta = np.zeros((3, 3)) - saw_plastic = False - for i in range(1, 13): - E_strain = (1e-3 * i) * direction - res = radial_return_kinematic(mat, E_strain, Ep, beta) - Ep = res.plastic_strain - beta = res.back_stress - - # beta and Ep symmetric + deviatoric on every step. - assert np.allclose(beta, beta.T, atol=1e-9), f"step {i}: beta not symmetric" - assert abs(np.trace(beta)) < 1e-7, f"step {i}: beta not deviatoric" - assert np.allclose(Ep, Ep.T, atol=1e-12), f"step {i}: Ep not symmetric" - assert abs(np.trace(Ep)) < 1e-12, f"step {i}: Ep not deviatoric (J2 isochoric)" - - if res.is_plastic: - saw_plastic = True - # (1) Consistency: relative stress sits on the constant radius. - xi_eq = _eq(_dev(res.stress) - beta) - assert abs(xi_eq - _SIGMA_Y0) < 1e-7, ( - f"step {i}: ||dev(S)-beta||_eq = {xi_eq:.6f} off the yield " - f"surface {_SIGMA_Y0:.6f} (shear-component return error)" - ) - # Tangent symmetries (minor + major). - C = res.tangent - assert np.allclose(C, np.swapaxes(C, 0, 1), rtol=1e-9, atol=1e-6), ( - f"step {i}: tangent lacks minor symmetry (ij)" - ) - assert np.allclose(C, np.swapaxes(C, 2, 3), rtol=1e-9, atol=1e-6), ( - f"step {i}: tangent lacks minor symmetry (kl)" - ) - assert np.allclose(C, np.transpose(C, (2, 3, 0, 1)), rtol=1e-9, atol=1e-6), ( - f"step {i}: tangent lacks major symmetry" - ) - - assert saw_plastic, "multi-axial path never yielded — shear return untested" - # The shear directions must have genuinely loaded the back-stress: at least - # one off-diagonal component is non-trivially non-zero. - off_diag_max = max(abs(beta[0, 1]), abs(beta[0, 2]), abs(beta[1, 2])) - assert off_diag_max > 1.0, ( - f"off-diagonal back-stress never developed (max |beta_ij| = " - f"{off_diag_max:.3e}); shear components were not exercised" - ) - - @pytest.mark.integration - def test_generated_code_within_jit_budget(self) -> None: - """Verifies: the transpiled kinematic return-map stays within the JIT - budget (07-CONVENTIONS <=512 lines per @ti.func). AC-3. Passes when: line - count within budget AND the module is valid, callable Taichi code. - """ - code = transpile_radial_return_j2_kinematic(backend="taichi") - - # Must be syntactically valid Python and declare the entry point. - tree = ast.parse(code) - func_names = [n.name for n in tree.body if isinstance(n, ast.FunctionDef)] - assert "radial_return_j2_kinematic" in func_names, ( - f"expected radial_return_j2_kinematic entry point; emitted: {func_names}" - ) - - # JIT budget probe: the longest @ti.func body must stay within budget. The - # scalar return-map emits as a single plain function (no @ti.func decorator - # on scalar algorithms), so the whole-module line count is a strict - # overestimate of any single @ti.func body — a conservative proxy. - line_count = len(code.splitlines()) - assert line_count <= JIT_BUDGET_LINES_PER_TI_FUNC, ( - f"emitted module {line_count} lines > {JIT_BUDGET_LINES_PER_TI_FUNC}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P6-3.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P6-3.py deleted file mode 100644 index 82748c9..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_P6-3.py +++ /dev/null @@ -1,388 +0,0 @@ -"""Tests for Task P6-3: J2 mixed hardening + numpy reference + reduction checks. - -The J2 MIXED-hardening return-map (isotropic power-law radius -``sigma_y(alpha) = sigy0 + K*alpha^n`` PLUS linear kinematic back-stress -``beta``, simultaneously) is authored as algpseudocode in -``dev/algorithms/radial_return_j2_mixed.tex`` and transpiled to Taichi by -algo2code. Yield is on the RELATIVE stress ``xi = dev(S) - beta`` against the -EXPANDING radius ``sigma_y(alpha)``. Because the isotropic part is a nonlinear -power law in ``alpha``, the consistency residual is nonlinear in the plastic -multiplier ``dl`` and is solved by a scalar Newton loop (P6-1) with the kinematic -``(3*mu + H_kin)*dl`` term added (P6-2). There is NO existing mechdsl oracle for -mixed hardening, so a small, independent numpy 1D-cyclic reference lives in -``packages/mechdsl-core/tests/ref/ref_j2_mixed.py``. - -Two INDEPENDENT computation paths are compared on the cyclic differential-test -(NOT the same code run twice): - -- Path (a): the algo2code-TRANSPILED scalar Newton solve, wrapped by the 3D - tensor orchestration in ``mechdsl.lib.plasticity_mixed`` (deviatoric split, - relative stress, von Mises of ``xi``, Prager tensor back-stress + plastic-strain - + accumulated-plastic-strain ``alpha`` updates, algorithmic tangent). -- Path (b): the hand-written 1D mixed model in ``ref_j2_mixed.py`` — classical - scalar (sigma, eps) plasticity with its OWN independent 1D Newton solve for the - nonlinear power-law consistency. It shares no code with path (a): different - state, different algebra, a separate scalar solver. - -The strongest correctness signal is the pair of REDUCTION cross-checks, which -compare the mixed law against the ALREADY-VALIDATED P6-1 isotropic and P6-2 -kinematic implementations (independent of the self-authored numpy reference): - -- ``H_kin = 0`` -> the mixed law must match the P6-1 isotropic variant - (``models/j2_power_law.radial_return``). -- ``K = 0`` -> the mixed law must match the P6-2 kinematic variant - (``lib/plasticity_kinematic.radial_return_kinematic``). - -Even a wrong numpy reference cannot mask a mixed-law error: the reductions tie -the law back to two independently-validated models. - -Acceptance criteria: -- AC-1: Mixed variant matches the numpy reference on a cyclic path. -- AC-2: Reduces to isotropic when kinematic modulus = 0. -- AC-3: Reduces to kinematic when isotropic modulus = 0. -- AC-4: Generated code within JIT budget. -""" - -from __future__ import annotations - -import ast - -import numpy as np -import pytest - -from algo2code.library.radial_return_j2_mixed import ( - transpile_radial_return_j2_mixed, -) -from mechdsl.lib.plasticity_kinematic import ( - J2KinematicMaterial, - radial_return_kinematic, -) -from mechdsl.lib.plasticity_mixed import ( - J2MixedMaterial, - radial_return_mixed, -) -from mechdsl.symbolic.models.j2_power_law import ( - J2PowerLawMaterial, -) -from mechdsl.symbolic.models.j2_power_law import ( - radial_return as iso_radial_return, -) -from tests.ref.ref_j2_mixed import ( - Mixed1D, - analytic_first_yield, - simulate_uniaxial_cyclic, -) - -# Material parameters shared across tests. -_E = 200_000.0 -_NU = 0.3 -_MU = _E / (2.0 * (1.0 + _NU)) -_SIGMA_Y0 = 250.0 -_K = 500.0 # isotropic power-law coefficient -_N = 0.5 # isotropic power-law exponent -_H_KIN = 20_000.0 # linear kinematic (Prager) modulus - -# Cyclic-path differential-test tolerance. -# -# Path (a) (3D tensor return, algo2code scalar Newton) and path (b) (independent -# 1D mixed model, own scalar Newton) agree to ~2e-11 MPa on the cyclic path -# (verified) — both Newton solves drive the same nonlinear consistency residual -# to ~1e-13 each step, and the deviatoric uniaxial map is exact, so the only -# residual is accumulated Newton/float round-off. A WRONG transpile (mis-scaled -# dl, wrong back-stress factor, wrong alpha increment, or a missing isotropic -# term) would leave ||xi||_eq off the (expanding) yield surface and shift the -# curve by MPa, blowing past this bound. Set at 1e-6 MPa (~3e-9 of the ~300 MPa -# peak) — a strict, non-tautological gate. -_CYCLIC_TOL_MPA = 1e-6 - -# Reduction tolerances. The reductions are the strongest correctness signal. -# -# K=0 reduces the residual to linear (constant radius) and the mixed and kinematic -# paths share the SAME tensor state (Ep, beta) advanced identically, so they agree -# to machine precision (verified: 0.0 over the full cyclic path). -_KIN_REDUCTION_TOL_MPA = 1e-9 -# H_kin=0 zeroes the back-stress so the mixed law collapses to the isotropic -# return; compared single-step from the zero state (P6-1's isotropic return does -# not subtract a plastic-strain tensor, so a per-step-from-zero sweep is the -# apples-to-apples comparison). Measured: stress diff 5.7e-14, tangent diff -# 1.16e-10. Set to 1e-9 — strict (well below the design-doc <1e-8 target) yet -# safely above the achieved tangent diff (1e-10 would be tighter than the -# measured 1.16e-10 and would flake). -_ISO_REDUCTION_TOL = 1e-9 - -JIT_BUDGET_LINES_PER_TI_FUNC = 512 # 07-CONVENTIONS.md - - -def _mixed_material(K: float = _K, H_kin: float = _H_KIN) -> J2MixedMaterial: - return J2MixedMaterial(E=_E, nu=_NU, sigma_y0=_SIGMA_Y0, K=K, n=_N, H_kin=H_kin) - - -def _reference(K: float = _K, H_kin: float = _H_KIN) -> Mixed1D: - """1D mixed analog of the 3D model on a deviatoric uniaxial path. - - Effective deviatoric parameters: E_1d = 3*mu (signed von-Mises trial slope vs - the axial deviatoric strain), H_kin = H_kin (Prager), (K, n) unchanged, - Y0 = sigma_y0. - """ - return Mixed1D(E=3.0 * _MU, H_kin=H_kin, K=K, n=_N, sigma_y0=_SIGMA_Y0) - - -def _cyclic_strain_path(eps_peak: float = 0.004, n_fwd: int = 800) -> np.ndarray: - """Uniaxial cyclic strain amplitudes: load -> reverse -> reload.""" - fwd = np.linspace(0.0, eps_peak, n_fwd) - rev = np.linspace(eps_peak, -eps_peak, 2 * n_fwd) - reload_ = np.linspace(-eps_peak, eps_peak, 2 * n_fwd) - return np.concatenate([fwd, rev[1:], reload_[1:]]) - - -def _drive_3d_eq_stress(mat: J2MixedMaterial, path: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """Drive the 3D mixed orchestration on a deviatoric uniaxial path. - - For ``E = diag(e, -e/2, -e/2)`` (traceless) the elastic predictor is purely - deviatoric. The comparable signed von-Mises equivalent stress is recovered as - ``1.5 * dev(S)[0,0]`` — the exact 1D analog the reference integrates. - - Returns ``(signed_eq_stress, is_plastic)`` arrays aligned with ``path``. - """ - Ep = np.zeros((3, 3)) - beta = np.zeros((3, 3)) - alpha = 0.0 - eq_stress = np.empty(path.size, dtype=np.float64) - plastic = np.empty(path.size, dtype=bool) - for i, e in enumerate(path): - E_strain = np.diag([e, -0.5 * e, -0.5 * e]).astype(float) - res = radial_return_mixed(mat, E_strain, alpha, Ep, beta) - Ep = res.plastic_strain - beta = res.back_stress - alpha = res.alpha_new - s_dev = res.stress - (np.trace(res.stress) / 3.0) * np.eye(3) - eq_stress[i] = 1.5 * s_dev[0, 0] - plastic[i] = res.is_plastic - return eq_stress, plastic - - -class TestTaskP6_3J2Mixed: - """Tests for Task P6-3: J2 mixed hardening + reduction cross-checks. - - AC covered: AC-1 (reference match), AC-2 (reduce->isotropic), - AC-3 (reduce->kinematic), AC-4 (JIT budget). - - Class name carries the ``J2`` token so the plan's verification command - ``pytest -k 'j2 and (mixed or cyclic)'`` selects these tests. - """ - - @pytest.mark.integration - def test_numpy_reference_cross_validates_analytically(self) -> None: - """Verifies: the self-authored numpy mixed reference reproduces the - hand-computable first-yield landmarks (eps_yield, sigma_yield), and the - independent 1D Newton solve returns the response exactly to the - (expanding) yield surface each plastic step. AC-1 (guards a wrong oracle). - - Runs FIRST conceptually: makes the reference trustworthy ground truth - before it is used to validate the transpile. - """ - ref = _reference() - lm = analytic_first_yield(ref) - - # First yield is purely elastic up to |sigma| = sigma_y0, independent of - # K, n, H_kin (all hardening engages only after first yield). - fwd = np.linspace(0.0, 0.004, 4000) - res = simulate_uniaxial_cyclic(ref, fwd) - - first_plastic = int(np.argmax(res["is_plastic"])) - assert res["is_plastic"][first_plastic], "path never yielded" - sigma_at_yield = res["stress"][first_plastic - 1] - assert abs(sigma_at_yield - lm["sigma_yield"]) < 1.0, ( - f"first-yield stress {sigma_at_yield:.3f} != {lm['sigma_yield']:.3f}" - ) - # The last elastic strain is below eps_yield, the first plastic at/above. - assert fwd[first_plastic] >= lm["eps_yield"] - 1e-6, ( - "first plastic step occurred before the analytic yield strain" - ) - - # Consistency: on every plastic step PAST the alpha->0 boundary the - # relative stress |sigma - q| must sit on the expanded radius - # sigma_y(alpha) (the Newton solve returns to the surface). This is the 1D - # analog of ||xi||_eq == sigma_y(alpha). - # - # The first plastic increment from alpha == 0 is excluded: with n < 1 the - # isotropic slope K*n*alpha^(n-1) diverges as alpha -> 0+, so both this - # reference and the 3D path hold the radius at sigy0 for that first step - # (the documented alpha->0 guard, identical to j2_power_law.py — "tests - # with n < 1 should start from a pre-yielded state"). The exclusion uses - # the SAME 1e-12 threshold as Mixed1D.iso_slope's regulariser, so only the - # single genuine boundary step is dropped (verified: 1 excluded, 2915 - # checked, worst off-surface residual 2.4e-11). - checked = 0 - for i in range(fwd.size): - if not res["is_plastic"][i]: - continue - alpha = res["alpha"][i] - if alpha <= 1e-12: # alpha->0 boundary (n<1 slope guard) — excluded - continue - sigma = res["stress"][i] - q = res["back_stress"][i] - radius = ref.sigma_y(alpha) - assert abs(abs(sigma - q) - radius) < 1e-6, ( - f"step {i}: |sigma-q|={abs(sigma - q):.6f} off radius {radius:.6f}" - ) - checked += 1 - assert checked > 0, "no plastic step past the alpha->0 boundary was checked" - - @pytest.mark.integration - def test_cyclic_path_matches_numpy_reference(self) -> None: - """Verifies: generated mixed stress matches the new numpy 1D mixed - reference on a loading/unloading/reverse path. AC-1. Passes when: max abs - diff within tolerance. - - Path (a) drives ``radial_return_mixed`` (algo2code-transpiled scalar - Newton + 3D Prager tensor orchestration); path (b) drives - ``simulate_uniaxial_cyclic`` (independent 1D mixed model, own Newton). - They share no code. - """ - mat = _mixed_material() - path = _cyclic_strain_path() - - eq_stress_3d, plastic_3d = _drive_3d_eq_stress(mat, path) - ref = simulate_uniaxial_cyclic(_reference(), path) - stress_1d = ref["stress"] - - # The path must genuinely yield in both forward and reverse, with elastic - # steps too — otherwise the plastic Newton branch is never exercised. - assert plastic_3d.any(), "3D path never yielded" - assert (~plastic_3d).any(), "3D path never had an elastic step" - - max_diff = float(np.max(np.abs(eq_stress_3d - stress_1d))) - assert max_diff < _CYCLIC_TOL_MPA, ( - f"max |3D - 1D| = {max_diff:.4e} MPa >= {_CYCLIC_TOL_MPA} MPa " - f"(3D peak {eq_stress_3d.max():.2f}, 1D peak {stress_1d.max():.2f})" - ) - - @pytest.mark.integration - def test_reduces_to_isotropic_when_kinematic_modulus_zero(self) -> None: - """Verifies: with H_kin = 0 the mixed law matches the P6-1 isotropic - variant (``models/j2_power_law.radial_return``). AC-2 — the STRONGEST - correctness signal: it ties the mixed law to an already-validated model - independent of the self-authored numpy reference. - - With H_kin = 0 the back-stress stays zero (xi == dev(S)), so the mixed - return collapses to the isotropic power-law return. Compared single-step - from the zero state across a strain sweep (P6-1's isotropic return does - not subtract a plastic-strain tensor, so a per-step-from-zero sweep is the - apples-to-apples comparison — Ep = 0 makes the mixed trial identical to - the isotropic trial). - """ - mat = _mixed_material(H_kin=0.0) - iso = J2PowerLawMaterial(E=_E, nu=_NU, sigma_y0=_SIGMA_Y0, K=_K, n=_N) - - saw_plastic = False - saw_elastic = False - max_stress_diff = 0.0 - max_tangent_diff = 0.0 - for i in range(1, 21): - eps = 1e-4 * i - E_strain = np.diag([eps, -0.5 * eps, -0.5 * eps]).astype(float) - - res_mixed = radial_return_mixed(mat, E_strain, 0.0, np.zeros((3, 3)), np.zeros((3, 3))) - res_iso = iso_radial_return(iso, E_strain, 0.0) - - assert res_mixed.is_plastic == res_iso.is_plastic, ( - f"step {i}: is_plastic disagreement " - f"(mixed={res_mixed.is_plastic}, iso={res_iso.is_plastic})" - ) - saw_plastic = saw_plastic or res_iso.is_plastic - saw_elastic = saw_elastic or not res_iso.is_plastic - - max_stress_diff = max( - max_stress_diff, - float(np.max(np.abs(res_mixed.stress - res_iso.stress))), - ) - max_tangent_diff = max( - max_tangent_diff, - float(np.max(np.abs(res_mixed.tangent - res_iso.tangent))), - ) - - assert saw_plastic, "sweep never yielded (reduction vacuous)" - assert saw_elastic, "sweep never had an elastic step (reduction vacuous)" - assert max_stress_diff < _ISO_REDUCTION_TOL, ( - f"H_kin=0 mixed stress != isotropic: max diff {max_stress_diff:.3e}" - ) - assert max_tangent_diff < _ISO_REDUCTION_TOL, ( - f"H_kin=0 mixed tangent != isotropic: max diff {max_tangent_diff:.3e}" - ) - - @pytest.mark.integration - def test_reduces_to_kinematic_when_isotropic_modulus_zero(self) -> None: - """Verifies: with K = 0 (isotropic modulus zero) the mixed law matches the - P6-2 kinematic variant (``lib/plasticity_kinematic.radial_return_kinematic``). - AC-3 — a strong correctness signal independent of the numpy reference. - - With K = 0 the radius is constant (sigma_y(alpha) == sigma_y0) and the - residual is linear; the mixed and kinematic paths advance the SAME tensor - state (Ep, beta), so they agree to machine precision over a full cyclic - path (where the back-stress / Bauschinger coupling is exercised). - """ - mat = _mixed_material(K=0.0) - kin = J2KinematicMaterial(E=_E, nu=_NU, sigma_y0=_SIGMA_Y0, H_kin=_H_KIN) - path = _cyclic_strain_path() - - Ep_m = np.zeros((3, 3)) - beta_m = np.zeros((3, 3)) - alpha_m = 0.0 - Ep_k = np.zeros((3, 3)) - beta_k = np.zeros((3, 3)) - - saw_plastic = False - saw_reverse_plastic = False - max_diff = 0.0 - n_fwd = 800 - for i, e in enumerate(path): - E_strain = np.diag([e, -0.5 * e, -0.5 * e]).astype(float) - res_mixed = radial_return_mixed(mat, E_strain, alpha_m, Ep_m, beta_m) - res_kin = radial_return_kinematic(kin, E_strain, Ep_k, beta_k) - - Ep_m, beta_m, alpha_m = ( - res_mixed.plastic_strain, - res_mixed.back_stress, - res_mixed.alpha_new, - ) - Ep_k, beta_k = res_kin.plastic_strain, res_kin.back_stress - - assert res_mixed.is_plastic == res_kin.is_plastic, ( - f"step {i}: is_plastic disagreement " - f"(mixed={res_mixed.is_plastic}, kin={res_kin.is_plastic})" - ) - saw_plastic = saw_plastic or res_mixed.is_plastic - if i >= n_fwd and res_mixed.is_plastic: - saw_reverse_plastic = True - - max_diff = max(max_diff, float(np.max(np.abs(res_mixed.stress - res_kin.stress)))) - - assert saw_plastic, "cyclic path never yielded (reduction vacuous)" - assert saw_reverse_plastic, "reverse branch never re-yielded (Bauschinger unexercised)" - assert max_diff < _KIN_REDUCTION_TOL_MPA, ( - f"K=0 mixed stress != kinematic: max diff {max_diff:.3e} MPa" - ) - - @pytest.mark.integration - def test_generated_code_within_jit_budget(self) -> None: - """Verifies: the transpiled mixed return-map stays within the JIT budget - (07-CONVENTIONS <=512 lines per @ti.func). AC-4. Passes when: line count - within budget AND the module is valid, callable Taichi code. - """ - code = transpile_radial_return_j2_mixed(backend="taichi") - - # Must be syntactically valid Python and declare the entry point. - tree = ast.parse(code) - func_names = [n.name for n in tree.body if isinstance(n, ast.FunctionDef)] - assert "radial_return_j2_mixed" in func_names, ( - f"expected radial_return_j2_mixed entry point; emitted: {func_names}" - ) - - # JIT budget probe: the scalar return-map emits as a single plain function - # (no @ti.func decorator on scalar algorithms), so the whole-module line - # count is a strict overestimate of any single @ti.func body. - line_count = len(code.splitlines()) - assert line_count <= JIT_BUDGET_LINES_PER_TI_FUNC, ( - f"emitted module {line_count} lines > {JIT_BUDGET_LINES_PER_TI_FUNC}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_derived_solver_e2e.py b/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_derived_solver_e2e.py deleted file mode 100644 index 98d05db..0000000 --- a/packages/mechdsl-core/tests/plan_tests/constitutive_latex/test_derived_solver_e2e.py +++ /dev/null @@ -1,261 +0,0 @@ -"""End-to-end tests for a LaTeX-derived constitutive solver. - -These cover the two production-wiring follow-ups deferred at the end of Phase 3 -(see ``dev/tasks/constitutive_latex/Handoff_Phase_4.md`` "Known Issues"): - -1. ``compile_latex`` façade auto-population — passing a strain-energy block - (``energy_source`` / ``energy_file``) derives the symbolic stress + tangent - and attaches them to the ``ProblemIR`` as ``derived_energy``. -2. Derived-parameter plumbing end-to-end — the generated solver - (``compute_internal_force`` / ``tangent_matvec`` / ``newton_solve`` / - ``__main__``) is parameterised on the energy's own material-parameter names - (Neo-Hookean: ``kappa, mu``) instead of the named-model ``(lam, mu)`` vocab, - and ``tangent_matvec`` linearises about the derived rank-4 tangent - (``dS = C_IJKL : dE``) rather than the SVK closed form. - -The slow gate is reference-free and rigorous: the generated ``tangent_matvec`` -must equal the central finite difference of ``compute_internal_force`` w.r.t. the -nodal displacements. That simultaneously exercises the derived stress (inside -``compute_internal_force``) and the derived tangent (inside ``tangent_matvec``) -through the *whole* emitted program, so any parameter-plumbing or ``C : dE`` -wiring error shows up as an inconsistency. Pointwise agreement of the derived -stress/tangent with the ``neo_hookean.py`` oracle is already pinned by -``test_P3-3.py``. -""" - -from __future__ import annotations - -import py_compile -from pathlib import Path -from typing import TYPE_CHECKING - -import numpy as np -import pytest - -from mechdsl import compile_latex - -if TYPE_CHECKING: - from types import ModuleType - -_EXAMPLES_DIR = Path(__file__).resolve().parents[5] / "dev" / "examples" -_NH_TEX = _EXAMPLES_DIR / "neo_hookean_energy.tex" - -# Material parameters (match test_P3-3.py). mu = shear, kappa = bulk modulus. -_MU = 80.0 -_KAPPA = 160.0 - -# The Neo-Hookean energy derives parameters {kappa, mu}; the generated solver is -# parameterised on this sorted list. Kept explicit so the tests assert the exact -# emitted vocabulary rather than re-deriving it. -_DERIVED_PARAMS = ("kappa", "mu") - -_PROBLEM_TEX = """% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material neo_hookean --mu 80 --kappa 160 -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "0 0 -1000" -""" - - -def _compile_derived_bundle(): - """Compile the Neo-Hookean problem with the energy block auto-populated.""" - return compile_latex(_PROBLEM_TEX, energy_file=_NH_TEX) - - -# --------------------------------------------------------------------------- -# Fast tests — façade auto-population + emitted param vocabulary (no JIT) -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_energy_file_populates_derived_energy(): - """``compile_latex(..., energy_file=...)`` attaches a derived energy model. - - Passes when: the bundle carries a non-None ``derived_energy`` whose PK2 and - tangent are present, and the emitted constitutive block is routed through the - derived branch (its banner), not the named-model SVK switch. - """ - bundle = _compile_derived_bundle() - assert bundle.derived_energy is not None - assert hasattr(bundle.derived_energy, "pk2") - assert hasattr(bundle.derived_energy, "tangent") - assert "derived from LaTeX energy" in bundle.emitted_source - - -@pytest.mark.integration -def test_energy_source_string_matches_energy_file(): - """The ``energy_source`` string path is equivalent to ``energy_file``. - - Passes when: passing the energy ``.tex`` text via ``energy_source`` produces - the same emitted source as reading it via ``energy_file``. - """ - by_file = compile_latex(_PROBLEM_TEX, energy_file=_NH_TEX) - by_str = compile_latex(_PROBLEM_TEX, energy_source=_NH_TEX.read_text()) - assert by_str.derived_energy is not None - assert by_str.emitted_source == by_file.emitted_source - - -@pytest.mark.unit -def test_energy_source_and_file_are_mutually_exclusive(): - """Supplying both ``energy_source`` and ``energy_file`` is rejected. - - Passes when: a ValueError naming both options is raised before any work. - """ - with pytest.raises(ValueError, match="at most one of energy_source / energy_file"): - compile_latex(_PROBLEM_TEX, energy_source="x", energy_file=_NH_TEX) - - -@pytest.mark.integration -def test_emitted_solver_parameterised_on_derived_params(tmp_path): - """The whole emitted solver speaks the derived ``(kappa, mu)`` vocabulary. - - Passes when: the constitutive / internal-force / tangent-matvec / newton-solve - signatures all carry the derived params, the derived tangent contraction - ``dS = C : dE`` is present, no stray named-model ``lam``/``mu_val`` Lamé - plumbing leaks into the derived program, and the source byte-compiles. - """ - src = _compile_derived_bundle().emitted_source - derived_sig = ", ".join(_DERIVED_PARAMS) - - assert f"def constitutive_update(F, {derived_sig}):" in src - assert f"def compute_internal_force({_DERIVED_PARAMS[0]}: ti.f64" in src - assert f"S = constitutive_update(F, {derived_sig})" in src - assert f"def tangent_matvec(v_flat: np.ndarray, {_DERIVED_PARAMS[0]}: float" in src - assert f"def newton_solve({_DERIVED_PARAMS[0]}: float" in src - assert "dS = np.einsum('ijkl,kl->ij', C4, dE)" in src - # The derived program must not fall back to the SVK Lamé plumbing. - assert "lam_val" not in src - assert "lam, mu : float" not in src - - out = tmp_path / "derived_nh_solver.py" - out.write_text(src, encoding="utf-8") - py_compile.compile(str(out), doraise=True) - - -# --------------------------------------------------------------------------- -# Slow e2e tests — the generated solver runs under Taichi JIT (the real gate) -# --------------------------------------------------------------------------- - - -def _load_unit_cube(mod: ModuleType): - """Allocate fields and load a single unit-cube Hex8 element into ``mod``.""" - from tests.ref.ref_hex8_elastic import generate_hex8_mesh - - coords, conn = generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - mod.allocate_fields(coords.shape[0], conn.shape[0]) - mod.x_ref.from_numpy(coords) - for e in range(conn.shape[0]): - for a in range(8): - mod.elem_nodes[e, a] = int(conn[e, a]) - mod.f_ext.from_numpy(np.zeros_like(coords)) - return coords, conn - - -@pytest.mark.slow -@pytest.mark.e2e -class TestDerivedNeoHookeanSolverE2E: - """The emitted Neo-Hookean solver JIT-compiles, runs, and is self-consistent.""" - - def test_tangent_matvec_matches_finite_difference(self, tmp_path): - """Generated ``tangent_matvec`` == central FD of ``compute_internal_force``. - - This is the gate for the derived-tangent wiring: at a non-trivial - deformed state, the analytic ``K @ v`` (derived rank-4 ``C : dE``) must - match ``[f_int(u + h v) - f_int(u - h v)] / (2 h)`` for random directions - ``v``. It exercises the derived stress (in ``compute_internal_force``) and - the derived tangent (in ``tangent_matvec``) through the full emitted - program, so any parameter-plumbing or contraction error surfaces here. - """ - ti = pytest.importorskip("taichi") - ti.init(arch=ti.cpu, default_fp=ti.f64) - - from tests._e2e_helpers import _import_generated_module - - src = _compile_derived_bundle().emitted_source - mod = _import_generated_module(src, tmp_path, "derived_nh_fd") - coords, _ = _load_unit_cube(mod) - n_dof = coords.shape[0] * 3 - - rng = np.random.default_rng(20260604) - # Non-trivial deformed state (~3% strain) — well inside the NH validity - # domain, large enough to exercise the geometric + material tangent. - u = 0.03 * rng.standard_normal((coords.shape[0], 3)) - - def f_int_at(u_state: np.ndarray) -> np.ndarray: - mod.u.from_numpy(u_state) - mod.compute_internal_force(_KAPPA, _MU) - return mod.f_int.to_numpy().ravel().copy() - - h = 1e-6 - max_rel = 0.0 - for _ in range(4): - v = rng.standard_normal(n_dof) - v /= np.linalg.norm(v) - v_mat = v.reshape((-1, 3)) - - mod.u.from_numpy(u) - kv = mod.tangent_matvec(v, _KAPPA, _MU) - - kv_fd = (f_int_at(u + h * v_mat) - f_int_at(u - h * v_mat)) / (2.0 * h) - - scale = max(1.0, float(np.linalg.norm(kv_fd))) - max_rel = max(max_rel, float(np.linalg.norm(kv - kv_fd) / scale)) - - assert max_rel < 1e-5, ( - f"derived tangent_matvec vs finite-difference of f_int: " - f"max rel-err {max_rel:.3e} >= 1e-5 (tangent/stress wiring inconsistent)" - ) - - def test_newton_solve_converges_to_nontrivial_solution(self, tmp_path): - """The emitted ``newton_solve`` drives a derived-NH problem to convergence. - - Passes when: with a fixed face and a small external load, the generated - ``newton_solve(kappa, mu, bc_dofs=...)`` returns after >= 1 iteration and - leaves a non-trivial displacement field (BCs applied, solver runs). - """ - ti = pytest.importorskip("taichi") - ti.init(arch=ti.cpu, default_fp=ti.f64) - - from tests._e2e_helpers import _import_generated_module - - src = _compile_derived_bundle().emitted_source - mod = _import_generated_module(src, tmp_path, "derived_nh_newton") - coords, _ = _load_unit_cube(mod) - - bc_mask = np.zeros((coords.shape[0], 3), dtype=bool) - left = np.where(np.abs(coords[:, 0]) < 1e-12)[0] - bc_mask[left, :] = True - bc_dofs = np.where(bc_mask.ravel())[0].astype(np.int64) - - f_ext = np.zeros((coords.shape[0], 3), dtype=np.float64) - right = np.where(np.abs(coords[:, 0] - 1.0) < 1e-12)[0] - for n_idx in right: - f_ext[n_idx, 0] = 5.0 - mod.f_ext.from_numpy(f_ext) - - n_iters = mod.newton_solve(_KAPPA, _MU, bc_dofs=bc_dofs) - - assert n_iters >= 1 - u_arr = mod.u.to_numpy() - assert float(np.max(np.abs(u_arr))) > 1e-10, "displacement trivially zero" - - -def test_derived_energy_with_no_params_raises(monkeypatch): - """A LaTeX-derived energy exposing zero material parameters must fail fast - with a clear error, not emit malformed (empty-argument) solver signatures. - - Guards the boundary that ``constitutive_update`` / ``tangent_matvec`` / - ``newton_solve`` are parameterised on: an empty parameter vocabulary would - otherwise produce ``def newton_solve(,`` / trailing-comma syntax errors in - the generated module. - """ - import mechdsl.codegen.energy_emitter as energy_emitter - from mechdsl.codegen.taichi_printer import _derived_params - - bundle = _compile_derived_bundle() - assert bundle.derived_energy is not None - monkeypatch.setattr(energy_emitter, "derived_param_names", lambda _model: []) - - with pytest.raises(ValueError, match="no material parameters"): - _derived_params(bundle) diff --git a/packages/mechdsl-core/tests/plan_tests/fgram/test_p1_1.py b/packages/mechdsl-core/tests/plan_tests/fgram/test_p1_1.py deleted file mode 100644 index 3fecd89..0000000 --- a/packages/mechdsl-core/tests/plan_tests/fgram/test_p1_1.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Docs-governance tests for fgram P1-1.""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -import pytest - -pytestmark = pytest.mark.docs - - -def _find_repo_root() -> Path: - for parent in Path(__file__).resolve().parents: - if (parent / "pyproject.toml").is_file() and (parent / "dev").is_dir(): - return parent - raise RuntimeError("Repository root not found") - - -REPO_ROOT = _find_repo_root() -FGRAM_TASKS = REPO_ROOT / "dev" / "tasks" / "fgram" -FGRAM_JSON = FGRAM_TASKS / "json" -FGRAM_INDEX = FGRAM_TASKS / "all-tasks.md" -FGRAM_TRACKER = REPO_ROOT / "dev" / "tracking" / "tasks-tracker_fgram.md" -FGRAM_REVIEW = REPO_ROOT / "dev" / "reviews" / "fgram_recovery_continuation.md" - -FOUNDATION_SOURCES = ("recovery_plan_latex_contract", "post_recovery_plan") -EXPECTED_TASKS = {f"P{phase}-1": str(phase) for phase in range(1, 8)} - - -def _read(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def _resolve_design_doc_base_ref() -> str | None: - """Return a git ref to diff committed design-doc changes against, or ``None``. - - Prefers ``origin/main`` (the ref a CI checkout has after fetching the base) - and falls back to ``main`` for local runs. Returns ``None`` when neither - resolves — e.g. a detached or shallow clone with no base ref — so the - committed-change check degrades to a skip rather than a spurious failure - (the concern raised in issue #271). - """ - for ref in ("origin/main", "main"): - probe = subprocess.run( - ["git", "rev-parse", "--verify", "--quiet", ref], - cwd=REPO_ROOT, - capture_output=True, - text=True, - ) - if probe.returncode == 0: - return ref - return None - - -def _load_task(task_id: str) -> dict[str, object]: - return json.loads((FGRAM_JSON / f"{task_id}.json").read_text(encoding="utf-8")) - - -class TestFgramP1_1Governance: - """Tests for Task P1-1: Plan/task governance scaffold.""" - - def test_fgram_review_note_marks_recovery_as_continuation(self) -> None: - """fgram is the next plan, not a replacement for completed recovery work.""" - note = _read(FGRAM_REVIEW) - - assert "fgram" in note - assert "continues after" in note - assert "does not replace" in note - for source in FOUNDATION_SOURCES: - assert source in note - assert note.count("foundation reused") >= len(FOUNDATION_SOURCES) - - def test_all_task_json_records_foundation_reuse_notes(self) -> None: - """Every task carries the reused recovery foundations as notes, not scope.""" - for task_id in EXPECTED_TASKS: - task = _load_task(task_id) - notes = task.get("foundation_notes") - assert isinstance(notes, list), f"{task_id} missing foundation_notes list" - note_text = "\n".join(str(note) for note in notes) - - for source in FOUNDATION_SOURCES: - assert source in note_text, f"{task_id} missing {source} foundation note" - assert note_text.count("foundation reused") >= len(FOUNDATION_SOURCES) - - scope_text = "\n".join(str(item) for item in task.get("scope", [])) - assert "recovery_plan_latex_contract" not in scope_text - assert "post_recovery_plan" not in scope_text - - def test_task_ids_map_cleanly_to_seven_fgram_phases(self) -> None: - """The index, tracker, and JSON records expose one task per plan phase.""" - index = _read(FGRAM_INDEX) - tracker = _read(FGRAM_TRACKER) - - for task_id, phase in EXPECTED_TASKS.items(): - task = _load_task(task_id) - assert task["task_id"] == task_id - assert task["phase"] == phase - assert f"| {task_id} | {phase} |" in index - assert f"| {task_id} |" in tracker - - assert len(list(FGRAM_JSON.glob("P*-1.json"))) == len(EXPECTED_TASKS) - - def test_no_design_doc_files_changed(self) -> None: - """The read-only design-doc source of truth must not be modified. - - ``git status --short`` only inspects the working tree, which is always - clean after a CI checkout — so a PR that *commits* design-doc edits would - slip past that gate (issue #271). Check both surfaces: uncommitted edits - in the working tree, and committed edits on this branch relative to the - base branch (``origin/main``/``main``). - """ - uncommitted = subprocess.run( - ["git", "status", "--short", "--", "dev/design_docs"], - cwd=REPO_ROOT, - check=True, - capture_output=True, - text=True, - ) - assert uncommitted.stdout == "", ( - f"Uncommitted design-doc changes detected (read-only):\n{uncommitted.stdout}" - ) - - base_ref = _resolve_design_doc_base_ref() - if base_ref is None: - pytest.skip("no base ref (origin/main or main) available to diff against") - - # Three-dot ``base...HEAD`` diffs from the merge-base, so it reports only - # what THIS branch changed since it forked — not design-doc edits that - # landed on the base branch afterwards. - committed = subprocess.run( - ["git", "diff", "--name-only", f"{base_ref}...HEAD", "--", "dev/design_docs"], - cwd=REPO_ROOT, - check=True, - capture_output=True, - text=True, - ) - assert committed.stdout == "", ( - f"Committed design-doc changes detected vs {base_ref} " - f"(design docs are read-only in PRs):\n{committed.stdout}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/fgram/test_p2_1.py b/packages/mechdsl-core/tests/plan_tests/fgram/test_p2_1.py deleted file mode 100644 index 429385c..0000000 --- a/packages/mechdsl-core/tests/plan_tests/fgram/test_p2_1.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Tests for fgram P2-1 math-aware compile_latex plumbing.""" - -from __future__ import annotations - -import pytest - -import mechdsl.frontend as frontend -from mechdsl import compile_latex -from mechdsl.frontend import FrontendSemanticError, parse, parse_compile_context -from mechdsl.ir.mechanics_ir import ProblemIR - -_DIRECTIVES_ONLY = ( - "% mechanics dim 3\n" - "% mechanics cell hex8\n" - "% mechanics formulation total_lagrangian\n" - "% mechanics material svk --E 200e3 --nu 0.3\n" - "% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2\n" -) - -_SUPPORTED_MATH_SOURCE = ( - _DIRECTIVES_ONLY - + "% declare FUU --dim 3\n" - + "% declare AUU --dim 3\n" - + "$A^{i I} = F^{i I}$\n" -) - -# ``\sin`` is in math_parser._UNSUPPORTED_FUNCTIONS (full-grammar deferral). -# ``\det`` is NOT used here: Phase 4 (P4-1) promoted it to a supported node, -# so it no longer exercises the frontend rejection path. -_UNSUPPORTED_MATH_SOURCE = _DIRECTIVES_ONLY + "% declare FUU --dim 3\n" + "$T = \\sin{F}$\n" - -_PROSE_GOVERNING_EQUATION_SOURCE = ( - _DIRECTIVES_ONLY - + "The governing equation is " - + "$\\nabla \\cdot \\boldsymbol{P} + \\boldsymbol{b} = 0$.\n" -) - -_WEAK_FORM_ACTIONABLE_MATH_SOURCE = ( - _DIRECTIVES_ONLY - + "% mechanics weak_form momentum --residual\n" - + "$\\nabla \\cdot \\boldsymbol{P} + \\boldsymbol{b} = 0$\n" -) - -_NOSPACE_WEAK_FORM_ACTIONABLE_MATH_SOURCE = ( - _DIRECTIVES_ONLY - + "%mechanics weak_form momentum --residual\n" - + "$\\nabla \\cdot \\boldsymbol{P} + \\boldsymbol{b} = 0$\n" -) - -_NOSPACE_CONSTITUTIVE_ACTIONABLE_MATH_SOURCE = ( - _DIRECTIVES_ONLY + "%mechanics constitutive Psi --strain_energy\n" + "$J = \\det{F}$\n" -) - - -@pytest.mark.unit -def test_parse_compile_context_preserves_directive_only_context_exactly() -> None: - """Directive-only compile frontend semantics stay byte-for-byte boring.""" - assert parse_compile_context(_DIRECTIVES_ONLY) == parse(_DIRECTIVES_ONLY) - - -@pytest.mark.unit -def test_parse_compile_context_ignores_prose_math_without_mechanics_math_context() -> None: - """Narrative equations in directive-only examples must not invoke NRPyLaTeX.""" - assert parse_compile_context(_PROSE_GOVERNING_EQUATION_SOURCE) == parse( - _PROSE_GOVERNING_EQUATION_SOURCE - ) - - -@pytest.mark.integration -def test_compile_latex_math_bearing_source_reaches_semantic_bundle( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A supported equation must flow through the math-aware frontend path.""" - original = frontend.parse_with_math - saw_math_bundle: list[bool] = [] - - def _spy_parse_with_math(source: str) -> dict: - context = original(source) - saw_math_bundle.append("math" in context) - return context - - monkeypatch.setattr(frontend, "parse_with_math", _spy_parse_with_math) - - bundle = compile_latex(_SUPPORTED_MATH_SOURCE, profile="mvp") - - assert saw_math_bundle == [True] - assert bundle.problem_ir_dict["material"]["model"] == "svk" - assert bundle.element_ir_summary["element_type"] == "hex8" - - -@pytest.mark.parametrize( - "source", - [ - _WEAK_FORM_ACTIONABLE_MATH_SOURCE, - _NOSPACE_WEAK_FORM_ACTIONABLE_MATH_SOURCE, - _NOSPACE_CONSTITUTIVE_ACTIONABLE_MATH_SOURCE, - ], -) -@pytest.mark.integration -def test_compile_latex_actionable_mechanics_math_fails_before_problem_ir( - source: str, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Metadata-tagged equations are actionable and fail at the frontend boundary.""" - problem_ir_calls: list[dict] = [] - - def _sentinel_from_context(ctx: dict) -> ProblemIR: - problem_ir_calls.append(ctx) - raise AssertionError("ProblemIR.from_context must not run for unsupported math") - - monkeypatch.setattr(ProblemIR, "from_context", _sentinel_from_context) - - with pytest.raises(FrontendSemanticError, match="before IR construction"): - compile_latex(source, profile="mvp") - - assert problem_ir_calls == [] - - -@pytest.mark.integration -def test_compile_latex_unsupported_math_fails_before_problem_ir( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Unsupported math-bearing input is rejected at the frontend boundary.""" - problem_ir_calls: list[dict] = [] - - def _sentinel_from_context(ctx: dict) -> ProblemIR: - problem_ir_calls.append(ctx) - raise AssertionError("ProblemIR.from_context must not run for unsupported math") - - monkeypatch.setattr(ProblemIR, "from_context", _sentinel_from_context) - - with pytest.raises(FrontendSemanticError, match="before IR construction"): - compile_latex(_UNSUPPORTED_MATH_SOURCE, profile="mvp") - - assert problem_ir_calls == [] diff --git a/packages/mechdsl-core/tests/plan_tests/fgram/test_p4_1.py b/packages/mechdsl-core/tests/plan_tests/fgram/test_p4_1.py deleted file mode 100644 index c7c438b..0000000 --- a/packages/mechdsl-core/tests/plan_tests/fgram/test_p4_1.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Focused fgram Phase 4 P4-1 coverage.""" - -from __future__ import annotations - -import pytest - -from mechdsl.frontend.math_parser import ( - MathParseError, - extract_equations, - parse_math, -) -from mechdsl.symbolic.bridge import convert_equations - - -@pytest.mark.integration -def test_p4_1_equation_bridge_preserves_line_role_and_indices() -> None: - source = r""" -% declare FDD --dim 3 -% declare SDD --dim 3 -% declare PDD --dim 3 -P_{i I} = F_{i J} S_{J I} -""" - result = parse_math(source) - equations = convert_equations(result.equations) - - assert len(equations) == 1 - equation = equations[0] - assert equation.lhs == "P_{i I}" - assert equation.rhs == "F_{i J} S_{J I}" - assert equation.free_indices == ("I", "i") - assert equation.contracted_indices == ("J",) - assert equation.role == "stress_measure" - assert equation.source_line == 5 - - -@pytest.mark.integration -def test_p4_1_invalid_material_index_on_spatial_stress_is_actionable() -> None: - source = "% declare sigmaDD --dim 3\n% declare FDD --dim 3\n\\sigma_{i I} = F_{i I}\n" - - with pytest.raises(MathParseError) as excinfo: - parse_math(source) - - message = str(excinfo.value) - assert "spatial tensor" in message - assert "post_recovery_plan Phase 4" in message - - -@pytest.mark.integration -def test_p4_1_non_letter_lhs_classifies_without_error() -> None: - """A LHS that does not begin with a letter must not crash role inference.""" - (equation,) = extract_equations("2 a = b + c\n") - - assert equation.role == "auxiliary_definition" - - -@pytest.mark.integration -def test_p4_1_directive_keyword_overrides_symbol_heuristic() -> None: - """A role directive in the block is authoritative over LHS-symbol heuristics.""" - # LHS ``S`` would heuristically be a stress measure, but the block carries a - # strain_energy directive, which must win. - (equation,) = extract_equations("% mechanics strain_energy\nS = a + b\n") - - assert equation.role == "strain_energy" diff --git a/packages/mechdsl-core/tests/plan_tests/fgram/test_p5_1.py b/packages/mechdsl-core/tests/plan_tests/fgram/test_p5_1.py deleted file mode 100644 index ab476d9..0000000 --- a/packages/mechdsl-core/tests/plan_tests/fgram/test_p5_1.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Focused fgram Phase 5 P5-1 coverage: LaTeX semantics to Mechanics IR.""" - -from __future__ import annotations - -import json - -import pytest - -from mechdsl.frontend import parse, parse_with_math -from mechdsl.ir.mechanics_ir import ( - ElementType, - Formulation, - ProblemIR, - ResidualContract, -) - -# A directive-only LaTeX source carrying fields, a constitutive role, weak-form -# (residual) metadata, and both a Dirichlet and a Neumann boundary. This is the -# canonical "what the compiler understood" input for P5-1. -_LATEX_SOURCE = r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics field u --type vector --space H1 --order 1 -% mechanics constitutive Psi --strain_energy -% mechanics constitutive S --pk2 -% mechanics weak_form internal_residual --residual -% mechanics bc dirichlet --boundary fix_base --value 0.0 -% mechanics bc neumann --boundary load_top --traction "0 0 -1000" -""" - -# The same directive core plus a real ``$...$`` math block whose equation the -# math parser classifies as an auxiliary definition (LHS symbol ``A`` is not a -# recognised constitutive symbol and no directive keyword surrounds it). This -# drives the equation path through the *real* frontend (``parse_with_math``), -# not a hand-built ``ctx["math"]`` shape. -_LATEX_SOURCE_WITH_MATH = _LATEX_SOURCE + ( - "% declare FUU --dim 3\n% declare AUU --dim 3\n$A^{i I} = F^{i I}$\n" -) - - -class TestTaskP5_1: - """Tests for Task P5-1: LaTeX semantics to Mechanics IR. AC covered: 1, 2.""" - - @pytest.mark.integration - def test_latex_semantics_constructs_mvp_stable_problem_ir(self) -> None: - """Verifies: LaTeX with fields, constitutive equations, weak-form metadata, - and BCs constructs a valid MVP-stable ProblemIR via the LaTeX-semantic - constructor. AC: A LaTeX source ... constructs a valid MVP-stable ProblemIR. - Passes when: from_latex_semantics (or equivalent adapter) returns a ProblemIR - that passes MVP-subset validation.""" - ctx = parse(_LATEX_SOURCE) - ir = ProblemIR.from_latex_semantics(ctx) - - # Core MVP-stable configuration is honoured. - assert ir.dim == 3 - assert ir.formulation is Formulation.TOTAL_LAGRANGIAN - assert ir.element_type is ElementType.HEX8 - assert ir.material.model == "svk" - assert ir.is_mvp_stable() - ir.assert_mvp_stable() # must not raise - - # LaTeX-derived fields became FieldSpec entries. - assert tuple(f.name for f in ir.fields) == ("u",) - assert ir.fields[0].kind == "vector" - - # Weak-form (residual) declaration became a ResidualContract. - assert isinstance(ir.residual_contract, ResidualContract) - assert ir.residual_contract.weak_form_label == "internal_residual" - - # Both boundaries flowed through. - assert {bc.name for bc in ir.boundaries} == {"fix_base", "load_top"} - - # Convergence: from_latex_semantics and from_context agree on the - # MVP-stable core (the LaTeX path only *adds* enrichment). - core = ProblemIR.from_context(ctx) - assert ir.dim == core.dim - assert ir.formulation == core.formulation - assert ir.element_type == core.element_type - assert ir.material.model == core.material.model - assert {bc.name for bc in ir.boundaries} == {bc.name for bc in core.boundaries} - - @pytest.mark.integration - def test_serialized_ir_records_source_semantic_metadata(self) -> None: - """Verifies: the serialized ProblemIR exposes enough LaTeX-derived semantic - source data to explain what the compiler understood. AC: The serialized - ProblemIR records enough semantic source data. Passes when: to_dict output - includes the LaTeX-derived semantic metadata for review and golden diffs.""" - ctx = parse(_LATEX_SOURCE) - ir = ProblemIR.from_latex_semantics(ctx) - d = ir.to_dict() - - # to_dict stays JSON-primitive and round-trip-safe. - json_str = json.dumps(d) - assert isinstance(json_str, str) - - # The serialized IR carries the LaTeX-derived semantic record. - assert "latex_semantics" in d - semantics = d["latex_semantics"] - assert semantics is not None - - # Constitutive roles the compiler understood are recorded by symbol. - constitutive = {entry["symbol"]: entry["role"] for entry in semantics["constitutive"]} - assert constitutive == {"Psi": "strain_energy", "S": "pk2"} - - # The declared fields and the weak-form label are recorded too. - assert "u" in semantics["fields"] - assert semantics["weak_form_label"] == "internal_residual" - - # Round-trips losslessly through from_dict back to a serialized form. - restored = ProblemIR.from_dict(json.loads(json_str)) - assert restored.to_dict() == d - - @pytest.mark.integration - def test_real_math_block_populates_equation_semantics(self) -> None: - """The real frontend (``parse_with_math``) populates ``ctx['math']['equations']`` - from a ``$...$`` block, and ``from_latex_semantics`` reads that real key — - no hand-built ctx/math shape. Confirms the end-to-end plumbing of P5-1.""" - ctx = parse_with_math(_LATEX_SOURCE_WITH_MATH) - # The frontend actually emits the equation semantics under the real key. - assert "math" in ctx - assert ctx["math"]["equations"], "parse_with_math must populate math['equations']" - - ir = ProblemIR.from_latex_semantics(ctx) - equations = ir.to_dict()["latex_semantics"]["equations"] - roles = {e["lhs"]: e["role"] for e in equations} - # The auxiliary-definition role (no committed physics) is recorded as - # ``auxiliary`` — never inferred into a real constitutive role. Per the - # Phase 4 handoff, ``unknown`` / ``None`` are downgraded the same way. - assert "A^{i I}" in roles - assert roles["A^{i I}"] == "auxiliary" - - @pytest.mark.integration - def test_unknown_role_is_treated_as_auxiliary_not_inferred(self) -> None: - """Heeds the Phase 4 handoff: a role of ``unknown`` / ``None`` must not be - inferred into a constitutive role; it is recorded as auxiliary. Drives the - downgrade through real ``parse_with_math`` output rather than a synthetic - ctx/math shape — the equation classifier labels ``A^{i I} = F^{i I}`` as a - non-committed (auxiliary) definition.""" - ctx = parse_with_math(_LATEX_SOURCE_WITH_MATH) - # The real classifier produced a non-committed role for this equation. - raw_roles = {eq["lhs"]: eq["role"] for eq in ctx["math"]["equations"]} - assert raw_roles["A^{i I}"] in {"unknown", "auxiliary_definition", None, ""} - - ir = ProblemIR.from_latex_semantics(ctx) - equations = ir.to_dict()["latex_semantics"]["equations"] - roles = {e["lhs"]: e["role"] for e in equations} - # Non-committed role is downgraded to auxiliary — never promoted. - assert roles["A^{i I}"] == "auxiliary" diff --git a/packages/mechdsl-core/tests/plan_tests/fgram/test_p6_1.py b/packages/mechdsl-core/tests/plan_tests/fgram/test_p6_1.py deleted file mode 100644 index 04bb5ec..0000000 --- a/packages/mechdsl-core/tests/plan_tests/fgram/test_p6_1.py +++ /dev/null @@ -1,360 +0,0 @@ -"""Focused fgram Phase 6 P6-1 coverage: Taichi emission from LaTeX-derived IR. - -Implements the P6-1 acceptance contract (red->green from the scaffolded stubs): -an equation-bearing LaTeX source must drive ``compile_latex`` through the -existing lowering -> einsum -> Taichi printer path and emit code that matches -the handwritten references, while built-in material-name paths and JIT budget -limits remain intact. The LaTeX-semantic constructor additionally threads a -source-role metadata record (``latex_semantics``) onto the artifact bundle. - -Entry point: ``mechdsl.compile_latex(source) -> ArtifactBundle`` (the canonical -``from_latex`` facade). References live in ``tests/ref/ref_hex8_elastic.py`` (SVK) -and ``tests/ref/ref_hex8_plastic.py`` (J2). - -Convention authority: ``dev/design_docs/07-CONVENTIONS.md`` — Voigt ordering -``[xx, yy, zz, xy, xz, yz]`` with unscaled shears, tension-positive stress, and -JIT budget 512/func, 2000/kernel, 5000 absolute ceiling. -""" - -from __future__ import annotations - -import uuid as _uuid -from typing import TYPE_CHECKING - -import numpy as np -import pytest -from tests._e2e_helpers import _import_generated_module - -from mechdsl import compile_latex -from mechdsl.codegen.einsum_optimizer import ( - MAX_LINES_ABSOLUTE, - MAX_LINES_TI_FUNC, - MAX_LINES_TI_KERNEL, - Tier, -) - -if TYPE_CHECKING: - from pathlib import Path - -# Whole-file marker: every P6-1 case traverses the LaTeX -> facade -> Taichi path. -pytestmark = pytest.mark.from_latex - - -# --------------------------------------------------------------------------- -# Material parameters — match tests/ref/ref_hex8_elastic.py and -# tests/test_e2e_plastic.py so generated vs reference comparisons are apples -# to apples. -# --------------------------------------------------------------------------- - -E_YOUNG = 200.0e3 -NU = 0.3 -SIGMA_Y0 = 200.0 -K_HARD = 100.0 -N_HARD = 0.3 -LAM = E_YOUNG * NU / ((1 + NU) * (1 - 2 * NU)) -MU = E_YOUNG / (2 * (1 + NU)) - - -# --------------------------------------------------------------------------- -# Equation-bearing LaTeX sources. Each carries the directive core *plus* -# explicit field / constitutive-role / weak-form declarations so the emitted -# bundle reflects LaTeX equation semantics rather than only a built-in -# material name. These are the P6-1 acceptance targets (Phase 6 context: -# "SVK and J2 equation-bearing LaTeX sources are the required acceptance -# targets"). -# --------------------------------------------------------------------------- - -SVK_EQUATION_SOURCE = r""" -% MechDSL P6-1 acceptance — equation-bearing SVK Hex8 cantilever. -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics field u --type vector --space H1 --order 1 -% mechanics constitutive Psi --strain_energy -% mechanics constitutive S --pk2 -% mechanics weak_form internal_residual --residual -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "1 0 0" --surface x1 -""" - -J2_EQUATION_SOURCE = r""" -% MechDSL P6-1 acceptance — equation-bearing J2 power-law plasticity Hex8. -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material j2_power_law --E 200e3 --nu 0.3 --sigma_y0 200 --K 100 --n 0.3 -% mechanics field u --type vector --space H1 --order 1 -% mechanics constitutive S --pk2 -% mechanics weak_form internal_residual --residual -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "t_bar" -""" - -# Built-in material-name path: directive-only, no field/constitutive/weak-form -# enrichment. This is the legacy shape — must still compile unchanged. -BUILTIN_SVK_SOURCE = r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "1 0 0" --surface x1 -""" - -BUILTIN_J2_SOURCE = r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material j2_power_law --E 200e3 --nu 0.3 --sigma_y0 200 --K 100 --n 0.3 -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "t_bar" -""" - - -def _gen_name(stem: str) -> str: - """Unique-per-invocation module name (avoids importlib cache collisions).""" - return f"gen_p6_1_{stem}_{_uuid.uuid4().hex}" - - -class TestTaskP6_1: - """Tests for Task P6-1: Taichi emission from LaTeX-derived IR. AC covered: 1-4.""" - - @pytest.mark.e2e - @pytest.mark.slow - def test_svk_equation_source_emits_taichi_matching_reference(self, tmp_path: Path) -> None: - """AC1: an SVK *equation-bearing* LaTeX source compiles through - ``compile_latex`` and the emitted Taichi residual/tangent matches the - handwritten Hex8 elastic reference within spec tolerance (< 1e-10). - - Distinct from the recovery-plan ``test_p7_2`` SVK acceptance test: the - source here carries explicit ``field`` / ``constitutive`` / ``weak_form`` - equation declarations, and the test additionally asserts the emitted - bundle carries the source-role metadata that links code to equation - roles (the P6-1 deliverable).""" - from tests.ref.ref_hex8_elastic import generate_hex8_mesh, solve_elastic - - # 1. Canonical entry point: equation-bearing LaTeX -> ArtifactBundle. - bundle = compile_latex(SVK_EQUATION_SOURCE, profile="mvp") - assert bundle.emitted_source, "compile_latex returned an empty source" - assert "import taichi as ti" in bundle.emitted_source - assert "@ti.kernel" in bundle.emitted_source - assert bundle.element_ir_summary["element_type"] == "hex8" - assert bundle.element_ir_summary["formulation"] == "total_lagrangian" - - # Source-role metadata (P6-1 deliverable): the equation roles the - # compiler understood are recorded on the bundle so emitted sections - # are traceable back to source. The constitutive contract still drives - # codegen — this record is the explanatory link, not the contract. - latex_semantics = bundle.problem_ir_dict.get("latex_semantics") - assert latex_semantics is not None, ( - "equation-bearing SVK source must attach latex_semantics to the bundle" - ) - roles = {e["symbol"]: e["role"] for e in latex_semantics["constitutive"]} - assert roles == {"Psi": "strain_energy", "S": "pk2"} - assert latex_semantics["weak_form_label"] == "internal_residual" - assert "u" in latex_semantics["fields"] - - # Neumann directive surfaces as the emitted f_ext init kernel. - assert bundle.f_ext_kernel is not None - assert "init_f_ext_from_neumann_load" in bundle.f_ext_kernel - - # 2. Import the emitted module under Taichi JIT. - merged = bundle.emitted_source + "\n\n" + bundle.f_ext_kernel - mod = _import_generated_module(merged, tmp_path, _gen_name("svk")) - assert hasattr(mod, "compute_internal_force") - assert hasattr(mod, "tangent_matvec") - assert hasattr(mod, "newton_solve") - - # 3. Minimal canonical 1-element unit cube. - coords, conn = generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - n_nodes = coords.shape[0] - n_elem = conn.shape[0] - mod.allocate_fields(n_nodes, n_elem) - mod.x_ref.from_numpy(coords) - for e in range(n_elem): - for a in range(8): - mod.elem_nodes[e, a] = int(conn[e, a]) - - # 4. BCs/loads driven by the LaTeX directives. - bc_mask = np.zeros((n_nodes, 3), dtype=bool) - bc_values = np.zeros((n_nodes, 3), dtype=np.float64) - left = np.where(np.abs(coords[:, 0]) < 1e-12)[0] - bc_mask[left, :] = True - bc_dofs = np.where(bc_mask.ravel())[0].astype(np.int64) - - right = np.where(np.abs(coords[:, 0] - 1.0) < 1e-12)[0].astype(np.int32) - f_factor = 1.0 / float(len(right)) # face_area (=1.0) / n_face_nodes - f_ext = np.zeros((n_nodes, 3), dtype=np.float64) - f_ext[right, 0] = 1.0 * f_factor - - # 5. Drive the emitted Newton solver; load via the emitted kernel. - mod.init_f_ext_from_neumann_load(right, f_factor) - n_iters = mod.newton_solve(LAM, MU, bc_dofs=bc_dofs) - u_gen = mod.u.to_numpy() - assert n_iters >= 1 - assert float(np.max(np.abs(u_gen))) > 1e-10, "Generated solution is trivially zero" - - # 6. Reference solve, then compare (07-CONVENTIONS Sec 6: < 1e-10). - u_ref, _ = solve_elastic(coords, conn, LAM, MU, bc_mask, bc_values, f_ext) - max_diff = float(np.max(np.abs(u_gen - u_ref))) - assert max_diff < 1e-10, ( - "SVK equation-bearing LaTeX-to-solution path does not match reference " - f"within 07-CONVENTIONS Sec 6 tolerance: max |u_gen - u_ref| = " - f"{max_diff:.3e} (>= 1e-10)" - ) - - @pytest.mark.e2e - @pytest.mark.slow - def test_j2_equation_source_emits_taichi_matching_reference(self, tmp_path: Path) -> None: - """AC2: a J2 *equation-bearing* LaTeX source compiles through - ``compile_latex`` and the emitted Taichi matches the existing J2 - reference path (radial return) within tolerance. - - Mirrors ``test_e2e_plastic`` displacement-controlled load stepping, but - the ProblemIR's *sole* source is the equation-bearing LaTeX string — - not a programmatic ``_make_j2_problem_ir`` shortcut.""" - from tests.ref.ref_hex8_elastic import generate_hex8_mesh - from tests.ref.ref_hex8_plastic import solve_plastic - from tests.test_e2e_plastic import _load_mesh_into_module, _run_load_stepping - - from mechdsl.symbolic.models.j2_power_law import J2PowerLawMaterial - - # 1. Equation-bearing J2 LaTeX -> ArtifactBundle. - bundle = compile_latex(J2_EQUATION_SOURCE, profile="mvp") - assert bundle.emitted_source, "compile_latex returned an empty J2 source" - assert "import taichi as ti" in bundle.emitted_source - # J2 emission must carry the radial-return path (existing infra reused). - assert "radial_return" in bundle.emitted_source, ( - "J2 emitted code must reuse the existing radial-return path" - ) - - # Source-role metadata records the J2 PK2 stress role. - latex_semantics = bundle.problem_ir_dict.get("latex_semantics") - assert latex_semantics is not None - roles = {e["symbol"]: e["role"] for e in latex_semantics["constitutive"]} - assert roles == {"S": "pk2"} - assert latex_semantics["weak_form_label"] == "internal_residual" - # Material model is the codegen contract (authoritative, not the record). - assert bundle.problem_ir_dict["material"]["model"] == "j2_power_law" - - # 2. Import under Taichi JIT. - mod = _import_generated_module(bundle.emitted_source, tmp_path, _gen_name("j2")) - assert hasattr(mod, "compute_internal_force") - assert hasattr(mod, "alpha") - - # 3. Setup 1-element mesh (matches test_e2e_plastic fixture). - coords, conn = generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - n_nodes = coords.shape[0] - left_nodes = np.where(np.abs(coords[:, 0]) < 1e-12)[0] - right_nodes = np.where(np.abs(coords[:, 0] - 1.0) < 1e-12)[0] - bc_mask = np.zeros((n_nodes, 3), dtype=bool) - bc_mask[left_nodes, :] = True - _load_mesh_into_module(mod, coords, conn) - - # 4. Displacement-controlled load stepping past yield (10x yield strain). - total_disp = 0.01 - n_steps = 5 - u_gen, _residuals, _alpha = _run_load_stepping( - mod, coords, bc_mask, right_nodes, total_disp, n_steps - ) - - # 5. Reference plastic solve with the same setup. - mat = J2PowerLawMaterial(E=E_YOUNG, nu=NU, sigma_y0=SIGMA_Y0, K=K_HARD, n=N_HARD) - bc_mask_ref = bc_mask.copy() - bc_mask_ref[right_nodes, 0] = True - bc_values_ref = np.zeros((n_nodes, 3), dtype=np.float64) - bc_values_ref[right_nodes, 0] = total_disp - f_ext_ref = np.zeros((n_nodes, 3), dtype=np.float64) - u_ref, _hist, _res = solve_plastic( - coords, - conn, - mat, - bc_mask_ref, - bc_values_ref, - f_ext_ref, - n_steps=n_steps, - tol=1e-8, - max_iter=50, - ) - - # 6. Compare (07-CONVENTIONS Sec 6: < 1e-10; observed ~machine eps). - max_diff = float(np.max(np.abs(u_gen - u_ref))) - assert max_diff < 1e-10, ( - "J2 equation-bearing LaTeX path does not match the reference within " - f"07-CONVENTIONS Sec 6 tolerance: max |u_gen - u_ref| = {max_diff:.3e}" - ) - # Plastic deformation actually occurred (not a trivial elastic match). - assert float(np.max(mod.alpha.to_numpy())) > 1e-6, ( - "Expected plastic deformation (alpha > 0) past yield" - ) - - @pytest.mark.e2e - @pytest.mark.slow - def test_builtin_material_path_compatible_and_within_jit_budget(self, tmp_path: Path) -> None: - """AC3+AC4: built-in material-name LaTeX paths still emit equivalent - Taichi after the equation-driven path lands, and generated code stays - within the JIT budget (07-CONVENTIONS: 512/func, 2000/kernel, 5000 - ceiling). - - - Equation-bearing and built-in sources must emit *identical* Taichi - source for the same physics (the latex_semantics record is additive - and does not perturb codegen). - - Every contraction plan must classify into Tier 1 or Tier 2 (within - the per-``@ti.func`` budget), and the summed estimated lines must - stay under the kernel and absolute ceilings.""" - # --- AC4: built-in material-name path remains compatible --- - builtin_svk = compile_latex(BUILTIN_SVK_SOURCE, profile="mvp") - eqn_svk = compile_latex(SVK_EQUATION_SOURCE, profile="mvp") - assert builtin_svk.emitted_source, "built-in SVK path emitted no source" - # Equation-driven and built-in paths emit byte-identical Taichi for the - # same physics — the equation path only *adds* latex_semantics metadata. - assert eqn_svk.emitted_source == builtin_svk.emitted_source, ( - "equation-bearing SVK and built-in SVK must emit identical Taichi; " - "the latex_semantics record must not perturb codegen" - ) - # The built-in (directive-only) path carries no equation enrichment. - assert builtin_svk.problem_ir_dict.get("latex_semantics") is None, ( - "directive-only built-in path must not fabricate latex_semantics" - ) - # Built-in path still compiles and imports under Taichi JIT. - merged = builtin_svk.emitted_source + "\n\n" + (builtin_svk.f_ext_kernel or "") - mod = _import_generated_module(merged, tmp_path, _gen_name("builtin_svk")) - assert hasattr(mod, "compute_internal_force") - - builtin_j2 = compile_latex(BUILTIN_J2_SOURCE, profile="mvp") - eqn_j2 = compile_latex(J2_EQUATION_SOURCE, profile="mvp") - assert eqn_j2.emitted_source == builtin_j2.emitted_source, ( - "equation-bearing J2 and built-in J2 must emit identical Taichi" - ) - - # --- AC3: JIT budget compliance across all four bundles --- - for label, bundle in ( - ("builtin_svk", builtin_svk), - ("eqn_svk", eqn_svk), - ("builtin_j2", builtin_j2), - ("eqn_j2", eqn_j2), - ): - plans = bundle.contraction_plans - assert plans, f"{label}: bundle carries no contraction plans to budget-check" - for plan in plans: - # Tier 1 (native) and Tier 2 (emitted ti.func) are within the - # per-function budget; Tier 3 means a contraction overflowed - # 512 lines and was restructured — none expected for MVP Hex8. - assert plan.tier in (Tier.TIER_1.value, Tier.TIER_2.value), ( - f"{label}: contraction {plan.einsum_string!r} classified Tier " - f"{plan.tier} (> Tier 2) — exceeds per-@ti.func budget of " - f"{MAX_LINES_TI_FUNC} lines" - ) - # The emitted source itself must stay under the absolute ceiling. - total_lines = bundle.emitted_source.count("\n") + 1 - assert total_lines <= MAX_LINES_ABSOLUTE, ( - f"{label}: emitted source has {total_lines} lines > absolute " - f"ceiling {MAX_LINES_ABSOLUTE}" - ) - # Sanity: a single residual+tangent kernel pair should sit well - # under the per-kernel budget for a 1-element Hex8 problem. - assert total_lines <= MAX_LINES_TI_KERNEL * 3, ( - f"{label}: emitted source {total_lines} lines is implausibly large " - f"for MVP Hex8 (kernel budget {MAX_LINES_TI_KERNEL})" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/fgram/test_p7_1.py b/packages/mechdsl-core/tests/plan_tests/fgram/test_p7_1.py deleted file mode 100644 index dbe58f7..0000000 --- a/packages/mechdsl-core/tests/plan_tests/fgram/test_p7_1.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Focused fgram Phase 7 P7-1 coverage: verification, review, and closure. - -Closure is an evidence package, not a prose victory lap (Phase 7 context): -the closure review must summarize grammar coverage and name remaining -rejected/deferred constructs; the headline example must run through the public -``compile_latex`` facade on an equation-bearing source; and remaining -unsupported grammar must raise cleanly with a plan-phase pointer. - -These tests pin the P7-1 closure contract against the *real artifacts* (the -review doc, the example script, the public API) — not against prose. Each -maps to one acceptance criterion. - -Convention authority: ``dev/design_docs/07-CONVENTIONS.md``. -""" - -from __future__ import annotations - -import runpy -from pathlib import Path - -import pytest - -from mechdsl import compile_latex -from mechdsl.symbolic.convected import UnsupportedError - -# Repo root: this file is packages/mechdsl-core/tests/plan_tests/fgram/. -_REPO_ROOT = Path(__file__).resolve().parents[5] -_CLOSURE_REVIEW = _REPO_ROOT / "dev" / "reviews" / "fgram_closure_2026_05.md" -_EQUATION_EXAMPLE = _REPO_ROOT / "dev" / "examples" / "run_compile_latex_equation.py" - -# Equation-bearing source (mirrors the headline example / P6-1 acceptance): -# directive core PLUS field / constitutive-role / weak-form declarations. -EQUATION_SOURCE = r""" -% MechDSL P7-1 closure — equation-bearing SVK Hex8 cantilever. -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics field u --type vector --space H1 --order 1 -% mechanics constitutive Psi --strain_energy -% mechanics constitutive S --pk2 -% mechanics weak_form internal_residual --residual -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "1 0 0" --surface x1 -""" - - -class TestTaskP7_1: - """Tests for Task P7-1: verification, review, and closure. AC covered: 1-3.""" - - @pytest.mark.regression - def test_closure_review_summarizes_coverage_and_rejections(self) -> None: - """AC1: a closure review exists under ``dev/reviews/`` that summarizes - fgram grammar coverage phase-by-phase and names the remaining - rejected/deferred constructs with rationale. Passes when: the review - doc exists, marks every phase P1-P7 as done (no task left implicit), - and carries an explicit remaining-unsupported-constructs section.""" - assert _CLOSURE_REVIEW.is_file(), ( - f"closure review missing at {_CLOSURE_REVIEW} — AC1 requires a " - "dev/reviews closure artifact" - ) - text = _CLOSURE_REVIEW.read_text() - - # Every phase task must appear and be marked done (AC1: all tasks done - # or explicitly deferred). fgram has zero deferred tasks. - for task in ("P1-1", "P2-1", "P3-1", "P4-1", "P5-1", "P6-1", "P7-1"): - assert task in text, f"closure review must reference {task}" - - # Coverage summary present. - assert "Grammar coverage" in text or "grammar coverage" in text, ( - "closure review must summarize grammar coverage" - ) - - # Remaining-unsupported-constructs map present, with rejection AND - # deferral language and at least the canonical Plan B pointers. - assert "Remaining unsupported constructs" in text - assert "deferred" in text.lower() - assert "Plan B" in text, "remaining-unsupported map must cite the Plan B deferral pointers" - # Clean-rejection mechanism named (not just prose). - assert "UnsupportedError" in text - - @pytest.mark.regression - def test_equation_bearing_example_demonstrates_product_story(self) -> None: - """AC2: a user-facing example demonstrates the product story — - equation-bearing LaTeX compiled to Taichi through the public API - (``compile_latex``). Passes when: the example file exists and the same - public path it exercises emits Taichi from an equation-bearing source, - attaching the LaTeX-derived ``latex_semantics`` record.""" - assert _EQUATION_EXAMPLE.is_file(), ( - f"headline equation-bearing example missing at {_EQUATION_EXAMPLE}" - ) - # The example must drive the PUBLIC facade, not an internal constructor. - src = _EQUATION_EXAMPLE.read_text() - assert "compile_latex" in src, "example must use the public compile_latex facade" - assert "from_context" not in src and "ProblemIR(" not in src, ( - "headline example must go through compile_latex, not internal constructors" - ) - - # Exercise the same public path the example runs on an equation-bearing - # source: equation roles -> bundle -> emitted Taichi. - bundle = compile_latex(EQUATION_SOURCE, profile="mvp") - assert "import taichi as ti" in bundle.emitted_source - assert "@ti.kernel" in bundle.emitted_source - - # The LaTeX-derived equation semantics ride on the bundle (the fgram - # product story: code traceable back to source equation roles). - semantics = bundle.problem_ir_dict.get("latex_semantics") - assert semantics is not None, ( - "equation-bearing source must attach latex_semantics (LaTeX-derived)" - ) - roles = {e["symbol"]: e["role"] for e in semantics["constitutive"]} - assert roles == {"Psi": "strain_energy", "S": "pk2"} - assert semantics["weak_form_label"] == "internal_residual" - assert "u" in semantics["fields"] - - @pytest.mark.regression - def test_equation_example_script_runs_through_public_api(self) -> None: - """AC2 (anti-drift): the headline example *executes* end-to-end via - ``runpy`` without raising. The named risk is example drift from the - public API — running the actual script (not a copy) guards it.""" - # runpy executes the script in a fresh namespace; main() is invoked via - # the ``__main__`` guard. Any API drift (renamed facade, changed return - # shape, broken assert in the script) surfaces as an exception here. - runpy.run_path(str(_EQUATION_EXAMPLE), run_name="__main__") - - @pytest.mark.regression - def test_unsupported_grammar_rejected_cleanly_with_phase_pointer(self) -> None: - """AC3: remaining unsupported grammar raises cleanly with a plan-phase - pointer through the public ``compile_latex`` facade — no deep codegen - or runtime failure. Covers representative full-grammar rejections that - the closure review's remaining-unsupported map enumerates.""" - # 2D problem -> Plan B phase B2. - with pytest.raises(UnsupportedError, match=r"Plan B phase B2"): - compile_latex( - r""" -% mechanics dim 2 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 -""", - profile="mvp", - ) - - # Non-Total-Lagrangian formulation -> rejected with a formulation pointer. - with pytest.raises(UnsupportedError, match=r"formulation"): - compile_latex( - r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation eulerian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 -""", - profile="mvp", - ) - - # Non-MVP profile -> ValueError with a broader-support pointer. - with pytest.raises(ValueError, match=r"profile"): - compile_latex(EQUATION_SOURCE, profile="experimental") diff --git a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/__init__.py b/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/__init__.py deleted file mode 100644 index e69de29..0000000 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 deleted file mode 100644 index 5044137..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-1.py +++ /dev/null @@ -1,84 +0,0 @@ -"""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 deleted file mode 100644 index 0518ab0..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-2.py +++ /dev/null @@ -1,97 +0,0 @@ -"""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 deleted file mode 100644 index 8fd0db7..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P1-3.py +++ /dev/null @@ -1,51 +0,0 @@ -"""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 deleted file mode 100644 index 282dbd6..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-1.py +++ /dev/null @@ -1,77 +0,0 @@ -"""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 deleted file mode 100644 index eaaa571..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-2.py +++ /dev/null @@ -1,141 +0,0 @@ -"""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 deleted file mode 100644 index 2afee22..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-3.py +++ /dev/null @@ -1,99 +0,0 @@ -"""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 deleted file mode 100644 index 0a6a512..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P2-4.py +++ /dev/null @@ -1,107 +0,0 @@ -"""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 deleted file mode 100644 index ce596d2..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-1.py +++ /dev/null @@ -1,115 +0,0 @@ -"""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 deleted file mode 100644 index 45d842a..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-2.py +++ /dev/null @@ -1,90 +0,0 @@ -"""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 deleted file mode 100644 index bf7e910..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P3-3.py +++ /dev/null @@ -1,110 +0,0 @@ -"""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 deleted file mode 100644 index af09cb1..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-1.py +++ /dev/null @@ -1,233 +0,0 @@ -"""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 deleted file mode 100644 index f988918..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-2.py +++ /dev/null @@ -1,36 +0,0 @@ -"""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 deleted file mode 100644 index 79b7b98..0000000 --- a/packages/mechdsl-core/tests/plan_tests/mfront_cyclem0/test_P4-3.py +++ /dev/null @@ -1,39 +0,0 @@ -"""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/post_recovery_plan/__init__.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_1.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_1.py deleted file mode 100644 index 214a7a0..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_1.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Tests for Task P1-1: Extend BoundaryCondition IR slot with traction + surface tag.""" - -from __future__ import annotations - -import pytest - -from mechdsl.ir.mechanics_ir import BCType, BoundaryCondition - - -class TestTaskP1_1: - """ - Tests for Task P1-1: Extend BoundaryCondition IR slot. - Acceptance criteria covered: 1, 2, 3 - """ - - @pytest.mark.unit - def test_construct_neumann_with_traction_and_surface_tag(self): - """ - Verifies: BoundaryCondition with bc_type=NEUMANN, vector traction, and surface_tag constructs cleanly. - Acceptance criterion #1: extended IR slot accepts traction + surface_tag. - """ - bc = BoundaryCondition( - name="load", - bc_type=BCType.NEUMANN, - traction=[0.0, 0.0, -1000.0], - surface_tag="top", - ) - assert bc.traction == (0.0, 0.0, -1000.0) - assert bc.surface_tag == "top" - assert bc.effective_surface_tag == "top" - # Round-trip through to_dict / from_dict preserves all new fields. - restored = BoundaryCondition.from_dict(bc.to_dict()) - assert restored.traction == (0.0, 0.0, -1000.0) - assert restored.surface_tag == "top" - - @pytest.mark.unit - def test_neumann_missing_traction_raises_validation(self): - """ - Verifies: Neumann BC with traction=None raises ValueError citing Phase 1. - Acceptance criterion #2: IR validation rejects malformed Neumann. - """ - with pytest.raises(ValueError, match="post_recovery_plan Phase 1"): - BoundaryCondition(name="bad", bc_type=BCType.NEUMANN) - - @pytest.mark.unit - def test_dirichlet_bc_unchanged(self): - """ - Verifies: Dirichlet BC construction is unaffected by the new fields. - Acceptance criterion #3: backward compatibility preserved. - """ - bc = BoundaryCondition( - name="fix", bc_type=BCType.DIRICHLET, components=(0, 1, 2), value=0.0 - ) - assert bc.traction is None - assert bc.surface_tag is None - # Falls back to .name when surface_tag is unset. - assert bc.effective_surface_tag == "fix" - - @pytest.mark.unit - def test_legacy_string_traction_still_supported(self): - """ - Verifies: existing callers passing traction="t_bar" continue to work. - Back-compat with 30+ existing test fixtures across the repo. - """ - bc = BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar") - assert bc.traction == "t_bar" - assert bc.surface_tag is None - assert bc.effective_surface_tag == "load" - - @pytest.mark.unit - def test_traction_vector_wrong_length_raises(self): - """ - Verifies: non-length-3 traction sequence raises with Phase 1 pointer. - """ - with pytest.raises(ValueError, match="length 3"): - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction=[1.0, 2.0]) - - @pytest.mark.unit - def test_traction_vector_non_numeric_raises(self): - """ - Verifies: traction sequence with non-numeric entries raises. - """ - with pytest.raises(ValueError, match="numeric components"): - BoundaryCondition( - name="load", - bc_type=BCType.NEUMANN, - traction=["a", "b", "c"], - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_2.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_2.py deleted file mode 100644 index 4e9d099..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_2.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Tests for Task P1-2: Extend Neumann directive parser.""" - -from __future__ import annotations - -import pytest - -from mechdsl.frontend.directives import ParseError, _mech_boundary -from mechdsl.frontend.parser import parse - - -def _parse_bc(directive_body: str) -> dict: - """Drive the BC directive handler with the parsed-args tuple shape.""" - accum: dict = {} - # Strip leading "boundary " from the body for handler input. - # The handler expects (positional, options) shape. - parts = directive_body.split(maxsplit=1) - name = parts[0] - rest = parts[1] if len(parts) > 1 else "" - # Tokenize options as the parser does (very simple split-on-flags). - options: dict = {} - tokens = [] - in_quote = False - cur = [] - for ch in rest: - if ch == '"': - in_quote = not in_quote - continue - if ch == " " and not in_quote: - if cur: - tokens.append("".join(cur)) - cur = [] - else: - cur.append(ch) - if cur: - tokens.append("".join(cur)) - i = 0 - while i < len(tokens): - tok = tokens[i] - if tok.startswith("--"): - key = tok[2:] - options[key] = tokens[i + 1] if i + 1 < len(tokens) else "" - i += 2 - else: - i += 1 - _mech_boundary(accum, ([name], options), line_no=1) - return accum["boundaries"][0] - - -class TestTaskP1_2: - """Tests for Task P1-2: Neumann directive parser with traction + surface.""" - - @pytest.mark.unit - def test_parse_neumann_with_traction_and_surface(self): - bc = _parse_bc('load --type neumann --traction "0 0 -1000" --surface top') - assert bc["type"] == "neumann" - assert bc["traction"] == [0.0, 0.0, -1000.0] - assert bc["surface_tag"] == "top" - - @pytest.mark.unit - def test_parse_neumann_with_symbolic_traction(self): - bc = _parse_bc("load --type neumann --traction t_bar") - assert bc["traction"] == "t_bar" - assert "surface_tag" not in bc - - @pytest.mark.unit - def test_parse_neumann_malformed_traction_errors(self): - with pytest.raises(ParseError, match="--traction"): - _parse_bc('load --type neumann --traction "0 abc -1000"') - - @pytest.mark.unit - def test_parse_neumann_wrong_arity_traction_errors(self): - with pytest.raises(ParseError, match="3 components"): - _parse_bc('load --type neumann --traction "0 0"') - - @pytest.mark.unit - def test_dirichlet_directive_unchanged(self): - bc = _parse_bc('fix --type dirichlet --components "0 1 2" --value 0') - assert bc["type"] == "dirichlet" - assert bc["components"] == [0, 1, 2] - assert bc["value"] == 0 - - @pytest.mark.unit - def test_full_pipeline_neumann_directive(self): - # End-to-end through parse(): the LaTeX source produces a context - # with the Neumann BC carrying traction vector + surface tag. - latex = ( - "% mechanics dim 3\n" - "% mechanics cell hex8\n" - "% mechanics formulation total_lagrangian\n" - "% mechanics material svk --E 200e3 --nu 0.3\n" - '% mechanics boundary load --type neumann --traction "0 0 -1000" --surface top\n' - ) - ctx = parse(latex) - bcs = ctx.get("boundaries", []) - load_bc = next(b for b in bcs if b["name"] == "load") - assert load_bc["traction"] == [0.0, 0.0, -1000.0] - assert load_bc["surface_tag"] == "top" diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_3.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_3.py deleted file mode 100644 index d9a038b..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_3.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Tests for Task P1-3: Lower Neumann BC to per-node force contributions.""" - -from __future__ import annotations - -import numpy as np -import pytest - -from mechdsl.ir.mechanics_ir import BCType, BoundaryCondition -from mechdsl.lowering.boundary import ( - NodalForceContribution, - lower_neumann, - per_node_contributions, - resolve_traction_vector, -) -from mechdsl.solver.mesh_io import generate_hex8_mesh - - -@pytest.fixture -def unit_cube_mesh(): - """1x1x1 element on a unit cube — face area exactly 1.0.""" - return generate_hex8_mesh(1, 1, 1, Lx=1.0, Ly=1.0, Lz=1.0) - - -@pytest.fixture -def two_cube_mesh(): - """2x2x2 element mesh — multi-element face for aggregation tests.""" - return generate_hex8_mesh(2, 2, 2, Lx=1.0, Ly=1.0, Lz=1.0) - - -class TestTaskP1_3: - """Tests for Task P1-3: Neumann BC lowering to nodal force contributions.""" - - @pytest.mark.unit - def test_uniform_traction_face_total_force(self, unit_cube_mesh): - """ - Uniform traction (0, 0, -1000) on the unit-area z1 face yields a - total nodal force whose sum equals the prescribed traction. - Acceptance criterion #1. - """ - bc = BoundaryCondition( - name="load", - bc_type=BCType.NEUMANN, - traction=(0.0, 0.0, -1000.0), - surface_tag="z1", - ) - nbc = lower_neumann(bc, unit_cube_mesh) - total = nbc.force.sum(axis=0) - np.testing.assert_allclose(total, [0.0, 0.0, -1000.0], rtol=0, atol=1e-9) - - @pytest.mark.unit - def test_non_tagged_surface_zero_contribution(self, unit_cube_mesh): - """ - Nodes outside the tagged surface receive zero contribution. - Acceptance criterion #2. - """ - bc = BoundaryCondition( - name="load", - bc_type=BCType.NEUMANN, - traction=(0.0, 0.0, -1000.0), - surface_tag="z1", - ) - nbc = lower_neumann(bc, unit_cube_mesh) - z1_nodes = unit_cube_mesh.boundary_tags["z1"] - mask = np.ones(unit_cube_mesh.n_nodes, dtype=bool) - mask[z1_nodes] = False - np.testing.assert_array_equal(nbc.force[mask], 0.0) - - @pytest.mark.unit - def test_index_convention_lowercase_spatial(self, unit_cube_mesh): - """ - Traction indices are spatial (lowercase i,j,k) per 07-CONVENTIONS: - the returned force array has shape (n_nodes, 3) with components - ordered (x, y, z). A pure-x traction must produce only x-component - forces. - Acceptance criterion #3. - """ - bc = BoundaryCondition( - name="load", - bc_type=BCType.NEUMANN, - traction=(500.0, 0.0, 0.0), - surface_tag="x1", - ) - nbc = lower_neumann(bc, unit_cube_mesh) - # y and z components everywhere zero — no spurious cross-coupling. - np.testing.assert_array_equal(nbc.force[:, 1], 0.0) - np.testing.assert_array_equal(nbc.force[:, 2], 0.0) - # Total x-force matches prescribed traction. - np.testing.assert_allclose(nbc.force[:, 0].sum(), 500.0, rtol=0, atol=1e-9) - - @pytest.mark.unit - def test_multi_face_aggregation(self, two_cube_mesh): - """ - A surface tag covering multiple element faces aggregates per-node - contributions correctly: total force on z1 in the 2x2x2 mesh still - equals the prescribed traction. - Test plan case: multi-face aggregation. - """ - bc = BoundaryCondition( - name="top_load", - bc_type=BCType.NEUMANN, - traction=(0.0, 0.0, -2000.0), - surface_tag="z1", - ) - nbc = lower_neumann(bc, two_cube_mesh) - np.testing.assert_allclose(nbc.force.sum(axis=0), [0.0, 0.0, -2000.0], rtol=0, atol=1e-9) - - @pytest.mark.unit - def test_per_node_contributions_sparse_list(self, unit_cube_mesh): - """ - per_node_contributions returns one entry per face node and zero - entries for interior nodes. - """ - bc = BoundaryCondition( - name="load", - bc_type=BCType.NEUMANN, - traction=(0.0, 0.0, -1000.0), - surface_tag="z1", - ) - contribs = per_node_contributions(bc, unit_cube_mesh) - z1_nodes = set(unit_cube_mesh.boundary_tags["z1"].tolist()) - assert {c.node_id for c in contribs} == z1_nodes - # Each entry is a NodalForceContribution with 3-tuple force. - for c in contribs: - assert isinstance(c, NodalForceContribution) - assert len(c.force) == 3 - - @pytest.mark.unit - def test_surface_tag_falls_back_to_name(self, unit_cube_mesh): - """ - When surface_tag is None the BC's `name` is used as the surface - identifier (effective_surface_tag fallback from P1-1). - """ - bc = BoundaryCondition( - name="z1", # name doubles as surface tag - bc_type=BCType.NEUMANN, - traction=(0.0, 0.0, -1000.0), - ) - nbc = lower_neumann(bc, unit_cube_mesh) - np.testing.assert_allclose(nbc.force.sum(axis=0), [0.0, 0.0, -1000.0], rtol=0, atol=1e-9) - - @pytest.mark.unit - def test_dirichlet_bc_rejected(self, unit_cube_mesh): - """ - Lowering a Dirichlet BC raises — only Neumann BCs lower to nodal - forces. - """ - bc = BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET) - with pytest.raises(ValueError, match="NEUMANN"): - lower_neumann(bc, unit_cube_mesh) - - @pytest.mark.unit - def test_symbolic_traction_requires_registry(self, unit_cube_mesh): - """ - A symbolic-string traction (legacy "t_bar" form) must be resolved - through a registry, otherwise lowering raises with a clear message. - """ - bc = BoundaryCondition( - name="load", - bc_type=BCType.NEUMANN, - traction="t_bar", - surface_tag="z1", - ) - with pytest.raises(ValueError, match="t_bar"): - lower_neumann(bc, unit_cube_mesh) - # Supplying the registry resolves the symbol. - registry = {"t_bar": (0.0, 0.0, -250.0)} - nbc = lower_neumann(bc, unit_cube_mesh, traction_registry=registry) - np.testing.assert_allclose(nbc.force.sum(axis=0), [0.0, 0.0, -250.0], rtol=0, atol=1e-9) - - @pytest.mark.unit - def test_resolve_traction_vector_from_tuple(self): - """resolve_traction_vector returns a numpy array for tuple input.""" - bc = BoundaryCondition( - name="load", - bc_type=BCType.NEUMANN, - traction=(1.0, 2.0, 3.0), - ) - v = resolve_traction_vector(bc) - assert isinstance(v, np.ndarray) - np.testing.assert_array_equal(v, [1.0, 2.0, 3.0]) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_4.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_4.py deleted file mode 100644 index 554538a..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_4.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Tests for Task P1-4: Emit f_ext init Taichi kernel from lowered Neumann BC.""" - -from __future__ import annotations - -import ast - -import pytest - -from mechdsl.codegen.taichi_printer import ( - EmissionContext, - NeumannKernelSpec, - _sanitize_kernel_suffix, - emit_neumann_f_ext_kernel, -) - - -@pytest.fixture -def spec_load_z1(): - return NeumannKernelSpec( - bc_name="load", - surface_tag="z1", - per_node_force=(0.0, 0.0, -250.0), - ) - - -def _emit(spec: NeumannKernelSpec) -> tuple[str, str]: - """Return (kernel_function_name, emitted_source).""" - ctx = EmissionContext() - name = emit_neumann_f_ext_kernel(ctx, spec) - return name, ctx.get_source() - - -class TestTaskP1_4: - """Tests for Task P1-4: Taichi @ti.kernel emission for f_ext from Neumann BC.""" - - @pytest.mark.integration - def test_emit_kernel_zeroes_outside_surface(self, spec_load_z1): - """ - Acceptance criterion #1: emitted kernel zeroes f_ext globally then - writes per-node force on tagged surface nodes only. - """ - _, src = _emit(spec_load_z1) - # Global zero loop must precede the surface-write loop. - zero_idx = src.find("f_ext[i][d] = 0.0") - write_idx = src.find("f_ext[nid][0]") - assert 0 < zero_idx < write_idx, ( - f"zero-loop must precede surface-write loop; got zero@{zero_idx}, write@{write_idx}" - ) - # Tagged-node assignment carries the per_node_force tuple values - # (deterministic-format helper strips trailing zeros from round - # numbers, so 250.0 emits as ``-250`` — Taichi coerces the int - # literal to f64 at the assignment site). - assert "f_ext[nid][2] = -250" in src - - @pytest.mark.integration - def test_emitted_source_is_valid_python(self, spec_load_z1): - """ - Acceptance criterion #2 (precondition): the emitted source must - parse as Python — confirms the kernel signature and body are - syntactically clean before any Taichi-side compile. - """ - _, src = _emit(spec_load_z1) - # Wrap in a dummy `import taichi as ti` + `n_nodes/f_ext` placeholders - # so the parser sees the full surface (the kernel body refers to - # `f_ext` which only exists in the full emitted file). - parseable = ("import taichi as ti\nn_nodes = 0\nf_ext = []\n") + src - ast.parse(parseable) - - @pytest.mark.integration - def test_jit_budget_probe_under_kernel_limit(self, spec_load_z1): - """ - Acceptance criterion #2: emitted kernel body line count stays well - under the per-kernel JIT budget (≤ 2000 lines per @ti.kernel from - .claude/CLAUDE.md). The Neumann emitter is fixed-size by design, - so a tight upper bound (50 lines) is the right regression guard. - """ - _, src = _emit(spec_load_z1) - # Slice from "@ti.kernel" to next blank-blank delimiter (the emitter - # writes two empty lines after the kernel). - kernel_lines = [] - capturing = False - for line in src.splitlines(): - if line.strip().startswith("@ti.kernel"): - capturing = True - continue - if capturing: - kernel_lines.append(line) - if line == "" and kernel_lines and kernel_lines[-2:] == ["", ""]: - break - assert len(kernel_lines) <= 50, ( - f"emitted kernel has {len(kernel_lines)} lines, expected ≤ 50 " - "(generous bound vs the 2000-line per-kernel JIT cap)" - ) - - @pytest.mark.integration - def test_kernel_callable_signature_stable(self, spec_load_z1): - """ - Acceptance criterion #3: kernel name follows - ``init_f_ext_from_neumann_`` and signature - accepts a 1-D int32 ndarray of surface node indices. - """ - name, src = _emit(spec_load_z1) - assert name == "init_f_ext_from_neumann_load" - assert f"def {name}(surface_nodes: ti.types.ndarray(dtype=ti.i32, ndim=1)):" in src - - @pytest.mark.integration - def test_kernel_docstring_mentions_surface_tag(self, spec_load_z1): - """ - Emitted docstring documents the surface tag and per-node force — - downstream readers can audit the emission without re-running the - lowering pass. - """ - _, src = _emit(spec_load_z1) - assert "Surface tag: 'z1'" in src - assert "Per-node force" in src - - @pytest.mark.integration - def test_index_partitioning_rule(self, spec_load_z1): - """ - Mesh indices (i over n_nodes, k over surface_nodes) use runtime - ``range`` loops; spatial component (d) uses ``ti.static`` per the - index-partitioning rule (.claude/CLAUDE.md). - """ - _, src = _emit(spec_load_z1) - assert "for i in range(n_nodes):" in src - assert "for k in range(n_surface):" in src - assert "for d in ti.static(range(3)):" in src - - @pytest.mark.integration - def test_sanitize_kernel_suffix_handles_special_chars(self): - """BC names with hyphens/dots get sanitised into valid identifiers.""" - assert _sanitize_kernel_suffix("load-top") == "load_top" - assert _sanitize_kernel_suffix("load.0") == "load_0" - assert _sanitize_kernel_suffix("123load") == "_123load" - assert _sanitize_kernel_suffix("") == "bc" - - @pytest.mark.integration - def test_multiple_specs_emit_distinct_kernels(self): - """Two different BC specs produce two distinct kernels with - independent names; surface tag and per-node force are independent - per emission.""" - ctx = EmissionContext() - n1 = emit_neumann_f_ext_kernel( - ctx, - NeumannKernelSpec( - bc_name="load_top", surface_tag="z1", per_node_force=(0.0, 0.0, -100.0) - ), - ) - n2 = emit_neumann_f_ext_kernel( - ctx, - NeumannKernelSpec( - bc_name="load_side", surface_tag="x1", per_node_force=(50.0, 0.0, 0.0) - ), - ) - src = ctx.get_source() - assert n1 == "init_f_ext_from_neumann_load_top" - assert n2 == "init_f_ext_from_neumann_load_side" - assert n1 in src and n2 in src - assert "f_ext[nid][2] = -100" in src - assert "f_ext[nid][0] = 50" in src diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_5.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_5.py deleted file mode 100644 index 83ae083..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_5.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Tests for Task P1-5: Extend compile_latex façade to surface f_ext kernel.""" - -from __future__ import annotations - -import pytest - -from mechdsl import compile_latex - -_NEUMANN_LATEX = ( - "% mechanics dim 3\n" - "% mechanics cell hex8\n" - "% mechanics formulation total_lagrangian\n" - "% mechanics coord spatial x y z\n" - "% mechanics coord material X Y Z\n" - "% mechanics material svk --E 200e3 --nu 0.3\n" - '% mechanics boundary fix --type dirichlet --components "0 1 2" --value 0\n' - '% mechanics boundary load --type neumann --traction "0 0 -1000" --surface z1\n' -) - -_DIRICHLET_LATEX = ( - "% mechanics dim 3\n" - "% mechanics cell hex8\n" - "% mechanics formulation total_lagrangian\n" - "% mechanics coord spatial x y z\n" - "% mechanics coord material X Y Z\n" - "% mechanics material svk --E 200e3 --nu 0.3\n" - '% mechanics boundary fix --type dirichlet --components "0 1 2" --value 0\n' - "% mechanics boundary load --type neumann --traction t_bar\n" -) - - -class TestTaskP1_5: - """Tests for compile_latex façade f_ext_kernel surfacing.""" - - @pytest.mark.integration - def test_neumann_directive_yields_populated_f_ext_kernel(self): - """Acceptance criterion #1: numeric-traction Neumann directive yields a non-None f_ext_kernel.""" - bundle = compile_latex(_NEUMANN_LATEX) - assert bundle.f_ext_kernel is not None - assert "init_f_ext_from_neumann_load" in bundle.f_ext_kernel - assert "f_factor: ti.f64" in bundle.f_ext_kernel - # Traction baked deterministically. - assert "-1000" in bundle.f_ext_kernel - - @pytest.mark.integration - def test_pure_symbolic_traction_returns_none_f_ext_kernel(self): - """Acceptance criterion #1 (back-compat): symbolic-string traction - keeps the legacy imported numeric injection path; f_ext_kernel - stays None.""" - bundle = compile_latex(_DIRICHLET_LATEX) - assert bundle.f_ext_kernel is None - - @pytest.mark.integration - def test_existing_residual_tangent_fields_unchanged(self): - """Acceptance criterion #2: existing emitted_source and bundle - fields keep their shape and content for the Dirichlet baseline.""" - bundle = compile_latex(_DIRICHLET_LATEX) - assert isinstance(bundle.emitted_source, str) - assert bundle.emitted_source != "" # Taichi printer still ran - # Pre-existing fields are still populated. - assert bundle.problem_ir_dict["material"]["model"] == "svk" - assert bundle.element_ir_summary["element_type"] == "hex8" - - @pytest.mark.integration - def test_neumann_bundle_round_trips_through_to_dict(self): - """f_ext_kernel survives to_dict / from_dict round-trip.""" - from mechdsl.codegen.artifact import ArtifactBundle - - bundle = compile_latex(_NEUMANN_LATEX) - restored = ArtifactBundle.from_dict(bundle.to_dict()) - assert restored.f_ext_kernel == bundle.f_ext_kernel - - @pytest.mark.integration - def test_kernel_emission_is_parametric_in_f_factor(self): - """The façade-emitted kernel takes f_factor as a runtime arg - (no mesh available at compile time) and bakes traction as a - literal.""" - bundle = compile_latex(_NEUMANN_LATEX) - assert bundle.f_ext_kernel is not None - # Body multiplies the literal traction by the runtime f_factor. - assert "* f_factor" in bundle.f_ext_kernel - # Spatial-component loop uses ti.static per index-partitioning rule. - assert "for d in ti.static(range(3))" in bundle.f_ext_kernel - # Mesh loops use runtime range. - assert "for i in range(n_nodes)" in bundle.f_ext_kernel diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_6.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_6.py deleted file mode 100644 index d385175..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_6.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Tests for Task P1-6: test_p7_2 directive-only Neumann path.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -_TARGET = ( - Path(__file__).resolve().parents[2] - / "plan_tests" - / "recovery_plan_latex_contract" - / "test_p7_2.py" -) - - -class TestTaskP1_6: - """Tests for Task P1-6: test_p7_2 directive-only Neumann path. - - The acceptance work for this task is the rewrite of test_p7_2 itself - (verified by `uv run pytest .../test_p7_2.py -v`). The cases below - audit the rewrite's structural shape so a future regression that - re-introduces the manual-injection pattern surfaces here. - """ - - @pytest.mark.unit - def test_target_file_exists(self): - assert _TARGET.is_file(), f"missing {_TARGET}" - - @pytest.mark.unit - def test_target_no_longer_constructs_f_ext_directly(self): - """Acceptance criterion #1: the manual `mod.f_ext.from_numpy(...)` - injection is gone; the kernel emitted by P1-5 drives the load.""" - text = _TARGET.read_text(encoding="utf-8") - assert "mod.f_ext.from_numpy" not in text, ( - "test_p7_2 must drive f_ext via the emitted " - "init_f_ext_from_neumann_load kernel, not via direct injection" - ) - assert "mod.init_f_ext_from_neumann_load" in text, ( - "test_p7_2 must call the directive-driven f_ext kernel" - ) - - @pytest.mark.unit - def test_traction_string_gap_comment_removed(self): - """Acceptance criterion #3: the placeholder traction-string-gap - comment that flagged the missing directive flow (closes follow-up - item 9) is no longer present.""" - text = _TARGET.read_text(encoding="utf-8") - assert "placeholder for symbolic binding" not in text, ( - "the obsolete traction-string-gap placeholder comment must be " - "removed once P1-1..P1-5 land the directive flow" - ) - assert "the numeric f_ext is provided here as the contract" not in text - - @pytest.mark.unit - def test_latex_directive_carries_numeric_traction_and_surface(self): - """The Neumann directive in CANONICAL_LATEX_SOURCE uses the new - numeric 3-vector traction form and an explicit `--surface` tag, - exercising P1-2's directive parser extension.""" - text = _TARGET.read_text(encoding="utf-8") - assert '--traction "1 0 0"' in text or '--traction "0 0 -1000"' in text, ( - "expected a numeric 3-vector traction in CANONICAL_LATEX_SOURCE" - ) - assert "--surface x1" in text or "--surface " in text, ( - "expected an explicit --surface tag on the Neumann directive" - ) - - @pytest.mark.unit - def test_test_p7_2_asserts_f_ext_kernel_present(self): - """The rewrite asserts compile_latex returns a non-None - f_ext_kernel — that's the contract P1-5 introduced and the - directive-only path depends on.""" - text = _TARGET.read_text(encoding="utf-8") - assert "bundle.f_ext_kernel is not None" in text - assert "init_f_ext_from_neumann_load" in text diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_7.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_7.py deleted file mode 100644 index 0022a2d..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p1_7.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Tests for Task P1-7: Golden test test_boundary_neumann for emitted f_ext kernel.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -_TESTS_DIR = Path(__file__).resolve().parents[2] -_TARGET = _TESTS_DIR / "test_boundary_neumann.py" -_GOLDEN = _TESTS_DIR / "golden" / "boundary_neumann.ti.txt" - - -class TestTaskP1_7: - """Tests for Task P1-7: Golden test for emitted f_ext kernel. - - Acceptance criteria covered: 1, 2, 3. - The acceptance work is the new ``test_boundary_neumann.py`` file - plus the committed ``boundary_neumann.ti.txt`` artifact. Tests - below audit those two surfaces. - """ - - @pytest.mark.unit - def test_test_boundary_neumann_passes_on_clean_checkout(self): - """Acceptance criterion #1: the golden test file exists and - carries the canonical Neumann fixture.""" - assert _TARGET.is_file(), f"missing {_TARGET}" - text = _TARGET.read_text(encoding="utf-8") - assert "NeumannKernelSpec" in text - assert "emit_neumann_f_ext_kernel" in text - # Canonical fixture per plan acceptance: traction "0 0 -1000". - assert "(0.0, 0.0, -1000.0)" in text - - @pytest.mark.unit - def test_golden_artifact_committed(self): - """Acceptance criterion #2: golden file lives under - tests/golden/ alongside the test.""" - assert _GOLDEN.is_file(), f"missing golden artifact {_GOLDEN}" - body = _GOLDEN.read_text(encoding="utf-8") - assert "init_f_ext_from_neumann_load" in body - assert "f_ext[nid][2] = -1000" in body - - @pytest.mark.unit - def test_intentional_codegen_change_diffs_in_golden(self): - """Acceptance criterion #3: the golden test asserts on - ``source == golden`` so any drift surfaces as a diff. Audit the - target file for that comparison shape.""" - text = _TARGET.read_text(encoding="utf-8") - assert "assert source == golden" in text, ( - "golden test must use a strict equality comparison so " - "intentional codegen changes show up as a diff in the " - "stored golden file" - ) - # Regen escape hatch documented. - assert "_UPDATE_GOLDEN" in text diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p2_1.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p2_1.py deleted file mode 100644 index ec400c4..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p2_1.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Tests for Task P2-1: register `docs` pytest marker. - -Acceptance criteria covered: -1. `uv run pytest --markers` lists `docs` alongside `slow`, `gpu`, `e2e`. -2. `.claude/rules/tests.md` mentions `docs` tier. -3. No `PytestUnknownMarkWarning` for `@pytest.mark.docs`. -""" - -from __future__ import annotations - -import re -import subprocess -import sys -import tomllib -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - """Walk up until pyproject.toml with [tool.pytest.ini_options] is found.""" - here = Path(__file__).resolve() - for parent in here.parents: - candidate = parent / "pyproject.toml" - if candidate.is_file(): - data = tomllib.loads(candidate.read_text(encoding="utf-8")) - ini = data.get("tool", {}).get("pytest", {}).get("ini_options") - if ini is not None: - return parent - raise RuntimeError("repo root with [tool.pytest.ini_options] not found") - - -def _registered_marker_names(pyproject: Path) -> set[str]: - data = tomllib.loads(pyproject.read_text(encoding="utf-8")) - markers = data.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("markers", []) - assert isinstance(markers, list) - return {str(entry).split(":", 1)[0].strip() for entry in markers} - - -class TestTaskP2_1: - """Tests for Task P2-1: Register `docs` pytest marker. - - Acceptance criteria covered: 1, 2, 3. - """ - - @pytest.mark.unit - def test_docs_marker_registered_in_pyproject(self) -> None: - """`docs` declared under ``[tool.pytest.ini_options].markers``.""" - root = _repo_root() - names = _registered_marker_names(root / "pyproject.toml") - assert "docs" in names, ( - f"pyproject.toml [tool.pytest.ini_options].markers must register " - f"'docs'. Registered: {sorted(names)}" - ) - for required in ("slow", "gpu", "e2e"): - assert required in names, f"baseline marker '{required}' missing from {sorted(names)}" - - @pytest.mark.unit - def test_tests_md_mentions_docs_tier(self) -> None: - """`.claude/rules/tests.md` registers the `docs` tier in its Markers section.""" - root = _repo_root() - tests_md = root / ".claude" / "rules" / "tests.md" - assert tests_md.is_file(), f"missing {tests_md}" - content = tests_md.read_text(encoding="utf-8") - markers_section = re.search(r"## Markers\s*\n(.+?)(?:\n## |\Z)", content, re.DOTALL) - assert markers_section is not None, "## Markers section missing in tests.md" - body = markers_section.group(1) - assert re.search(r"`@pytest\.mark\.docs`", body), ( - "tests.md ## Markers section must reference `@pytest.mark.docs`" - ) - - @pytest.mark.unit - def test_no_unknown_mark_warning_for_docs(self, tmp_path: Path) -> None: - """`@pytest.mark.docs` collects clean under --strict-markers.""" - probe = tmp_path / "test_docs_probe.py" - probe.write_text( - "import pytest\n@pytest.mark.docs\ndef test_probe():\n assert True\n", - encoding="utf-8", - ) - root = _repo_root() - result = subprocess.run( - [ - sys.executable, - "-m", - "pytest", - "-c", - str(root / "pyproject.toml"), - "--rootdir", - str(root), - str(probe), - "--collect-only", - "-q", - "--strict-markers", - ], - cwd=root, - capture_output=True, - text=True, - check=False, - ) - combined = result.stdout + result.stderr - assert result.returncode == 0, ( - f"strict-marker collection failed (rc={result.returncode}):\n{combined}" - ) - assert "PytestUnknownMarkWarning" not in combined, ( - f"unexpected unknown-mark warning:\n{combined}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p2_2.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p2_2.py deleted file mode 100644 index 895f3ab..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p2_2.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Tests for Task P2-2: swap @pytest.mark.integration → @pytest.mark.docs -on doc-tier P7-3..P7-6 tests. - -Acceptance criteria covered: -1. No `@pytest.mark.integration` decorators remain on doc-tier tests in - `test_p7_*` files (test_p7_3, test_p7_4, test_p7_5, test_p7_6). -2. `uv run pytest -m docs` selects the doc-tier tests; the selection is - confined to the recovery_plan_latex_contract test_p7_*.py files (and - any tests explicitly tagged docs in this plan's stubs). - -Tier note: P2-2 task JSON sets test_plan.tier="docs" (the swap target -marker), but this meta-stub itself verifies *source files* and runs in -the fast suite, so it is registered at tier `unit`. -""" - -from __future__ import annotations - -import re -import subprocess -import sys -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / ".github").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -_DOC_TIER_FILES = ( - "packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_3.py", - "packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_4.py", - "packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_5.py", - "packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_6.py", -) - - -class TestTaskP2_2: - """Tests for Task P2-2: swap integration → docs marker on P7-3..6 doc-tier tests.""" - - @pytest.mark.unit - def test_no_integration_marker_on_doc_tier_files(self) -> None: - """Each doc-tier P7 file must contain zero @pytest.mark.integration - decorators and at least one @pytest.mark.docs decorator post-swap.""" - root = _repo_root() - problems: list[str] = [] - for relpath in _DOC_TIER_FILES: - f = root / relpath - if not f.is_file(): - continue - text = f.read_text(encoding="utf-8") - integration_hits = re.findall(r"@pytest\.mark\.integration\b", text) - docs_hits = re.findall(r"@pytest\.mark\.docs\b", text) - if integration_hits: - problems.append( - f"{relpath}: {len(integration_hits)} @pytest.mark.integration decorator(s) remain" - ) - if not docs_hits: - problems.append(f"{relpath}: missing @pytest.mark.docs decorator") - assert not problems, "\n".join(problems) - - @pytest.mark.unit - def test_docs_marker_selects_only_p7_doc_tier_tests(self) -> None: - """`pytest -m docs --collect-only` returns nodeids only under the doc-tier - scope (recovery_plan_latex_contract/test_p7_*.py for the swap target; - post_recovery_plan-stub paths for explicit P2 tagging are also allowed - via @pytest.mark.docs but no such stub uses it today).""" - root = _repo_root() - result = subprocess.run( - [ - sys.executable, - "-m", - "pytest", - "-c", - str(root / "pyproject.toml"), - "--rootdir", - str(root), - "-m", - "docs", - "--collect-only", - "-q", - ], - cwd=root, - capture_output=True, - text=True, - check=False, - ) - assert result.returncode == 0, ( - f"pytest -m docs collection failed (rc={result.returncode}):\n" - f"{result.stdout}\n{result.stderr}" - ) - nodeids = [ - line for line in result.stdout.splitlines() if "::" in line and not line.startswith("=") - ] - assert nodeids, "expected at least one collected docs-marked test" - # Allowed doc-tier homes: - # - The P2-2 swap target — recovery_plan_latex_contract/test_p7_3..6. - # - The post_recovery_plan-doc-tier paragraph regression tests - # (test_compile_latex_docstring.py + test_p3_*.py / test_p5_*.py - # meta-stubs) added by Phase 3 P3-1/P3-2 and Phase 5 P5-5. - # - The Phase 4 nrpylatex round-trip suite - # (test_nrpylatex_round_trip.py) which exercises the math - # import chain at the docs tier. - # NOTE: post_recovery_plan Phase 7 (P7-2) generalises the - # explicit prefix list — any docs-tier test file under the - # post_recovery_plan stub directory is admitted, plus the two - # standalone regression-guard files. Replaces the old - # phase-by-phase widening pattern (P3-1, P4-5, P5-5, P7). - # The fgram plan (the continuation after post_recovery_plan) - # carries its own docs-tier governance/round-trip stubs under - # plan_tests/fgram/, admitted here on the same footing. - # PlanJune14 (PJ-7 governance) adds two top-level doc-anchor stubs under - # plan_tests/: test_p7_1 pins the 06-CODEGEN/11-ALGO2CODE design-doc addenda, - # test_p7_2 pins the STATUS_LEGEND vocabulary contract. Admitted here on the - # same footing as the recovery_plan / post_recovery_plan / fgram doc-tier tests. - allowed_prefixes = ( - "packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_", - "packages/mechdsl-core/tests/plan_tests/post_recovery_plan/", - "packages/mechdsl-core/tests/plan_tests/fgram/", - "packages/mechdsl-core/tests/plan_tests/test_p7_1.py", - "packages/mechdsl-core/tests/plan_tests/test_p7_2.py", - "packages/mechdsl-core/tests/test_compile_latex_docstring.py", - "packages/mechdsl-core/tests/test_nrpylatex_round_trip.py", - ) - for nodeid in nodeids: - normalised = nodeid.split("::", 1)[0] - assert any(normalised.startswith(p) for p in allowed_prefixes), ( - f"unexpected docs-marked nodeid outside doc-tier scope: {nodeid}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p2_3.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p2_3.py deleted file mode 100644 index 71f8e70..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p2_3.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Tests for Task P2-3: audit and update CI workflow tier:docs selector. - -Acceptance criteria covered: -1. CI workflow runs `pytest -m docs` on the doc-tier label or label-routed - selector. -2. No remaining references to integration-marker fallback for the doc-tier - tests in `.github/workflows/*.yml`. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / ".github" / "workflows").is_dir(): - return parent - raise RuntimeError("repo root with .github/workflows not found") - - -def _workflow_files() -> list[Path]: - root = _repo_root() - return sorted((root / ".github" / "workflows").glob("*.yml")) + sorted( - (root / ".github" / "workflows").glob("*.yaml") - ) - - -_DOCS_SELECTOR_RE = re.compile(r"-m\s+(?:\"[^\"]*\bdocs\b[^\"]*\"|'[^']*\bdocs\b[^']*'|docs\b)") -_INTEGRATION_SELECTOR_RE = re.compile( - r"-m\s+(?:\"[^\"]*\bintegration\b[^\"]*\"|'[^']*\bintegration\b[^']*'|integration\b)" -) -_DOC_TIER_TEST_RE = re.compile(r"test_p7_[3-6]") - - -class TestTaskP2_3: - """Tests for Task P2-3: audit/update CI workflow tier:docs selector.""" - - @pytest.mark.integration - def test_workflow_invokes_pytest_dash_m_docs(self) -> None: - """At least one workflow job runs `pytest -m docs`.""" - files = _workflow_files() - assert files, "no workflow files found under .github/workflows/" - matches: list[tuple[str, int, str]] = [] - for f in files: - text = f.read_text(encoding="utf-8") - for lineno, line in enumerate(text.splitlines(), start=1): - if _DOCS_SELECTOR_RE.search(line): - matches.append((f.name, lineno, line.strip())) - assert matches, ( - "expected at least one `pytest -m docs` selector in " - ".github/workflows/*.yml; tier:docs label has no route" - ) - - @pytest.mark.integration - def test_no_integration_fallback_for_doc_tier_tests(self) -> None: - """No workflow job targets the P7-3..6 doc-tier tests via the - integration-marker fallback. Generic `-m integration` references - elsewhere (covering non-doc-tier tests) are permitted.""" - problems: list[str] = [] - for f in _workflow_files(): - text = f.read_text(encoding="utf-8") - if _DOC_TIER_TEST_RE.search(text) and _INTEGRATION_SELECTOR_RE.search(text): - # Narrow down to lines for reporting if possible, but flag the file if found anywhere - found_on_line = False - for lineno, line in enumerate(text.splitlines(), start=1): - if _DOC_TIER_TEST_RE.search(line) and _INTEGRATION_SELECTOR_RE.search(line): - problems.append(f"{f.name}:{lineno}: {line.strip()}") - found_on_line = True - if not found_on_line: - problems.append( - f"{f.name}: Found both integration marker and doc-tier tests (potential multi-line command)" - ) - problems.append(f"{f.name}:{lineno}: {line.strip()}") - assert not problems, ( - "integration-marker fallback still references doc-tier P7-3..6 " - "tests:\n" + "\n".join(problems) - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p3_1.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p3_1.py deleted file mode 100644 index 06cdb59..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p3_1.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Tests for Task P3-1: BC handoff paragraph in compile_latex docstring. - -Acceptance criteria covered: -1. compile_latex.__doc__ mentions BoundaryCondition. -2. Docstring covers the f_ext caller-provisioning contract. -3. Docstring linter passes on the modified module. -""" - -from __future__ import annotations - -import inspect -import re -import subprocess -from pathlib import Path - -import pytest - -_CALLER_PROVISIONING_TOKENS = ("caller", "supplied", "supplies", "provisioning") - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -class TestTaskP3_1: - """Tests for Task P3-1: BC handoff paragraph in compile_latex docstring.""" - - @pytest.mark.docs - def test_docstring_mentions_boundary_condition(self) -> None: - from mechdsl import compile_latex - - doc = inspect.getdoc(compile_latex) - assert doc is not None, "compile_latex has no docstring" - assert "BoundaryCondition" in doc, ( - "compile_latex docstring must reference BoundaryCondition " - "(the IR slot populated by `% mechanics boundary` directives)" - ) - - @pytest.mark.docs - def test_docstring_covers_f_ext_caller_provisioning(self) -> None: - from mechdsl import compile_latex - - doc = inspect.getdoc(compile_latex) - assert doc is not None, "compile_latex has no docstring" - assert "f_ext" in doc, "compile_latex docstring must mention f_ext" - lowered = doc.lower() - assert any(token in lowered for token in _CALLER_PROVISIONING_TOKENS), ( - "compile_latex docstring must describe f_ext as caller-provisioned " - f"(any of {_CALLER_PROVISIONING_TOKENS} expected)" - ) - - @pytest.mark.docs - def test_docstring_linter_passes_on_module(self) -> None: - root = _repo_root() - target = root / "packages" / "mechdsl-core" / "src" / "mechdsl" / "__init__.py" - assert target.is_file(), f"missing {target}" - result = subprocess.run( - ["uv", "run", "ruff", "check", "--select", "D", str(target)], - cwd=root, - capture_output=True, - text=True, - check=False, - ) - # Ruff exits 0 when clean; any D-rule violation should fail this test. - assert result.returncode == 0, ( - f"docstring lint failed (rc={result.returncode}):\n" - f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) - # Defensive: no lingering D-rule violations even if returncode is 0. - assert not re.search(r"\bD\d{3}\b", result.stdout), ( - f"docstring lint reported D-rule code in stdout:\n{result.stdout}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p3_2.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p3_2.py deleted file mode 100644 index 4265ec7..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p3_2.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Tests for Task P3-2: docstring-presence regression test. - -P3-2's deliverable IS a new test file -(packages/mechdsl-core/tests/test_compile_latex_docstring.py). This file -is the meta-spec for that deliverable: it asserts the deliverable file -exists, lives at the canonical path, and exercises compile_latex.__doc__ -with substring assertions on BoundaryCondition and the f_ext -caller-provisioning phrase. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _deliverable_path() -> Path: - return _repo_root() / "packages" / "mechdsl-core" / "tests" / "test_compile_latex_docstring.py" - - -class TestTaskP3_2: - """Tests for Task P3-2: docstring-presence test deliverable.""" - - @pytest.mark.docs - def test_deliverable_test_file_exists(self) -> None: - path = _deliverable_path() - assert path.is_file(), f"Phase 3 P3-2 deliverable missing: expected file at {path}" - assert path.stat().st_size > 0, f"{path} is empty" - - @pytest.mark.docs - def test_deliverable_file_asserts_boundary_condition_substring(self) -> None: - text = _deliverable_path().read_text(encoding="utf-8") - # The deliverable must reference BoundaryCondition AND exercise - # compile_latex.__doc__ (so a docstring removal would actually fail - # the test rather than passing because the source file mentions BC). - assert "BoundaryCondition" in text, ( - "deliverable test must assert BoundaryCondition substring presence" - ) - assert "compile_latex" in text, "deliverable test must reference compile_latex" - assert "__doc__" in text or "inspect.getdoc" in text, ( - "deliverable test must read compile_latex.__doc__ " - "(via attribute access or inspect.getdoc)" - ) - - @pytest.mark.docs - def test_deliverable_file_asserts_f_ext_caller_provisioning(self) -> None: - text = _deliverable_path().read_text(encoding="utf-8") - assert "f_ext" in text, "deliverable test must assert f_ext substring presence" - # Caller-provisioning synonym set — any one suffices. - synonyms = ("caller", "supplied", "supplies", "provisioning") - assert any(token in text for token in synonyms), ( - f"deliverable test must assert at least one caller-provisioning synonym ({synonyms})" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_1.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_1.py deleted file mode 100644 index 0a761a9..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_1.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Tests for Task P4-1: frontend/math_parser.py wrapping nrpylatex. - -Acceptance criteria covered: -1. Parses an indexed expression without error (SVK-flavoured rank-2 copy). -2. Mixed F^{iI} two-point: spatial/material index distinction is - classified post-parse by enforce_index_convention. -3. Unsupported node raises with explicit Phase-4 reference. -""" - -from __future__ import annotations - -import pytest - -from mechdsl.frontend.math_parser import ( - MathParseError, - MathParseResult, - parse_math, -) - -_RANK2_COPY = "% declare FUU --dim 3\n% declare AUU --dim 3\nA^{i j} = F^{i j}\n" - -_TWO_POINT = "% declare FUU --dim 3\n% declare AUU --dim 3\nA^{i I} = F^{i I}\n" - - -class TestTaskP4_1: - """Tests for Task P4-1: math_parser.py wrapping nrpylatex. - - Acceptance criteria covered: 1, 2, 3. - """ - - @pytest.mark.unit - def test_rank2_indexed_expression_parses(self) -> None: - """parse_math returns a populated MathParseResult for a balanced - rank-2 tensor copy ``A^{ij} = F^{ij}``. Stand-in for the - SVK-PK1 surface — full SVK requires bound-index + scalar mix - that nrpylatex 1.4.0 grammar does not accept end-to-end (see - module docstring). - """ - result = parse_math(_RANK2_COPY) - assert isinstance(result, MathParseResult) - assert "FUU" in result.tensors - assert "AUU" in result.tensors - assert result.tensors["FUU"].rank == 2 - assert result.tensors["AUU"].rank == 2 - - @pytest.mark.unit - def test_two_point_tensor_index_distinction_preserved(self) -> None: - """``F^{iI}`` post-parse classification reports axis 0 as - spatial and axis 1 as material (per 07-CONVENTIONS.md letter - case rule — nrpylatex itself does not enforce this). - """ - result = parse_math(_TWO_POINT) - f_class = result.classifications["FUU"] - assert 0 in f_class.spatial_axes, f"axis 0 of FUU should be spatial (i), got {f_class}" - assert 1 in f_class.material_axes, f"axis 1 of FUU should be material (I), got {f_class}" - - @pytest.mark.unit - def test_unsupported_node_raises_with_phase_pointer(self) -> None: - """A LaTeX block that exercises a grammar feature outside the - supported subset raises MathParseError whose message names - ``post_recovery_plan Phase 4``. - - ``\\det`` was the original example, but fgram Phase 4 (P4-1) - promoted it to a supported node; ``\\sin`` remains a documented - full-grammar deferral (``math_parser._UNSUPPORTED_FUNCTIONS``). - """ - unsupported = "% declare FUU --dim 3\nT = \\sin{F}\n" - with pytest.raises(MathParseError) as excinfo: - parse_math(unsupported) - assert "post_recovery_plan Phase 4" in str(excinfo.value), ( - f"missing Phase-4 pointer in error: {excinfo.value}" - ) - - @pytest.mark.unit - def test_index_convention_violation_raises(self) -> None: - """A tensor that appears with both spatial and material letters - on the same axis raises MathParseError with the Phase-4 pointer. - Forces correct convention usage at the front door. - """ - # Two parses; nrpylatex namespace is reset between them, but our - # convention check operates on a single parse — so synthesise a - # block where F^{ij} (spatial) and F^{IJ} (material) coexist on - # rank-2 F. - bad = "% declare FUU --dim 3\n% declare AUU --dim 3\nA^{i j} = F^{i j} + F^{I J}\n" - with pytest.raises(MathParseError) as excinfo: - parse_math(bad) - assert "post_recovery_plan Phase 4" in str(excinfo.value) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_2.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_2.py deleted file mode 100644 index 6848d6e..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_2.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Tests for Task P4-2: symbolic/bridge.py adapter (nrpylatex AST → mechdsl symbolic). - -Acceptance criteria covered: -1. convert() handles a balanced rank-2 indexed expression including - F^{iI} two-point. -2. log(J)-style scalar entries — covered structurally via rank-0 scalar - path. Nrpylatex 1.4.0 does not register \\log{} as a known function, - so the integration round-trip is deferred (see module docstring); - this test exercises the bridge's rank-0 path with the closest - parseable surrogate. -3. Unsupported nodes raise with Phase-4 pointer. -4. No mutation of existing symbolic types — verified by importing the - public symbolic surface and comparing identity before/after a - convert() call. -""" - -from __future__ import annotations - -import pytest - -from mechdsl.frontend.math_parser import IndexClassification, parse_math -from mechdsl.symbolic import constitutive as _sym_constitutive -from mechdsl.symbolic import convected as _sym_convected -from mechdsl.symbolic import kinematics as _sym_kinematics -from mechdsl.symbolic.bridge import ( - BridgeError, - SymbolicNode, - convert, - convert_namespace, -) - -_TWO_POINT_WITH_CONSTS = ( - "% declare FUU --dim 3\n% declare AUU --dim 3\n% declare \\mu --const\nA^{i I} = F^{i I}\n" -) - - -class TestTaskP4_2: - """Tests for Task P4-2: bridge.py adapter.""" - - @pytest.mark.unit - def test_convert_handles_two_point_F_iI(self) -> None: - result = parse_math(_TWO_POINT_WITH_CONSTS) - nodes = convert_namespace(result.tensors, result.classifications) - f = nodes["FUU"] - assert isinstance(f, SymbolicNode) - assert f.kind == "tensor2" - assert f.rank == 2 - assert f.suffix == "UU" - assert f.classification is not None - assert 0 in f.classification.spatial_axes - assert 1 in f.classification.material_axes - - @pytest.mark.unit - def test_convert_handles_constant_scalar(self) -> None: - """nrpylatex stores ``--const`` declarations as - Function('Constant')(Symbol('mu')); the bridge maps these to - SymbolicNode kind=='constant' with rank 0. - Surrogate for the deferred ``log(J)`` rank-0 scalar path. - """ - result = parse_math(_TWO_POINT_WITH_CONSTS) - nodes = convert_namespace(result.tensors, result.classifications) - mu = nodes[r"\mu"] - assert mu.kind == "constant" - assert mu.rank == 0 - - @pytest.mark.unit - def test_unsupported_rank_raises_with_phase_pointer(self) -> None: - """A rank-3 nrpylatex IndexedSymbol falls outside the bridge's - currently supported shapes and raises BridgeError. - """ - # Feed a synthetic rank-3 IndexedSymbol directly to convert(), - # bypassing nrpylatex's contraction grammar (which forbids - # bound-index assignment to a rank-0 LHS without UD pairing). - import nrpylatex - from sympy import Function, Symbol - - function = Function("Tensor")(Symbol("TUUU", real=True)) - rank3 = nrpylatex.IndexedSymbol(function, dimension=3) - with pytest.raises(BridgeError) as excinfo: - convert("TUUU", rank3, classification=None) - msg = str(excinfo.value).lower() - assert "post_recovery_plan phase 4" in msg - assert "rank-3" in msg or "rank 3" in msg - - @pytest.mark.unit - def test_convert_rejects_non_indexed_non_constant_input(self) -> None: - """``convert`` rejects raw Python types that nrpylatex would - never produce — guards the bridge surface. - """ - with pytest.raises(BridgeError) as excinfo: - convert("X", object(), classification=None) - assert "post_recovery_plan Phase 4" in str(excinfo.value) - - @pytest.mark.unit - def test_existing_symbolic_types_unchanged_after_convert(self) -> None: - """Identity of public symbolic-layer attributes survives a - bridge call. The bridge must not mutate or replace any - ``mechdsl.symbolic`` API surface. - """ - snapshot_attrs = { - mod.__name__: tuple(sorted(name for name in vars(mod) if not name.startswith("_"))) - for mod in (_sym_kinematics, _sym_constitutive, _sym_convected) - } - result = parse_math(_TWO_POINT_WITH_CONSTS) - convert_namespace(result.tensors, result.classifications) - after = { - mod.__name__: tuple(sorted(name for name in vars(mod) if not name.startswith("_"))) - for mod in (_sym_kinematics, _sym_constitutive, _sym_convected) - } - assert snapshot_attrs == after, "bridge mutated symbolic public attributes" - - @pytest.mark.unit - def test_convert_with_explicit_classification_passthrough(self) -> None: - """``convert`` accepts an externally supplied - IndexClassification and passes it onto the SymbolicNode. - Decouples convention enforcement from the bridge. - """ - result = parse_math(_TWO_POINT_WITH_CONSTS) - cls = IndexClassification( - name="FUU", - suffix="UU", - spatial_axes=(0,), - material_axes=(1,), - ) - node = convert("FUU", result.tensors["FUU"], classification=cls) - assert node.classification is cls diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_3.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_3.py deleted file mode 100644 index 15ba4f8..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_3.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Tests for Task P4-3: wire math parser into frontend pipeline. - -Acceptance criteria covered: -1. Math-bearing LaTeX produces a populated symbolic expression tree. -2. Directive-only LaTeX skips math parsing entirely (parse-when-needed - guard, plan §Phase 4 lines 226-227). -3. No regression in existing frontend tests (test_directives, - test_two_point, test_frontend_parser). -""" - -from __future__ import annotations - -import pytest - -from mechdsl.frontend import has_math_block, parse, parse_with_math -from mechdsl.symbolic.bridge import SymbolicNode - -_DIRECTIVES_ONLY = ( - "% mechanics dim 3\n" - "% mechanics cell hex8\n" - "% mechanics formulation total_lagrangian\n" - "% mechanics material svk --E 200e3 --nu 0.3\n" - "% mechanics boundary Gamma_u --type dirichlet --value 0\n" -) - -_DIRECTIVES_WITH_MATH = ( - _DIRECTIVES_ONLY - + "\n% Math block exercises the nrpylatex grammar:\n" - + "% declare FUU --dim 3\n" - + "% declare AUU --dim 3\n" - + "$A^{i I} = F^{i I}$\n" -) - - -class TestTaskP4_3: - """Tests for Task P4-3: wire math parser into frontend pipeline.""" - - @pytest.mark.integration - def test_math_bearing_input_routes_through_math_parser(self) -> None: - context = parse_with_math(_DIRECTIVES_WITH_MATH) - assert "math" in context, "math-bearing input must populate context['math']" - math = context["math"] - assert math["blocks"], "math.blocks must be non-empty for math-bearing input" - tensors = math["tensors"] - assert "FUU" in tensors and isinstance(tensors["FUU"], SymbolicNode) - assert "AUU" in tensors and tensors["AUU"].rank == 2 - f_class = math["classifications"]["FUU"] - assert 0 in f_class.spatial_axes - assert 1 in f_class.material_axes - - @pytest.mark.integration - def test_directive_only_input_skips_math_parser(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Directive-only input must NOT invoke the math parser. Verified - by an import-counter monkeypatch: replacing ``parse_math`` with a - sentinel that raises if called confirms the parse-when-needed - guard short-circuits before the math parser runs. - """ - from mechdsl.frontend import math_parser - - invocations: list[str] = [] - - def _sentinel(latex_block: str): - invocations.append(latex_block) - raise AssertionError("math_parser.parse_math was invoked on directive-only input") - - monkeypatch.setattr(math_parser, "parse_math", _sentinel) - context = parse_with_math(_DIRECTIVES_ONLY) - assert invocations == [], "math parser was called on directive-only input" - assert "math" not in context - - @pytest.mark.integration - def test_directive_only_dict_matches_plain_parse(self) -> None: - """Augmentation is additive: ``parse_with_math`` on - directive-only input returns exactly the dict :func:`parse` - returns (no extra keys, no shape change). Guards - directive-pipeline back-compat. - """ - plain = parse(_DIRECTIVES_ONLY) - with_math = parse_with_math(_DIRECTIVES_ONLY) - assert with_math == plain - - @pytest.mark.unit - def test_has_math_block_detects_inline_dollar(self) -> None: - assert has_math_block("$x = 1$") - assert has_math_block("text\n$F^{ij} = A^{ij}$\nmore") - assert not has_math_block("plain text without math") - assert not has_math_block(r"escaped \$ not math \$") - assert not has_math_block("% mechanics dim 3\n% mechanics cell hex8\n") - - @pytest.mark.integration - def test_existing_frontend_tests_still_pass(self) -> None: - """Smoke check: re-import the existing frontend modules and - verify their public API surface still exposes the symbols other - tests rely on. The directive parser, two-point resolver and - ``build_context`` must remain reachable post-Phase-4 wiring. - """ - import mechdsl.frontend as fe - from mechdsl.frontend import directives, parser, two_point - - for sym in ("parse", "parse_file", "build_context"): - assert hasattr(fe, sym), f"mechdsl.frontend.{sym} missing" - assert callable(parser.parse) - assert hasattr(directives, "ParseError") or hasattr(parser, "ParseError") - assert two_point is not None diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_4.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_4.py deleted file mode 100644 index 4792ed2..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_4.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for Task P4-4: nrpylatex round-trip test deliverable -(test_nrpylatex_round_trip.py). - -Acceptance criteria covered: -1. Three case families exercised (SVK PK1 surrogate, J2 yield, two-point). -2. Index convention verified at the bridge surface for the two-point - case (axis 0 spatial, axis 1 material). -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _deliverable_path() -> Path: - return _repo_root() / "packages" / "mechdsl-core" / "tests" / "test_nrpylatex_round_trip.py" - - -class TestTaskP4_4: - """Tests for Task P4-4: round-trip deliverable.""" - - @pytest.mark.integration - def test_deliverable_file_exists(self) -> None: - path = _deliverable_path() - assert path.is_file(), f"P4-4 deliverable missing: {path}" - assert path.stat().st_size > 0 - - @pytest.mark.integration - def test_deliverable_covers_svk_pk1_round_trip(self) -> None: - text = _deliverable_path().read_text(encoding="utf-8") - assert "svk" in text.lower(), "deliverable must reference SVK case" - # fgram Phase 4 (P4-1) replaced the closed-form ``test_round_trip_svk*`` - # surrogate with an expression-preserving finite-deformation test that - # covers the same PK2->PK1 stress round-trip (S_{IJ} then P_{iI}). - assert "test_round_trip_finite_deformation" in text, ( - "deliverable must define a finite-deformation (SVK/PK1) round-trip test" - ) - assert any(p in text for p in ("P_{i I}", "P_{iI}")), ( - "finite-deformation round-trip must preserve the PK1 stress P_{iI}" - ) - - @pytest.mark.integration - def test_deliverable_covers_j2_yield_round_trip(self) -> None: - text = _deliverable_path().read_text(encoding="utf-8") - assert "j2" in text.lower() or "yield" in text.lower(), ( - "deliverable must reference J2 yield case" - ) - assert "test_round_trip_j2" in text, "deliverable must define a J2 round-trip test" - - @pytest.mark.integration - def test_deliverable_covers_two_point_tensor_round_trip(self) -> None: - text = _deliverable_path().read_text(encoding="utf-8") - assert "two_point" in text.lower() or "F^{i I}" in text or "F^{iI}" in text, ( - "deliverable must reference two-point F^{iI} case" - ) - assert "test_round_trip_two_point" in text - # Verify the test asserts the spatial/material classification - # (otherwise the regression-guard property is empty). - assert "spatial_axes" in text and "material_axes" in text diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_5.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_5.py deleted file mode 100644 index c533cd8..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p4_5.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Tests for Task P4-5: dev/examples/svk_latex_math.tex + README inventory. - -Acceptance criteria covered: -1. Example file present and runs end-to-end through parse_with_math - (returns a populated context with a math block). -2. README inventory contains an entry referencing svk_latex_math.tex. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "dev").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _example_path() -> Path: - return _repo_root() / "dev" / "examples" / "svk_latex_math.tex" - - -def _examples_readme() -> Path: - return _repo_root() / "dev" / "examples" / "README.md" - - -class TestTaskP4_5: - """Tests for Task P4-5: SVK LaTeX-math example.""" - - @pytest.mark.integration - def test_example_file_exists(self) -> None: - path = _example_path() - assert path.is_file(), f"P4-5 example missing: {path}" - assert path.stat().st_size > 0 - - @pytest.mark.integration - def test_example_compiles_end_to_end(self) -> None: - """``parse_with_math`` on the example returns a context with a - populated ``math`` key, ``F`` and ``A`` tensors converted, and - the directive-side fields (``dim``, ``cell_type``, …) intact. - """ - from mechdsl.frontend import parse_with_math - from mechdsl.symbolic.bridge import SymbolicNode - - source = _example_path().read_text(encoding="utf-8") - ctx = parse_with_math(source) - - # Directive side intact. - assert ctx.get("dim") == 3 - assert ctx.get("cell_type") == "hex8" - assert ctx.get("formulation") == "total_lagrangian" - - # Math side populated. - assert "math" in ctx - tensors = ctx["math"]["tensors"] - assert "FUU" in tensors and isinstance(tensors["FUU"], SymbolicNode) - assert tensors["FUU"].rank == 2 - - # Two-point classification preserved end-to-end. - f_class = ctx["math"]["classifications"]["FUU"] - assert 0 in f_class.spatial_axes - assert 1 in f_class.material_axes - - @pytest.mark.integration - def test_examples_readme_lists_new_example(self) -> None: - text = _examples_readme().read_text(encoding="utf-8") - assert "svk_latex_math.tex" in text, ( - "dev/examples/README.md must list svk_latex_math.tex in the inventory" - ) - # The entry should describe the LaTeX-math integration. - assert "math" in text.lower(), "README entry should describe the math integration" diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_1.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_1.py deleted file mode 100644 index a22c5f0..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_1.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for Task P5-1: dev/algorithms/radial_return_j2.tex algpseudocode source. - -Acceptance criteria covered: -1. File exists at canonical path. -2. algo2code algo_parser smoke-parses the algpseudocode block. -3. Algorithm body references power-law hardening symbols. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from algo2code.algo_parser import parse_algorithm -from algo2code.library.radial_return_j2 import ( - RADIAL_RETURN_J2_LATEX, - get_radial_return_j2_latex, -) - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "dev").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _algorithm_path() -> Path: - return _repo_root() / "dev" / "algorithms" / "radial_return_j2.tex" - - -class TestTaskP5_1: - """Tests for Task P5-1: J2 radial-return algpseudocode source.""" - - @pytest.mark.unit - def test_algorithm_file_exists(self) -> None: - path = _algorithm_path() - assert path.is_file(), f"P5-1 deliverable missing: {path}" - assert path.stat().st_size > 0 - - @pytest.mark.unit - def test_algo2code_smoke_parse(self) -> None: - algo = parse_algorithm(RADIAL_RETURN_J2_LATEX) - assert algo.name == "radial_return_j2" - assert algo.backend == "taichi" - assert len(algo.args) > 0, "algorithm must declare at least one arg" - assert len(algo.body) > 0, "algorithm body must be non-empty" - - @pytest.mark.unit - def test_algorithm_references_power_law_hardening(self) -> None: - text = _algorithm_path().read_text(encoding="utf-8") - # Power-law hardening σ_y(α) = σ_y0 + K · α^n requires K, n, σ_y0 - # — we expose them as args K, n, sigy0 respectively. - for token in ("K", "n", "sigy0"): - assert f" {token}:scalar" in text, ( - f"algorithm must declare {token!r} as a power-law arg" - ) - # The wrapper docstring (or the LaTeX preamble) must mention - # power-law hardening so the contract is greppable. - assert "power-law" in text.lower(), "algorithm header must mention power-law hardening" - - @pytest.mark.unit - def test_library_loader_round_trips_text(self) -> None: - """``algo2code.library.radial_return_j2`` exposes the LaTeX as - ``RADIAL_RETURN_J2_LATEX`` and via the accessor function.""" - assert get_radial_return_j2_latex() == RADIAL_RETURN_J2_LATEX - assert _algorithm_path().read_text(encoding="utf-8") == RADIAL_RETURN_J2_LATEX diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_2.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_2.py deleted file mode 100644 index 8faf5ed..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_2.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Tests for Task P5-2: algo2code radial-return codegen test deliverable. - -P5-2's deliverable is the codegen test file -``packages/algo2code/tests/test_radial_return_codegen.py``. This stub -set is the meta-spec asserting the deliverable file exists and pins -the four required cases (elastic / elastoplastic / unloading / JIT -budget). - -Acceptance criteria covered: -1. Codegen test file exists at canonical path. -2. Test exercises elastic / elastoplastic / unloading cases (covered - under the broader "behavioural cases" umbrella — at this layer the - parser-deferral path means each case is asserted via the parity - route in P5-4; the codegen file pins the import-chain). -3. JIT budget probe ≤ 512 unrolled lines per ``@ti.func``. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _deliverable_path() -> Path: - return _repo_root() / "packages" / "algo2code" / "tests" / "test_radial_return_codegen.py" - - -class TestTaskP5_2: - """Tests for Task P5-2: algo2code radial-return codegen deliverable.""" - - @pytest.mark.unit - def test_deliverable_file_exists(self) -> None: - path = _deliverable_path() - assert path.is_file(), f"P5-2 deliverable missing: {path}" - assert path.stat().st_size > 0 - - @pytest.mark.unit - def test_deliverable_pins_jit_budget(self) -> None: - text = _deliverable_path().read_text(encoding="utf-8") - # The 512 budget number must appear so the probe cannot drift - # silently. - assert "512" in text, "deliverable must pin the 512-line JIT budget" - assert "JIT_BUDGET" in text or "jit budget" in text.lower(), ( - "deliverable must reference the JIT budget by name" - ) - - @pytest.mark.unit - def test_deliverable_exercises_transpile_helper(self) -> None: - """Phase 5 lifted the parser-deferral surface; the deliverable - now exercises ``transpile_radial_return_j2`` end-to-end.""" - text = _deliverable_path().read_text(encoding="utf-8") - assert "transpile_radial_return_j2" in text, ( - "deliverable must exercise the library transpile helper" - ) - assert "callable(" in text, "deliverable must assert the transpiled output is callable" - - @pytest.mark.unit - def test_deliverable_exercises_full_arg_set(self) -> None: - """Codegen test asserts the algorithm declares the scalar - Newton inner loop arg set (sigma_eq, alpha, mu, K, n, sigy0, - tol, max_iter) — the post_recovery_plan Phase 5 P5-1 contract.""" - text = _deliverable_path().read_text(encoding="utf-8") - for token in ("sigma_eq", "alpha", "mu", "K", "n", "sigy0", "tol", "max_iter"): - assert f'"{token}"' in text, f"deliverable must reference required arg {token!r}" - - @pytest.mark.unit - def test_deliverable_validates_emitted_python(self) -> None: - """Deliverable confirms the Taichi emission output is - syntactically valid Python.""" - text = _deliverable_path().read_text(encoding="utf-8") - assert "ast.parse" in text, "deliverable must validate emitted code via ast.parse" diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_3.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_3.py deleted file mode 100644 index c845fbe..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_3.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Tests for Task P5-3: switch lib/plasticity.py default to algo2code path -with feature-flag fallback. - -Acceptance criteria covered: -1. Default solver run (no flag set) uses algo2code-generated path. -2. Setting ``MECHDSL_USE_IMPORTED_RR=1`` reverts to imported path. -3. Switch happens via env var alone — no rebuild / no JIT cache reset. -""" - -from __future__ import annotations - -import numpy as np -import pytest - -from mechdsl.lib.plasticity import ( - FEATURE_FLAG_ENV, - ReturnMappingResult, - active_path_name, - radial_return, -) -from mechdsl.symbolic.models.j2_power_law import J2PowerLawMaterial - - -def _sample_material() -> J2PowerLawMaterial: - return J2PowerLawMaterial(E=200_000.0, nu=0.3, sigma_y0=250.0, K=500.0, n=0.5) - - -def _zero_strain() -> np.ndarray: - return np.zeros((3, 3), dtype=float) - - -class TestTaskP5_3: - """Tests for Task P5-3: lib/plasticity.py dispatch + feature flag.""" - - @pytest.mark.integration - def test_default_path_is_algo2code(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(FEATURE_FLAG_ENV, raising=False) - assert active_path_name() == "algo2code" - result = radial_return(_sample_material(), _zero_strain(), 0.0) - assert isinstance(result, ReturnMappingResult) - - @pytest.mark.integration - def test_flag_reverts_to_imported_path(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(FEATURE_FLAG_ENV, "1") - assert active_path_name() == "imported" - result = radial_return(_sample_material(), _zero_strain(), 0.0) - assert isinstance(result, ReturnMappingResult) - - @pytest.mark.integration - def test_env_var_switch_no_recompile(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Toggle the env var between calls within a single Python - session and confirm ``active_path_name`` updates without any - cached module-level state needing reset.""" - monkeypatch.delenv(FEATURE_FLAG_ENV, raising=False) - assert active_path_name() == "algo2code" - monkeypatch.setenv(FEATURE_FLAG_ENV, "1") - assert active_path_name() == "imported" - monkeypatch.setenv(FEATURE_FLAG_ENV, "0") - assert active_path_name() == "algo2code" - for truthy in ("true", "Yes", "ON"): - monkeypatch.setenv(FEATURE_FLAG_ENV, truthy) - assert active_path_name() == "imported", ( - f"truthy value {truthy!r} did not flip active path" - ) - - @pytest.mark.integration - def test_radial_return_signature_matches_imported(self) -> None: - """``mechdsl.lib.plasticity.radial_return`` exposes the same - keyword surface as the imported implementation so callers can - switch the import without code changes elsewhere. - """ - import inspect - - from mechdsl.symbolic.models.j2_power_law import ( - radial_return as imported_rr, - ) - - dispatcher_sig = inspect.signature(radial_return) - imported_sig = inspect.signature(imported_rr) - assert list(dispatcher_sig.parameters) == list(imported_sig.parameters) - - @pytest.mark.integration - def test_dispatch_preserves_imported_results_under_flag( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Under the feature flag, dispatcher output is bit-identical - to a direct call into the imported implementation. Sanity - check that the dispatcher does not silently transform the - result. - """ - from mechdsl.symbolic.models.j2_power_law import ( - radial_return as imported_rr, - ) - - mat = _sample_material() - E = np.array( - [[0.005, 0.0, 0.0], [0.0, -0.0025, 0.0], [0.0, 0.0, -0.0025]], - dtype=float, - ) - alpha_old = 0.001 - - monkeypatch.setenv(FEATURE_FLAG_ENV, "1") - via_dispatcher = radial_return(mat, E, alpha_old) - direct = imported_rr(mat, E, alpha_old) - - np.testing.assert_array_equal(via_dispatcher.stress, direct.stress) - assert via_dispatcher.alpha_new == direct.alpha_new - assert via_dispatcher.delta_lambda == direct.delta_lambda - assert via_dispatcher.is_plastic == direct.is_plastic - np.testing.assert_array_equal(via_dispatcher.tangent, direct.tangent) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_4.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_4.py deleted file mode 100644 index 0a1b809..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_4.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests for Task P5-4: imported vs algo2code parity test deliverable. - -P5-4's deliverable IS a new test file -``packages/mechdsl-core/tests/test_j2_radial_return_parity.py``. This -stub set is the meta-spec asserting the deliverable exists and pins -the three required parity cases plus the baseline-derived tolerance -contract. - -Acceptance criteria covered: -1. Parity test passes for elastic, elastoplastic, unloading load steps. -2. Tolerance derived from imported-path baseline, not absolute zero. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _deliverable_path() -> Path: - return _repo_root() / "packages" / "mechdsl-core" / "tests" / "test_j2_radial_return_parity.py" - - -class TestTaskP5_4: - """Tests for Task P5-4: parity-test deliverable.""" - - @pytest.mark.integration - def test_deliverable_file_exists(self) -> None: - path = _deliverable_path() - assert path.is_file(), f"P5-4 deliverable missing: {path}" - assert path.stat().st_size > 0 - - @pytest.mark.integration - def test_deliverable_covers_elastic_step(self) -> None: - text = _deliverable_path().read_text(encoding="utf-8") - assert "test_parity_elastic_load_step" in text, ( - "deliverable must define an elastic-step parity test" - ) - - @pytest.mark.integration - def test_deliverable_covers_elastoplastic_step(self) -> None: - text = _deliverable_path().read_text(encoding="utf-8") - assert "test_parity_elastoplastic_load_step" in text, ( - "deliverable must define an elastoplastic-step parity test" - ) - - @pytest.mark.integration - def test_deliverable_covers_unloading_step(self) -> None: - text = _deliverable_path().read_text(encoding="utf-8") - assert "test_parity_unloading_load_step" in text, ( - "deliverable must define an unloading-step parity test" - ) - - @pytest.mark.integration - def test_deliverable_uses_baseline_derived_tolerance(self) -> None: - text = _deliverable_path().read_text(encoding="utf-8") - # Tolerance contract: the deliverable must define and document a - # baseline-derived tolerance constant rather than asserting - # exact equality. - assert "BASELINE_TOL" in text, "deliverable must declare a BASELINE_TOL constant" - assert "imported-path baseline" in text or "Newton tolerance" in text, ( - "deliverable must reference the imported-path baseline as the " - "tolerance source (per plan line 267-268)" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_5.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_5.py deleted file mode 100644 index baa2cc5..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p5_5.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for Task P5-5: design-doc note on substitution + feature-flag fallback. - -Acceptance criteria covered: -1. Doc contains MECHDSL_USE_IMPORTED_RR mention. -2. Doc cross-links to dev/algorithms/radial_return_j2.tex. -3. Doc describes substitution default + fallback role. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "dev").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _doc_candidates() -> list[Path]: - """Per plan line 252-256, the note lands in 06-PLASTICITY.md or - 07-CONVENTIONS.md. Return whichever exists.""" - return [ - p - for p in ( - _repo_root() / "dev" / "design_docs" / "06-PLASTICITY.md", - _repo_root() / "dev" / "design_docs" / "07-CONVENTIONS.md", - ) - if p.is_file() - ] - - -def _doc_text() -> str: - docs = _doc_candidates() - assert docs, "neither 06-PLASTICITY.md nor 07-CONVENTIONS.md found" - return "\n".join(p.read_text(encoding="utf-8") for p in docs) - - -class TestTaskP5_5: - """Tests for Task P5-5: design-doc note on substitution.""" - - @pytest.mark.docs - def test_doc_mentions_feature_flag(self) -> None: - text = _doc_text() - assert "MECHDSL_USE_IMPORTED_RR" in text, ( - "design doc must mention the MECHDSL_USE_IMPORTED_RR env-var name" - ) - - @pytest.mark.docs - def test_doc_cross_links_algorithm_source(self) -> None: - text = _doc_text() - assert "dev/algorithms/radial_return_j2.tex" in text, ( - "design doc must reference dev/algorithms/radial_return_j2.tex" - ) - - @pytest.mark.docs - def test_doc_describes_substitution_default(self) -> None: - text = _doc_text().lower() - # The note must state that algo2code is default and imported is the - # feature-flagged fallback, matching post-Phase-5 reality. - assert "default" in text and "fallback" in text, ( - "design doc must describe default-vs-fallback dispatch roles" - ) - assert "algo2code" in text, "design doc must name the algo2code path" - assert "imported" in text, "design doc must name the imported (legacy) path" diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_1.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_1.py deleted file mode 100644 index 7746120..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_1.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Tests for Task P6-1: extract _e2e_helpers.py shared helper module. - -Acceptance criteria: -1. Module packages/mechdsl-core/tests/_e2e_helpers.py exists, exposes - _import_generated_module. -2. Imported helper has the same signature as the duplicated copies it - replaces. -""" - -from __future__ import annotations - -import importlib.util -import inspect -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _helpers_path() -> Path: - return _repo_root() / "packages" / "mechdsl-core" / "tests" / "_e2e_helpers.py" - - -class TestTaskP6_1: - """Tests for Task P6-1: shared helper module deliverable.""" - - @pytest.mark.unit - def test_helpers_module_exists(self) -> None: - path = _helpers_path() - assert path.is_file(), f"P6-1 deliverable missing: {path}" - assert path.stat().st_size > 0 - - @pytest.mark.unit - def test_helpers_exposes_import_generated_module(self) -> None: - path = _helpers_path() - spec = importlib.util.spec_from_file_location("_e2e_helpers_p6_1", path) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) # type: ignore[union-attr] - assert hasattr(module, "_import_generated_module"), ( - "_e2e_helpers must expose _import_generated_module" - ) - fn = module._import_generated_module - sig = inspect.signature(fn) - params = list(sig.parameters) - assert params[0] == "source" - assert params[1] == "tmp_path" - assert "name" in params diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_2.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_2.py deleted file mode 100644 index 2ad2984..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_2.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Tests for Task P6-2: swap test_p7_2 + test_e2e_taichi to use _e2e_helpers. - -Acceptance criteria: -1. Neither file contains a local copy of `_import_generated_module`. -2. Both files import the helper from `_e2e_helpers`. -3. Existing tests still pass (verified by running the affected files). -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -_TARGETS = ( - "packages/mechdsl-core/tests/test_e2e_taichi.py", - "packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_2.py", -) - - -class TestTaskP6_2: - """Tests for Task P6-2: e2e helper swap deliverable.""" - - @pytest.mark.unit - @pytest.mark.parametrize("relpath", _TARGETS) - def test_no_local_copy_remains(self, relpath: str) -> None: - path = _repo_root() / relpath - assert path.is_file(), f"target file missing: {path}" - text = path.read_text(encoding="utf-8") - assert not re.search(r"^def\s+_import_generated_module\b", text, re.MULTILINE), ( - f"{relpath} still defines _import_generated_module locally; " - "must import from _e2e_helpers" - ) - - @pytest.mark.unit - @pytest.mark.parametrize("relpath", _TARGETS) - def test_imports_helper_module(self, relpath: str) -> None: - path = _repo_root() / relpath - text = path.read_text(encoding="utf-8") - assert "_e2e_helpers" in text, f"{relpath} must import from _e2e_helpers (none found)" diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_3.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_3.py deleted file mode 100644 index d0ddb0b..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_3.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Tests for Task P6-3: robustify test_p7_4.py notes iteration. - -Acceptance criteria: -1. test_p7_4.py no longer indexes notes by position (e.g. ``notes[0]``) - when validating cross-link content. -2. The note-selection logic filters by plan-referenced filename so a - reordering of the candidate list does not change the assertion - target. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _target_path() -> Path: - return ( - _repo_root() - / "packages" - / "mechdsl-core" - / "tests" - / "plan_tests" - / "recovery_plan_latex_contract" - / "test_p7_4.py" - ) - - -class TestTaskP6_3: - """Tests for Task P6-3: notes-iteration robustness.""" - - @pytest.mark.unit - def test_no_positional_index_into_notes(self) -> None: - text = _target_path().read_text(encoding="utf-8") - # Tight match — the bare ``notes[0]`` pattern, not a substring of - # ``target_notes[0]`` or any other filtered list. The Phase-6 - # contract is "no positional index into the unfiltered candidate - # list", which the bare regex below catches without false-flagging - # the filtered-list deref that replaces it. - assert not re.search(r"(? None: - text = _target_path().read_text(encoding="utf-8") - # Robust filter must reference one of the plan-mentioned - # filenames inside an iteration over `notes`. - assert "for " in text and "notes" in text, ( - "test_p7_4.py must iterate over notes (no positional indexing)" - ) - assert "recovery_plan_latex_contract.md" in text or "drift_20_04.md" in text, ( - "test_p7_4.py iteration must filter by a plan-referenced filename" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_4.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_4.py deleted file mode 100644 index a7cccbd..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p6_4.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for Task P6-4: replace _INTENTIONAL_CLEANUP_MATCHES line-number -whitelist with regex/marker comment matching. - -Acceptance criteria: -1. test_phase6_exit.py no longer hardcodes line numbers for the - intentional cleanup sites. -2. test_emission_verification.py carries the in-source markers - (``# intentional-cleanup-site``) at the lines that were previously - tracked by absolute number. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -_PHASE6_EXIT = "packages/mechdsl-core/tests/test_phase6_exit.py" -_EMISSION = "packages/mechdsl-core/tests/test_emission_verification.py" - - -class TestTaskP6_4: - """Tests for Task P6-4: marker-driven cleanup-site whitelist.""" - - @pytest.mark.unit - def test_phase6_exit_no_line_number_whitelist(self) -> None: - text = (_repo_root() / _PHASE6_EXIT).read_text(encoding="utf-8") - # The old whitelist hardcoded `("…test_emission_verification.py", 747)` - # tuples. Phase 6 replaces them with marker-comment scanning. - assert "_INTENTIONAL_CLEANUP_MATCHES" not in text or ("intentional-cleanup-site" in text), ( - "test_phase6_exit.py must either drop _INTENTIONAL_CLEANUP_MATCHES " - "or rewrite it to scan for the in-source marker" - ) - # No bare integer-line-number entries pointing at - # test_emission_verification.py. (Pre-fix the whitelist held two - # hardcoded line numbers.) - import re - - bad = re.findall(r"test_emission_verification\.py\"?,\s*\d+", text) - assert not bad, ( - f"test_phase6_exit.py still hardcodes line numbers for " - f"test_emission_verification.py: {bad}" - ) - - @pytest.mark.unit - def test_emission_verification_has_markers(self) -> None: - text = (_repo_root() / _EMISSION).read_text(encoding="utf-8") - assert "intentional-cleanup-site" in text, ( - "test_emission_verification.py must mark its intentional cleanup " - "sites with the comment '# intentional-cleanup-site'" - ) - - @pytest.mark.unit - def test_phase6_exit_uses_marker_scan(self) -> None: - text = (_repo_root() / _PHASE6_EXIT).read_text(encoding="utf-8") - # The marker scan is the new mechanism — must be referenced. - assert "intentional-cleanup-site" in text, ( - "test_phase6_exit.py must scan for the in-source " - "'intentional-cleanup-site' marker comment" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_1.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_1.py deleted file mode 100644 index f165e9d..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_1.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Tests for Task P7-1: restore `## Inventory` anchor in dev/examples/README.md. - -Acceptance: README contains `## Inventory` heading. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "dev").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -class TestTaskP7_1: - @pytest.mark.docs - def test_examples_readme_has_inventory_anchor(self) -> None: - path = _repo_root() / "dev" / "examples" / "README.md" - assert path.is_file(), f"missing {path}" - text = path.read_text(encoding="utf-8") - assert re.search(r"^## Inventory\s*$", text, re.MULTILINE), ( - "dev/examples/README.md must contain a `## Inventory` heading" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_2.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_2.py deleted file mode 100644 index 2db045c..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_2.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Tests for Task P7-2: robustify test_p7_3.py ordering check + path matching. - -Acceptance: -1. Ordering check uses a first-runnable-code-block detector (markdown - ```python``` fence regex), not a bare `text.find("compile_latex(")` - that breaks if prose mentions appear earlier. -2. README path matching accepts `dev/examples/`, `./dev/examples/`, and - absolute prefixes. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _target() -> Path: - return ( - _repo_root() - / "packages" - / "mechdsl-core" - / "tests" - / "plan_tests" - / "recovery_plan_latex_contract" - / "test_p7_3.py" - ) - - -class TestTaskP7_2: - @pytest.mark.docs - def test_no_bare_text_find_for_compile_latex(self) -> None: - text = _target().read_text(encoding="utf-8") - # The bare `text.find("compile_latex(")` returns the position of - # the first prose mention — replace with a detector that looks - # at runnable code blocks only. - bad = re.search(r'text\.find\(\s*"compile_latex\(', text) - assert bad is None, ( - 'test_p7_3.py must not use bare text.find("compile_latex(") — ' - "use a runnable-code-block detector" - ) - - @pytest.mark.docs - def test_uses_runnable_code_block_detector(self) -> None: - text = _target().read_text(encoding="utf-8") - # Must reference a markdown code fence pattern (```python, ```bash, etc.) - # to scope the search to runnable blocks. - assert "```" in text or "code_fence" in text or "code_block" in text, ( - "test_p7_3.py must scope the ordering check to runnable code blocks" - ) - - @pytest.mark.docs - def test_path_matching_accepts_three_variants(self) -> None: - text = _target().read_text(encoding="utf-8") - # Loosened path matching must mention each acceptable prefix - # form so the regression check is documented. - for variant in ("dev/examples/", "./dev/examples/"): - assert variant in text, f"test_p7_3.py path matching must accept {variant!r}" - # Absolute-prefix branch — at minimum mention "absolute" or - # rely on Path.is_absolute logic. - assert "absolute" in text.lower() or "is_absolute" in text, ( - "test_p7_3.py must accept absolute-path prefixes" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_3.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_3.py deleted file mode 100644 index e0c7b48..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_3.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for Task P7-3: rename gen_p7_2 module-name + drop obsolete -traction-string-gap comment. - -Acceptance: -1. test_p7_2.py no longer hardcodes a literal `"gen_p7_2"` module name — - the helper invocation passes a fixture-derived value (e.g. uuid or - nodeid-based). -2. The traction-string-gap comment that pointed at Phase 1 closure is - removed (Phase 1 has landed). -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _target() -> Path: - return ( - _repo_root() - / "packages" - / "mechdsl-core" - / "tests" - / "plan_tests" - / "recovery_plan_latex_contract" - / "test_p7_2.py" - ) - - -class TestTaskP7_3: - @pytest.mark.docs - def test_module_name_no_longer_hardcoded(self) -> None: - text = _target().read_text(encoding="utf-8") - # No bare ``name="gen_p7_2"`` literal at any call site. - assert not re.search(r'name\s*=\s*"gen_p7_2"', text), ( - 'test_p7_2.py must not hardcode name="gen_p7_2" — ' - "derive from a fixture (uuid or pytest nodeid)" - ) - - @pytest.mark.docs - def test_module_name_uses_fixture_or_uuid(self) -> None: - text = _target().read_text(encoding="utf-8") - # Must reference a fixture-derived source — either uuid or the - # pytest request fixture's node id. - assert "uuid" in text or "request.node" in text or "request.nodeid" in text, ( - "test_p7_2.py must derive module name from uuid or pytest request fixture" - ) - - @pytest.mark.docs - def test_obsolete_traction_string_gap_comment_removed(self) -> None: - text = _target().read_text(encoding="utf-8") - # Phase 1 closure landed — the forward-pointer comment that - # said "blocked on Phase 1" must be gone. - assert "blocked on Phase 1" not in text.lower().replace("-", " ") - # The substantive traction-string-gap comment block (item 9 in - # the post_recovery_plan follow-ups) is no longer needed. - assert "traction-string gap" not in text and "traction string gap" not in text.lower(), ( - "obsolete traction-string-gap comment must be removed (Phase 1 has landed)" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_4.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_4.py deleted file mode 100644 index 2a4af93..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_4.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Tests for Task P7-4: trim test_p7_6.py to 100-250 lines by merging -redundant sub-bullets. - -Acceptance: -1. Line count in [100, 250]. -2. Test still passes after the trim (verified by running the file). -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "packages").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _target() -> Path: - return ( - _repo_root() - / "packages" - / "mechdsl-core" - / "tests" - / "plan_tests" - / "recovery_plan_latex_contract" - / "test_p7_6.py" - ) - - -class TestTaskP7_4: - @pytest.mark.docs - def test_test_p7_6_within_line_budget(self) -> None: - path = _target() - assert path.is_file(), f"missing {path}" - line_count = len(path.read_text(encoding="utf-8").splitlines()) - assert 100 <= line_count <= 250, ( - f"test_p7_6.py must be within [100, 250] lines; got {line_count}" - ) - - @pytest.mark.docs - def test_test_p7_6_still_documents_four_pillars(self) -> None: - text = _target().read_text(encoding="utf-8").lower() - # The four pillars (R1 frontend, R2 algo2code seam, R3 lib substitution, - # R4-R6 stable backend / docs / acceptance) must still be referenced - # post-trim — trim by merging redundant sub-bullets only. - for pillar in ("r1", "r2", "r3"): - assert pillar in text, ( - f"trim dropped pillar {pillar!r} — only redundant sub-bullets should be merged" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_5.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_5.py deleted file mode 100644 index 5c4db57..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_5.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Tests for Task P7-5: clarify Plan-B `_SUPERSEDED.md` runtime-active -vs archived sub-deliverables. - -Acceptance: ``_SUPERSEDED.md`` contains a sub-section listing -runtime-active vs archived Plan-B sub-deliverables. - -Resolution path: the original P7-5 edit landed at -``dev/tasks/PLAN-B/_SUPERSEDED.md``; subsequent main-branch archival -(commit 69c13b9) moved the Plan-B task folder under -``dev/archived/completed/PLAN-B/tasks/``. We accept both locations so -the test survives the move and any future re-archival. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "dev").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -_CANDIDATES = ( - ("dev", "tasks", "PLAN-B", "_SUPERSEDED.md"), - ("dev", "archived", "completed", "PLAN-B", "tasks", "_SUPERSEDED.md"), -) - - -def _target() -> Path: - root = _repo_root() - for parts in _CANDIDATES: - candidate = root.joinpath(*parts) - if candidate.is_file(): - return candidate - # Return the canonical (active) path so the failure message points - # at the location P7-5 originally targeted. - return root.joinpath(*_CANDIDATES[0]) - - -class TestTaskP7_5: - @pytest.mark.docs - def test_superseded_has_runtime_vs_archived_section(self) -> None: - path = _target() - assert path.is_file(), f"missing {path}" - text = path.read_text(encoding="utf-8") - lower = text.lower() - assert "runtime-active" in lower or "runtime active" in lower, ( - "_SUPERSEDED.md must label runtime-active sub-deliverables" - ) - assert "archived" in lower, "_SUPERSEDED.md must label archived sub-deliverables" - - @pytest.mark.docs - def test_superseded_has_explicit_subsection_heading(self) -> None: - text = _target().read_text(encoding="utf-8") - # A markdown heading (## or ###) referencing runtime-active / - # archived split must exist so readers can navigate to it. - import re - - assert re.search( - r"^#{2,4}\s+.*(runtime[- ]active|archived).*$", - text, - re.MULTILINE | re.IGNORECASE, - ), ( - "_SUPERSEDED.md must add a markdown heading distinguishing " - "runtime-active from archived sub-deliverables" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_6.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_6.py deleted file mode 100644 index 489cb4b..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_6.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Tests for Task P7-6: refresh GitNexus index. - -Phase 7 emits the refresh command but blocks on user authorization -(plan §"Allowed Deviations"). When `.gitnexus/meta.json` exists, the -test asserts `lastIndexed` is younger than the most recent source -change (HEAD commit time); otherwise the test documents the -intentionally-deferred no-op. - -Phase 7 cleanup: the original assertion compared `lastIndexed` to a -hardcoded `PHASE_START = date(2026, 5, 1)`. That date drifts out of -relevance the moment HEAD advances — the right invariant is "index -younger than the last source change", which is what GitNexus actually -guarantees post-refresh. -""" - -from __future__ import annotations - -import json -import subprocess -from datetime import UTC, datetime -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "dev").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _meta_path() -> Path: - return _repo_root() / ".gitnexus" / "meta.json" - - -def _head_commit_time() -> datetime | None: - """Return HEAD commit time as timezone-aware UTC datetime, or - ``None`` if not in a git checkout (test then falls back to a - 24-hour freshness window).""" - try: - proc = subprocess.run( - ["git", "log", "-1", "--format=%cI", "HEAD"], - cwd=_repo_root(), - capture_output=True, - text=True, - check=True, - timeout=5, - ) - except (subprocess.SubprocessError, FileNotFoundError): - return None - iso = proc.stdout.strip() - if not iso: - return None - # ``%cI`` is strict ISO-8601 with timezone (e.g. 2026-05-06T19:12:03+03:00). - return datetime.fromisoformat(iso) - - -def _parse_last_indexed(value: str) -> datetime: - """Parse the GitNexus ``lastIndexed`` field. Accepts ISO-8601 with - timezone; treats trailing ``Z`` as UTC; date-only strings are - interpreted as midnight UTC (worst case for the assertion, so the - check fails closed on coarse stamps).""" - s = value.strip() - if s.endswith("Z"): - s = s[:-1] + "+00:00" - if "T" not in s: - # Date-only — treat as start-of-day UTC. - return datetime.fromisoformat(s + "T00:00:00+00:00") - parsed = datetime.fromisoformat(s) - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=UTC) - return parsed - - -class TestTaskP7_6: - @pytest.mark.docs - def test_gitnexus_index_state_documented(self) -> None: - """Either an up-to-date `meta.json` exists with `lastIndexed` - younger than the most recent source change (HEAD commit time), - OR the index is intentionally absent (user has not authorised - the refresh yet).""" - meta = _meta_path() - if not meta.is_file(): - pytest.skip( - "No .gitnexus/meta.json — refresh requires user " - "authorization (plan §'Allowed Deviations'); skipped " - "rather than failed." - ) - data = json.loads(meta.read_text(encoding="utf-8")) - last_raw = data.get("lastIndexed") or data.get("last_indexed") or data.get("indexedAt") - assert last_raw is not None, ( - ".gitnexus/meta.json must record `lastIndexed` or GitNexus `indexedAt` after refresh" - ) - last_indexed = _parse_last_indexed(str(last_raw)) - head_time = _head_commit_time() - if head_time is None: - # Fallback: index must be younger than 24h. Captures the - # intent ("recent refresh") without a git dependency. - now = datetime.now(UTC) - age_seconds = (now - last_indexed).total_seconds() - assert age_seconds <= 24 * 3600, ( - f".gitnexus/meta.json lastIndexed={last_raw} is older " - f"than 24h ({age_seconds / 3600:.1f}h) — re-run " - "`npx gitnexus analyze` (with --embeddings if applicable)" - ) - else: - assert last_indexed >= head_time, ( - f".gitnexus/meta.json lastIndexed={last_raw} predates " - f"HEAD commit time {head_time.isoformat()} — re-run " - "`npx gitnexus analyze` (with --embeddings if applicable)" - ) - - @pytest.mark.docs - def test_embeddings_count_preserved_if_present(self) -> None: - """If `meta.json` previously recorded embeddings > 0, refresh - must preserve that count (i.e. used `--embeddings` flag).""" - meta = _meta_path() - if not meta.is_file(): - pytest.skip("no .gitnexus/meta.json — see authorization note") - data = json.loads(meta.read_text(encoding="utf-8")) - stats = data.get("stats", {}) - embeddings = stats.get("embeddings") - if embeddings is None or embeddings == 0: - pytest.skip( - "no pre-existing embeddings to preserve; --embeddings flag was not required" - ) - assert embeddings > 0, ( - "post-refresh embeddings count dropped to 0 — must rerun " - "`npx gitnexus analyze --embeddings` to preserve" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_7.py b/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_7.py deleted file mode 100644 index 7bd0f8d..0000000 --- a/packages/mechdsl-core/tests/plan_tests/post_recovery_plan/test_p7_7.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Tests for Task P7-7: add CI baseline-stability smoke job. - -Acceptance: -1. .github/workflows/*.yml contains a job confirming the algo2code - workspace install yields zero import failures. -2. Workflow YAML parses (syntactically valid). -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -try: - import yaml as _yaml -except ImportError: # pragma: no cover - _yaml = None - - -def _repo_root() -> Path: - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / ".github" / "workflows").is_dir(): - return parent - raise RuntimeError("repo root not found") - - -def _workflow_files() -> list[Path]: - root = _repo_root() - wf = root / ".github" / "workflows" - return sorted(wf.glob("*.yml")) + sorted(wf.glob("*.yaml")) - - -class TestTaskP7_7: - @pytest.mark.integration - def test_baseline_stability_job_present(self) -> None: - """At least one workflow file mentions a baseline-stability / - algo2code-import smoke job.""" - found = False - for f in _workflow_files(): - text = f.read_text(encoding="utf-8") - if ( - "baseline-stability" in text - or "baseline_stability" in text - or ("algo2code" in text and "import" in text and "collect-only" in text) - ): - found = True - break - assert found, ( - "no workflow under .github/workflows/ adds the post_recovery_plan " - "P7-7 baseline-stability smoke job" - ) - - @pytest.mark.integration - def test_all_workflow_yaml_parses(self) -> None: - if _yaml is None: - pytest.skip("PyYAML not available; workflow YAML parse skipped") - for f in _workflow_files(): - text = f.read_text(encoding="utf-8") - try: - _yaml.safe_load(text) - except _yaml.YAMLError as exc: - pytest.fail(f"{f.name} failed YAML parse: {exc}") diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/__init__.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_1.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_1.py deleted file mode 100644 index 49ab801..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_1.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Live audit for recovery-plan P1-1: Define two support tiers (MVP-stable / experimental). - -Asserts that README.md publishes the two tier names with their expected -membership boundaries. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[5] -README = REPO_ROOT / "README.md" - - -def _readme_text() -> str: - return README.read_text(encoding="utf-8") - - -class TestTaskP1_1: - """ - Tests for Task P1-1: Define two support tiers for the repo: `MVP-stable` and `experimental`. - Tier: docs - """ - - @pytest.mark.audit - def test_support_tiers_section_present(self) -> None: - """README.md exposes the canonical Support tiers heading.""" - text = _readme_text() - assert "## Support tiers" in text, "README missing '## Support tiers' heading" - - @pytest.mark.audit - def test_both_tier_names_and_canonical_membership_listed(self) -> None: - """Both tier names appear with their expected canonical / experimental members.""" - text = _readme_text() - assert "`MVP-stable`" in text, "MVP-stable tier name missing" - assert "`experimental`" in text, "experimental tier name missing" - # Canonical-tier surfaces called out by name - for canonical in ("Hex8", "Total Lagrangian", "Taichi"): - assert canonical in text, f"MVP-stable tier should reference {canonical!r}" - # Experimental-tier surfaces called out by name - for experimental in ("MFEM", "MOOSE"): - assert experimental in text, f"experimental tier should reference {experimental!r}" diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_2.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_2.py deleted file mode 100644 index 2b9fcef..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_2.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Live audit for recovery-plan P1-2: Mark experimental surfaces. - -Asserts that MFEM/MOOSE printers, the explicit-dynamics solver helper, -the non-MVP material model package, and the non-canonical element types -all carry an experimental-tier marker in their module/class documentation. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[5] -SRC = REPO_ROOT / "packages" / "mechdsl-core" / "src" / "mechdsl" - -MFEM_PRINTER = SRC / "codegen" / "mfem_printer.py" -MOOSE_PRINTER = SRC / "codegen" / "moose_printer.py" -LUMPED_MASS = SRC / "solver" / "lumped_mass.py" -MODELS_INIT = SRC / "symbolic" / "models" / "__init__.py" -MECHANICS_IR = SRC / "ir" / "mechanics_ir.py" - -EXPERIMENTAL_MARKER = "experimental" - - -def _module_doc(p: Path) -> str: - return p.read_text(encoding="utf-8") - - -class TestTaskP1_2: - """ - Tests for Task P1-2: Mark MFEM/MOOSE codegen, explicit dynamics, non-MVP - materials, and non-canonical elements as experimental. - Tier: docs - """ - - @pytest.mark.audit - def test_mfem_printer_marked_experimental(self) -> None: - text = _module_doc(MFEM_PRINTER) - assert EXPERIMENTAL_MARKER in text.lower(), ( - "mfem_printer.py module docstring should mark backend as experimental" - ) - - @pytest.mark.audit - def test_moose_printer_marked_experimental(self) -> None: - text = _module_doc(MOOSE_PRINTER) - assert EXPERIMENTAL_MARKER in text.lower(), ( - "moose_printer.py module docstring should mark backend as experimental" - ) - - @pytest.mark.audit - def test_explicit_dynamics_marked_experimental(self) -> None: - text = _module_doc(LUMPED_MASS) - assert EXPERIMENTAL_MARKER in text.lower(), ( - "lumped_mass.py module docstring should mark explicit dynamics as experimental" - ) - - @pytest.mark.audit - def test_non_mvp_materials_called_out(self) -> None: - text = _module_doc(MODELS_INIT) - for stable in ("svk", "j2_power_law"): - assert stable in text, f"models/__init__.py should name MVP-stable model {stable!r}" - for experimental in ( - "neo_hookean", - "mooney_rivlin", - "ogden", - "hgo", - "perzyna", - "johnson_cook", - "lemaitre", - ): - assert experimental in text, ( - f"models/__init__.py should name experimental model {experimental!r}" - ) - - @pytest.mark.audit - def test_non_canonical_elements_called_out(self) -> None: - text = _module_doc(MECHANICS_IR) - enum_match = re.search(r"class ElementType.*?(?=class \w)", text, flags=re.DOTALL) - assert enum_match, "ElementType class not located in mechanics_ir.py" - block = enum_match.group(0).lower() - assert "hex8" in block and "mvp-stable" in block, ( - "ElementType docstring should call out HEX8 as MVP-stable" - ) - assert EXPERIMENTAL_MARKER in block, ( - "ElementType docstring should label non-canonical elements experimental" - ) - for non_canonical in ("tet4", "tet10", "hex20"): - assert non_canonical in block, ( - f"ElementType docstring should mention non-canonical element {non_canonical!r}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_3.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_3.py deleted file mode 100644 index 4a69fbe..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_3.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Live audit for recovery-plan P1-3: Add a stability-policy note. - -Asserts the README has a Stability policy subsection that references both -the recovery plan and the status legend. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[5] -README = REPO_ROOT / "README.md" - - -def _readme_text() -> str: - return README.read_text(encoding="utf-8") - - -def _stability_policy_block() -> str: - text = _readme_text() - match = re.search( - r"^### Stability policy\b.*?(?=^### |^## )", text, flags=re.MULTILINE | re.DOTALL - ) - assert match, "README missing '### Stability policy' subsection" - return match.group(0) - - -class TestTaskP1_3: - """ - Tests for Task P1-3: Add a lightweight stability-policy note. - Tier: docs - """ - - @pytest.mark.audit - def test_stability_policy_subsection_present(self) -> None: - text = _readme_text() - assert "### Stability policy" in text, ( - "README should include a '### Stability policy' subsection under Support tiers" - ) - - @pytest.mark.audit - def test_policy_references_recovery_plan(self) -> None: - block = _stability_policy_block() - assert "recovery_plan_latex_contract.md" in block, ( - "Stability policy should reference the recovery plan" - ) - - @pytest.mark.audit - def test_policy_references_status_legend(self) -> None: - block = _stability_policy_block() - assert "STATUS_LEGEND.md" in block, ( - "Stability policy should point at the canonical tracker status legend" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_4.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_4.py deleted file mode 100644 index fdb04b9..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_4.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Live audit for recovery-plan P1-4: Normalize tracker vocabulary. - -Asserts the canonical status legend exists and lists all four required values, -and the MVP-plan tracker has been wired to reference it. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[5] -LEGEND = REPO_ROOT / "dev" / "tracking" / "STATUS_LEGEND.md" -MVP_TRACKER = REPO_ROOT / "dev" / "tracking" / "tasks-tracker_MVP_plan.md" - -REQUIRED_VALUES = ("not_started", "done", "deferred", "implemented-via-substitute") - - -class TestTaskP1_4: - """ - Tests for Task P1-4: Normalize tracker vocabulary. - Tier: docs - """ - - @pytest.mark.audit - def test_status_legend_file_exists(self) -> None: - assert LEGEND.is_file(), f"missing canonical legend: {LEGEND}" - - @pytest.mark.audit - def test_legend_lists_all_four_required_values(self) -> None: - text = LEGEND.read_text(encoding="utf-8") - for value in REQUIRED_VALUES: - assert f"`{value}`" in text, f"STATUS_LEGEND.md missing canonical value `{value}`" - - @pytest.mark.audit - def test_mvp_plan_tracker_references_legend(self) -> None: - text = MVP_TRACKER.read_text(encoding="utf-8") - assert "STATUS_LEGEND.md" in text, ( - "MVP_plan tracker should reference STATUS_LEGEND.md so future readers can resolve status values" - ) - assert "implemented-via-substitute" in text, ( - "MVP_plan tracker preamble should cite the new vocabulary explicitly" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_5.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_5.py deleted file mode 100644 index d2d8298..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_5.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Live audit for recovery-plan P1-5: Record frontend deferral as historical drift. - -Asserts the dev/reviews/frontend_drift_history.md note exists and -distinguishes 'planned but deferred' from 'never planned' from -'implemented via substitute'. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[5] -NOTE = REPO_ROOT / "dev" / "reviews" / "frontend_drift_history.md" - - -def _note_text() -> str: - return NOTE.read_text(encoding="utf-8") - - -class TestTaskP1_5: - """ - Tests for Task P1-5: Record the frontend deferral as historical execution drift. - Tier: docs - """ - - @pytest.mark.audit - def test_drift_note_exists(self) -> None: - assert NOTE.is_file(), f"missing frontend drift note: {NOTE}" - - @pytest.mark.audit - def test_distinguishes_planned_vs_never_planned_vs_substituted(self) -> None: - text = _note_text() - for pattern in ( - "Planned but never implemented", - "Never planned", - "Implemented via substitute", - ): - assert pattern in text, f"drift note missing classification: {pattern!r}" - - @pytest.mark.audit - def test_links_back_to_drift_audit_and_recovery_plan(self) -> None: - text = _note_text() - assert "drift_20_04.md" in text, ( - "drift note should reference the original audit (drift_20_04.md)" - ) - assert "recovery_plan_latex_contract.md" in text, ( - "drift note should reference the recovery plan that handles it" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_6.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_6.py deleted file mode 100644 index bb3226c..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p1_6.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Live audit for recovery-plan P1-6: MVP plans superseded banners. - -Asserts that MVP_plan.md and the three MVP_sprint plans carry a -supersession banner that points readers at the recovery plan. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[5] -PLANS_DIR = REPO_ROOT / "dev" / "plans" - -SUPERSEDED_PLANS = ( - PLANS_DIR / "MVP_plan.md", - PLANS_DIR / "MVP_sprint1.md", - PLANS_DIR / "MVP_sprint2.md", - PLANS_DIR / "MVP_sprint3.md", -) - - -def _head(p: Path, lines: int = 6) -> str: - return "\n".join(p.read_text(encoding="utf-8").splitlines()[:lines]) - - -class TestTaskP1_6: - """ - Tests for Task P1-6: Mark MVP plans as superseded. - Tier: docs - """ - - @pytest.mark.audit - def test_each_superseded_plan_has_top_banner(self) -> None: - for plan in SUPERSEDED_PLANS: - head = _head(plan) - assert "Superseded" in head, ( - f"{plan.relative_to(REPO_ROOT)} should have a Superseded banner near the top" - ) - assert "recovery_plan_latex_contract.md" in head, ( - f"{plan.relative_to(REPO_ROOT)} banner should reference the recovery plan" - ) - - @pytest.mark.audit - def test_main_plan_cites_status_vocabulary(self) -> None: - head = _head(PLANS_DIR / "MVP_plan.md") - # The main MVP plan's banner is the canonical place to cite the new - # status vocabulary; sprint plans link out via STATUS_LEGEND.md only. - assert "implemented-via-substitute" in head, ( - "MVP_plan.md banner should cite the implemented-via-substitute status" - ) - assert "STATUS_LEGEND.md" in head, "MVP_plan.md banner should reference STATUS_LEGEND.md" - - @pytest.mark.audit - def test_banner_links_drift_history_note(self) -> None: - head = _head(PLANS_DIR / "MVP_plan.md") - assert "frontend_drift_history.md" in head, ( - "MVP_plan.md banner should link the frontend_drift_history note from P1-5" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_1.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_1.py deleted file mode 100644 index d4f2831..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_1.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Live audit for recovery-plan P2-1: introduce the ``compile_latex`` façade. - -Asserts that ``mechdsl.compile_latex`` is importable, has the canonical -signature, accepts a minimal LaTeX source, and produces an -:class:`ArtifactBundle` with the right MVP enums baked in. -""" - -from __future__ import annotations - -import inspect - -import pytest - -from mechdsl import compile, compile_latex -from mechdsl.codegen.artifact import ArtifactBundle - -_MVP_LATEX = r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "t_bar" -""" - - -class TestTaskP2_1: - """ - Tests for Task P2-1: Introduce a canonical ``compile_latex`` façade. - Tier: unit - """ - - @pytest.mark.unit - def test_compile_latex_exported_from_mechdsl(self) -> None: - import mechdsl - - assert hasattr(mechdsl, "compile_latex"), ( - "mechdsl must export compile_latex as a public symbol" - ) - assert "compile_latex" in mechdsl.__all__, "compile_latex must be listed in mechdsl.__all__" - # The legacy programmatic API must remain available. - assert hasattr(mechdsl, "compile"), "mechdsl.compile must remain exported" - assert mechdsl.compile is compile - - @pytest.mark.unit - def test_signature_matches_canonical_form(self) -> None: - sig = inspect.signature(compile_latex) - params = list(sig.parameters.values()) - # The canonical core is (source, profile); constitutive_latex added the - # keyword-only energy_source / energy_file producers for strain-energy - # auto-population (both default None, so existing callers are unaffected). - assert [p.name for p in params] == [ - "source", - "profile", - "energy_source", - "energy_file", - ], f"unexpected parameter list: {[p.name for p in params]}" - # `source` must be positional, `profile` must default to "mvp" - source_param = sig.parameters["source"] - profile_param = sig.parameters["profile"] - assert source_param.default is inspect.Parameter.empty - assert profile_param.default == "mvp" - # The energy producers are keyword-only and optional. - for name in ("energy_source", "energy_file"): - p = sig.parameters[name] - assert p.kind is inspect.Parameter.KEYWORD_ONLY - assert p.default is None - - @pytest.mark.unit - def test_smoke_compile_latex_returns_artifact_bundle(self) -> None: - bundle = compile_latex(_MVP_LATEX) - assert isinstance(bundle, ArtifactBundle), ( - f"expected ArtifactBundle, got {type(bundle).__name__}" - ) - # The MVP enums must round-trip through the bundle's serialisable view. - ir_dict = bundle.problem_ir_dict - assert ir_dict["dim"] == 3 - assert ir_dict["formulation"] == "total_lagrangian" - assert ir_dict["element_type"] == "hex8" - assert ir_dict["material"]["model"] == "svk" - assert any(bc["bc_type"] == "dirichlet" for bc in ir_dict["boundaries"]) - assert any(bc["bc_type"] == "neumann" for bc in ir_dict["boundaries"]) - - @pytest.mark.unit - def test_non_mvp_profile_rejected(self) -> None: - with pytest.raises(ValueError, match="profile="): - compile_latex(_MVP_LATEX, profile="experimental") diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_2.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_2.py deleted file mode 100644 index 798e76d..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_2.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Live audit for recovery-plan P2-2: preserve ``build_context`` as documented secondary API. - -Asserts: -1. ``build_context`` is still importable and functional. -2. The frontend module docstring + the function docstring flag it as - secondary while pointing at ``compile_latex`` as canonical. -3. README's Quickstart leads with the LaTeX-source example, not the - programmatic ``build_context`` one. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[5] -README = REPO_ROOT / "README.md" -FRONTEND_INIT = ( - REPO_ROOT / "packages" / "mechdsl-core" / "src" / "mechdsl" / "frontend" / "__init__.py" -) - - -class TestTaskP2_2: - """ - Tests for Task P2-2: Preserve ``build_context()`` as documented secondary API. - Tier: unit - """ - - @pytest.mark.unit - def test_build_context_still_importable_and_functional(self) -> None: - from mechdsl.frontend import build_context - - ctx = build_context( - dim=3, - cell_type="hex8", - formulation="total_lagrangian", - material_type="svk", - params={"E": 200e3, "nu": 0.3}, - boundaries=[ - {"name": "fix", "type": "dirichlet", "value": 0.0}, - ], - ) - # Returns the same context-dict shape it always did. - assert ctx["dim"] == 3 - assert ctx["cell_type"] == "hex8" - assert ctx["material_type"] == "svk" - - @pytest.mark.unit - def test_frontend_module_doc_marks_build_context_secondary(self) -> None: - text = FRONTEND_INIT.read_text(encoding="utf-8") - # The module docstring should explicitly call out compile_latex as - # canonical and list build_context under a secondary header. - assert "compile_latex" in text, "frontend docstring should reference compile_latex" - assert "Secondary" in text or "secondary" in text, ( - "frontend module docstring should label build_context as secondary" - ) - - @pytest.mark.unit - def test_build_context_docstring_marks_secondary(self) -> None: - from mechdsl.frontend import build_context - - doc = build_context.__doc__ or "" - assert "secondary" in doc.lower(), "build_context docstring should mark itself as secondary" - assert "compile_latex" in doc, ( - "build_context docstring should point at compile_latex as canonical" - ) - - @pytest.mark.unit - def test_readme_quickstart_leads_with_latex_example(self) -> None: - text = README.read_text(encoding="utf-8") - quickstart_match = re.search( - r"^## Quickstart\b.*?(?=^## )", text, flags=re.MULTILINE | re.DOTALL - ) - assert quickstart_match, "README Quickstart section missing" - block = quickstart_match.group(0) - - latex_pos = block.find("compile_latex") - build_context_pos = block.find("build_context") - assert latex_pos != -1, "Quickstart should reference compile_latex" - assert build_context_pos != -1, ( - "Quickstart should still reference build_context as the secondary path" - ) - assert latex_pos < build_context_pos, ( - "Quickstart should show the LaTeX-source example before the " - "programmatic build_context example" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_3.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_3.py deleted file mode 100644 index 05655db..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_3.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Live audit for recovery-plan P2-3: define the frontend split. - -Asserts that each frontend module's docstring identifies its role -(scanner / normalizer / validator / index resolver) and that an -ARCHITECTURE.md exists alongside the source documenting the split. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[5] -FRONTEND = REPO_ROOT / "packages" / "mechdsl-core" / "src" / "mechdsl" / "frontend" - -ARCHITECTURE = FRONTEND / "ARCHITECTURE.md" -PARSER = FRONTEND / "parser.py" -DIRECTIVES = FRONTEND / "directives.py" -TWO_POINT = FRONTEND / "two_point.py" -INIT = FRONTEND / "__init__.py" - - -def _doc(p: Path) -> str: - return p.read_text(encoding="utf-8") - - -class TestTaskP2_3: - """ - Tests for Task P2-3: Define the frontend split — NRPyLaTeX = parser of record; - local code = adapter/normalizer/validator. - Tier: unit - """ - - @pytest.mark.unit - def test_architecture_md_present(self) -> None: - assert ARCHITECTURE.is_file(), ( - f"frontend ARCHITECTURE.md missing at {ARCHITECTURE.relative_to(REPO_ROOT)}" - ) - - @pytest.mark.unit - def test_architecture_describes_parser_of_record_split(self) -> None: - text = _doc(ARCHITECTURE) - # Both halves must be named explicitly so the split is unambiguous. - assert "parser of record" in text.lower(), ( - "ARCHITECTURE.md should use the phrase 'parser of record' to name NRPyLaTeX's role" - ) - assert "NRPyLaTeX" in text, "ARCHITECTURE.md should name NRPyLaTeX explicitly" - # The local-code triad should be enumerated. - for role in ("normalization", "validation"): - assert role.lower() in text.lower(), ( - f"ARCHITECTURE.md should name local-code role: {role!r}" - ) - - @pytest.mark.unit - def test_parser_module_docstring_calls_out_role(self) -> None: - text = _doc(PARSER) - assert "ARCHITECTURE.md" in text, ( - "parser.py docstring should reference frontend/ARCHITECTURE.md" - ) - # Parser scans + dispatches; it does NOT do math grammar. - assert "directive" in text.lower() - assert "NRPyLaTeX" in text or "math grammar" in text.lower(), ( - "parser.py docstring should contrast itself with the math-grammar parser" - ) - - @pytest.mark.unit - def test_directives_module_docstring_calls_out_role(self) -> None: - text = _doc(DIRECTIVES) - assert "ARCHITECTURE.md" in text - assert "normalization" in text.lower() or "normalizer" in text.lower(), ( - "directives.py docstring should identify itself as the normalization layer" - ) - - @pytest.mark.unit - def test_two_point_module_docstring_calls_out_role(self) -> None: - text = _doc(TWO_POINT) - assert "ARCHITECTURE.md" in text - assert "validator" in text.lower(), ( - "two_point.py docstring should identify itself as a validator" - ) - - @pytest.mark.unit - def test_init_module_docstring_separates_canonical_from_secondary(self) -> None: - text = _doc(INIT) - # P2-2 already added Canonical / Secondary split; P2-3 leans on it. - assert "Canonical" in text and "Secondary" in text, ( - "frontend/__init__.py module docstring should split entry points " - "into Canonical and Secondary sections" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_4.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_4.py deleted file mode 100644 index 52fa521..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_4.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Live audit for recovery-plan P2-4: reconcile old MVP P2.x rows with recovery tasks. - -Asserts that the legacy Phase-2 task rows (`P2.1..P2.5`) in the MVP_plan -tracker carry the canonical ``implemented-via-substitute`` status and cite -their substitute, removing the duplicate/conflicting frontend task set. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[5] -MVP_TRACKER = REPO_ROOT / "dev" / "tracking" / "tasks-tracker_MVP_plan.md" - -LEGACY_IDS = ("P2.1", "P2.2", "P2.3", "P2.4", "P2.5") - - -def _legacy_rows() -> dict[str, list[str]]: - """Return main-task-table rows for P2.1..P2.5. - - The MVP tracker also has a 3-column verification mapping table whose rows - start with ``| P2.`` — we discriminate by column count so only the main - 10-column task rows are collected. - """ - text = MVP_TRACKER.read_text(encoding="utf-8") - rows: dict[str, list[str]] = {} - for line in text.splitlines(): - if not line.startswith("| P2."): - continue - cells = [c.strip() for c in line.strip("|").split("|")] - if len(cells) < 9: # skip the verification mapping table (3 cols) - continue - if cells and cells[0] in LEGACY_IDS: - rows[cells[0]] = cells - return rows - - -class TestTaskP2_4: - """ - Tests for Task P2-4: Reconcile or replace old Phase 2 tasks with recovery tasks. - Tier: docs - """ - - @pytest.mark.audit - def test_all_five_legacy_rows_present(self) -> None: - rows = _legacy_rows() - missing = [tid for tid in LEGACY_IDS if tid not in rows] - assert not missing, f"missing legacy MVP rows in tracker: {missing}" - - @pytest.mark.audit - def test_no_legacy_row_still_marked_not_started(self) -> None: - rows = _legacy_rows() - for tid in LEGACY_IDS: - cells = rows[tid] - status = cells[2] - assert status != "not_started", ( - f"{tid} should no longer be `not_started`; current status is {status!r}" - ) - - @pytest.mark.audit - def test_each_legacy_row_uses_canonical_substitute_status(self) -> None: - rows = _legacy_rows() - for tid in LEGACY_IDS: - cells = rows[tid] - status = cells[2] - assert status == "implemented-via-substitute", ( - f"{tid} status should be `implemented-via-substitute`, found {status!r}" - ) - - @pytest.mark.audit - def test_each_legacy_row_cites_a_substitute(self) -> None: - rows = _legacy_rows() - for tid in LEGACY_IDS: - cells = rows[tid] - # The substitute citation lives in the `Verified by` column (index 8) - verified_by = cells[8] if len(cells) > 8 else "" - assert verified_by and verified_by != "—", ( - f"{tid} must cite its substitute in 'Verified by'; found {verified_by!r}" - ) - # At least one canonical-recovery-task ID OR a concrete code path - cites_recovery = re.search(r"recovery P\d-\d+", verified_by) is not None - cites_code = re.search(r"`[a-z_/]+\.py", verified_by) is not None - assert cites_recovery or cites_code, ( - f"{tid} 'Verified by' should cite a recovery task ID or a code path; " - f"found {verified_by!r}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_5.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_5.py deleted file mode 100644 index 2b86c9f..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_5.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Live audit for recovery-plan P2-5: minimal frontend contract test suite from LaTeX source. - -Per back2latex Phase 1 / amendment 4, the Phase-2 Code reality anchor -notes that ``tests/test_frontend.py is a stub`` and no test starts from -LaTeX source today. This suite closes that gap: it is the first set of -tests that begins with a real LaTeX-source string and reaches a -normalized frontend output (the context dict + a constructed -:class:`ProblemIR`). -""" - -from __future__ import annotations - -import pytest - -from mechdsl import compile_latex -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.frontend import parse -from mechdsl.frontend.directives import ParseError -from mechdsl.ir.mechanics_ir import ( - BCType, - ElementType, - Formulation, -) -from mechdsl.symbolic.convected import UnsupportedError - -_ELASTIC_LATEX = r""" -\documentclass{article} -\begin{document} - -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "t_bar" - -The governing equation is $\nabla \cdot \boldsymbol{P} + \boldsymbol{b} = 0$. -\end{document} -""" - - -_PLASTIC_LATEX = r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material j2_power_law --E 200e3 --nu 0.3 --sigma_y0 250 --K 500 --n 0.5 -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "t_bar" -""" - - -class TestFrontendContractFromLatex: - """The MVP-stable contract starts from LaTeX. These tests prove it. - - Tier: integration - """ - - @pytest.mark.integration - def test_elastic_latex_reaches_normalized_context(self) -> None: - ctx = parse(_ELASTIC_LATEX) - # The normalized context dict is the canonical Layer-1 output. - assert ctx["dim"] == 3 - assert ctx["cell_type"] == "hex8" - assert ctx["formulation"] == "total_lagrangian" - assert ctx["material_type"] == "svk" - assert ctx["params"]["E"] == 200e3 - assert ctx["params"]["nu"] == 0.3 - names = [bc["name"] for bc in ctx["boundaries"]] - assert names == ["fix", "load"] - - @pytest.mark.integration - def test_elastic_latex_compiles_to_artifact_bundle(self) -> None: - bundle = compile_latex(_ELASTIC_LATEX) - assert isinstance(bundle, ArtifactBundle) - ir = bundle.problem_ir_dict - assert ir["dim"] == 3 - assert ir["element_type"] == "hex8" - assert ir["formulation"] == "total_lagrangian" - assert ir["material"]["model"] == "svk" - # Validates the stable contract: emitted source is non-empty Taichi. - assert bundle.emitted_source, "compile_latex must produce non-empty Taichi source" - - @pytest.mark.integration - def test_plastic_latex_compiles_with_j2_params(self) -> None: - bundle = compile_latex(_PLASTIC_LATEX) - assert isinstance(bundle, ArtifactBundle) - material = bundle.problem_ir_dict["material"] - assert material["model"] == "j2_power_law" - assert material["params"]["sigma_y0"] == 250 - assert material["params"]["K"] == 500 - - @pytest.mark.integration - def test_problem_ir_round_trip_via_compile_latex(self) -> None: - # The recovered contract requires that LaTeX → ProblemIR → emitted source - # all stay in agreement. Construct an equivalent ProblemIR by hand and - # confirm the LaTeX-driven path matches the typed-enum representation. - bundle = compile_latex(_ELASTIC_LATEX) - ir = bundle.problem_ir_dict - assert ir["element_type"] == ElementType.HEX8.value - assert ir["formulation"] == Formulation.TOTAL_LAGRANGIAN.value - bc_types = {bc["bc_type"] for bc in ir["boundaries"]} - assert bc_types == {BCType.DIRICHLET.value, BCType.NEUMANN.value} - - -class TestFrontendContractRejection: - """Out-of-subset constructs must raise with stable, actionable messages. - - Tier: integration - """ - - @pytest.mark.integration - def test_unsupported_dim_raises_with_plan_b_pointer(self) -> None: - bad = r""" -% mechanics dim 2 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 -""" - with pytest.raises(UnsupportedError, match=r"Plan B"): - compile_latex(bad) - - @pytest.mark.integration - def test_malformed_directive_raises_parse_error(self) -> None: - bad = r""" -% mechanics boundary fix -""" - # `boundary` requires `--type`; missing it must raise a ParseError. - with pytest.raises(ParseError): - compile_latex(bad) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_6.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_6.py deleted file mode 100644 index 1dbe085..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p2_6.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Live audit for recovery-plan P2-6: contract-level errors from the canonical façade. - -Per recovery R1.6, frontend failures must produce contract-level errors -(``ParseError`` for malformed LaTeX, ``UnsupportedError`` for out-of-subset -constructs) with stable messages and Plan B / recovery-plan phase pointers -where relevant. These tests exercise the canonical entry point -:func:`mechdsl.compile_latex` and assert on the error type + message -shape — *not* on internal parser details — so the contract surface is -what's covered. -""" - -from __future__ import annotations - -import pytest - -from mechdsl import compile_latex -from mechdsl.frontend.directives import ParseError -from mechdsl.symbolic.convected import UnsupportedError - -# --------------------------------------------------------------------------- -# Out-of-subset constructs (must raise UnsupportedError with a phase pointer) -# --------------------------------------------------------------------------- - - -class TestUnsupportedConstructs: - """Constructs outside the MVP-supported subset must raise ``UnsupportedError``.""" - - @pytest.mark.integration - def test_dim_2_raises_with_plan_b_pointer(self) -> None: - with pytest.raises(UnsupportedError, match=r"Plan B phase B2"): - compile_latex( - r""" -% mechanics dim 2 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 -""" - ) - - @pytest.mark.integration - def test_unsupported_material_raises_with_plan_b_pointer(self) -> None: - with pytest.raises(UnsupportedError) as excinfo: - compile_latex( - r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material moonshine --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 -""" - ) - msg = str(excinfo.value) - assert "moonshine" in msg, "error must echo the offending material name" - assert "supported models" in msg, ( - "error must list the supported set so users can self-correct" - ) - - @pytest.mark.integration - def test_unsupported_formulation_raises(self) -> None: - with pytest.raises(UnsupportedError, match=r"formulation"): - compile_latex( - r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation eulerian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 -""" - ) - - @pytest.mark.integration - def test_unsupported_coord_system_raises_with_plan_b_pointer(self) -> None: - # The frontend handler doesn't accept `--coord_system spherical`; the - # build_context validator rejects it with a Plan B B2 pointer. - # We reach the validator by passing through build_context directly, - # since the directive parser itself only sets `coord_system="cartesian"`. - from mechdsl.frontend import build_context - - with pytest.raises(UnsupportedError, match=r"Plan B phase B2"): - build_context( - dim=3, - cell_type="hex8", - formulation="total_lagrangian", - material_type="svk", - params={"E": 200e3, "nu": 0.3}, - boundaries=[{"name": "fix", "type": "dirichlet", "value": 0.0}], - coord_system="spherical", - ) - - -# --------------------------------------------------------------------------- -# Malformed LaTeX (must raise ParseError, not crash the pipeline) -# --------------------------------------------------------------------------- - - -class TestMalformedLatex: - """Syntactically broken ``% mechanics`` directives must raise ``ParseError``.""" - - @pytest.mark.integration - def test_boundary_missing_required_type_option_raises(self) -> None: - with pytest.raises(ParseError, match=r"--type"): - compile_latex( - r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix -""" - ) - - @pytest.mark.integration - def test_boundary_too_many_positional_args_raises(self) -> None: - with pytest.raises(ParseError, match=r"positional"): - compile_latex( - r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix extra --type dirichlet --value 0 -""" - ) - - @pytest.mark.integration - def test_unknown_directive_raises(self) -> None: - # `% mechanics nonsense` is not in the handler set. - with pytest.raises(ParseError): - compile_latex( - r""" -% mechanics dim 3 -% mechanics nonsense foo --bar baz -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 -""" - ) - - @pytest.mark.integration - def test_deferred_directive_rejected_with_plan_b_pointer(self) -> None: - # `field`, `weak_form`, `constitutive`, `codegen`, `verify` are all - # documented in 02-LATEX-DSL.md but not part of the MVP subset. - with pytest.raises(ParseError) as excinfo: - compile_latex( - r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics field u --type vector -% mechanics boundary fix --type dirichlet --value 0 -""" - ) - msg = str(excinfo.value) - assert "Plan B" in msg or "deferred" in msg.lower(), ( - "deferred-directive rejection must mention Plan B or 'deferred' so " - "users know the construct is planned, not unknown" - ) - - -# --------------------------------------------------------------------------- -# Index semantics (recovery R1.6 calls out invalid index typing) -# --------------------------------------------------------------------------- - - -class TestIndexSemantics: - """Index-typing errors must surface as ``ParseError`` with stable messages.""" - - @pytest.mark.integration - def test_unknown_index_family_raises(self) -> None: - with pytest.raises(ParseError, match=r"index"): - compile_latex( - r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics index unknown_family i j k -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 -""" - ) - - -# --------------------------------------------------------------------------- -# Stable-message contract: every recovery error must mention something a -# user can act on. -# --------------------------------------------------------------------------- - - -class TestStableMessageContract: - """Failure messages must echo the offending construct so users can self-correct.""" - - @pytest.mark.integration - def test_unsupported_material_message_includes_offender_and_remedy(self) -> None: - with pytest.raises(UnsupportedError) as excinfo: - compile_latex( - r""" -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material foobar --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 -""" - ) - msg = str(excinfo.value) - assert "foobar" in msg, "message must echo the offending material name" - # Either a phase pointer or a list of supported alternatives counts as a remedy. - assert "Plan B" in msg or "supported models" in msg, ( - "message must point users at either a phase or a list of accepted values" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_1.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_1.py deleted file mode 100644 index c99a8ef..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_1.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Live audit for recovery-plan P3-1: enrich ProblemIR with semantic fields + serialization. - -Asserts that: -1. The four optional enrichment dataclasses exist and round-trip standalone. -2. ProblemIR carries the four new optional fields with safe defaults. -3. ``ProblemIR.to_dict() / from_dict()`` round-trip both legacy - (no enrichment fields) and enriched dicts. -4. Backward compatibility: every legacy construction site still works - without source changes, and a legacy dict deserializes to an equivalent - ProblemIR. -""" - -from __future__ import annotations - -import pytest - -from mechdsl.ir.mechanics_ir import ( - ALLOWED_FIELD_KINDS, - BCType, - BoundaryCondition, - DomainSpec, - ElementType, - FieldSpec, - Formulation, - MaterialSpec, - MeshContract, - ProblemIR, - ResidualContract, -) - - -def _make_legacy_problem_ir() -> ProblemIR: - """A ProblemIR built without any Phase-3 enrichment fields.""" - return ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - ) - - -def _make_enriched_problem_ir() -> ProblemIR: - """A ProblemIR built WITH every Phase-3 enrichment field populated.""" - return ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - fields=(FieldSpec(name="u", kind="vector", components=3),), - domain=DomainSpec(name="unit_cube", metadata={"bbox": [0, 1, 0, 1, 0, 1]}), - mesh_contract=MeshContract(region_tags=("fix", "load")), - residual_contract=ResidualContract( - terms=("internal_force", "external_force"), - weak_form_label="static_TL", - ), - ) - - -class TestEnrichmentDataclassesExist: - """The four optional enrichment types must round-trip standalone.""" - - @pytest.mark.unit - def test_field_spec_round_trip(self) -> None: - f = FieldSpec(name="u", kind="vector", components=3) - assert FieldSpec.from_dict(f.to_dict()) == f - - @pytest.mark.unit - def test_domain_spec_round_trip(self) -> None: - d = DomainSpec(name="unit_cube", metadata={"bbox": [0, 1, 0, 1, 0, 1]}) - assert DomainSpec.from_dict(d.to_dict()) == d - - @pytest.mark.unit - def test_mesh_contract_round_trip(self) -> None: - m = MeshContract(region_tags=("fix", "load"), metadata={"min_elements": 8}) - assert MeshContract.from_dict(m.to_dict()) == m - - @pytest.mark.unit - def test_residual_contract_round_trip(self) -> None: - r = ResidualContract( - terms=("internal_force", "external_force"), - weak_form_label="static_TL", - metadata={"linearity": "nonlinear"}, - ) - assert ResidualContract.from_dict(r.to_dict()) == r - - -class TestProblemIREnrichmentFields: - """ProblemIR must expose the four new enrichment fields with safe defaults.""" - - @pytest.mark.integration - def test_legacy_ir_has_safe_default_enrichment(self) -> None: - ir = _make_legacy_problem_ir() - assert ir.fields == () - assert ir.domain is None - assert ir.mesh_contract is None - assert ir.residual_contract is None - - @pytest.mark.integration - def test_enriched_ir_carries_populated_fields(self) -> None: - ir = _make_enriched_problem_ir() - assert len(ir.fields) == 1 - assert ir.fields[0].name == "u" - assert ir.domain is not None and ir.domain.name == "unit_cube" - assert ir.mesh_contract is not None - assert ir.mesh_contract.region_tags == ("fix", "load") - assert ir.residual_contract is not None - assert ir.residual_contract.weak_form_label == "static_TL" - - -class TestProblemIRRoundTrip: - """Serialization round-trips must preserve both legacy and enriched forms.""" - - @pytest.mark.integration - def test_legacy_round_trip_equal(self) -> None: - ir = _make_legacy_problem_ir() - round_tripped = ProblemIR.from_dict(ir.to_dict()) - assert round_tripped == ir - - @pytest.mark.integration - def test_enriched_round_trip_equal(self) -> None: - ir = _make_enriched_problem_ir() - round_tripped = ProblemIR.from_dict(ir.to_dict()) - assert round_tripped == ir - - @pytest.mark.integration - def test_to_dict_includes_enrichment_keys(self) -> None: - ir = _make_legacy_problem_ir() - d = ir.to_dict() - for key in ("fields", "domain", "mesh_contract", "residual_contract"): - assert key in d, f"to_dict() must always emit `{key}` for forward consumers" - - @pytest.mark.integration - def test_from_dict_accepts_legacy_dict_without_enrichment_keys(self) -> None: - # Build a dict that deliberately omits the four new keys to mimic - # a pre-recovery golden file. - legacy_dict = { - "dim": 3, - "formulation": "total_lagrangian", - "element_type": "hex8", - "material": {"model": "svk", "params": {"E": 200e3, "nu": 0.3}}, - "boundaries": [ - {"name": "fix", "bc_type": "dirichlet"}, - ], - } - ir = ProblemIR.from_dict(legacy_dict) - # Defaults rebuild correctly. - assert ir.fields == () - assert ir.domain is None - assert ir.mesh_contract is None - assert ir.residual_contract is None - - -class TestEnrichmentInvariants: - """The frozen-dataclass invariant must extend through metadata bags. - - Bare ``frozen=True`` only blocks attribute reassignment; nested dicts can - still be mutated. The enrichment dataclasses wrap their metadata in - ``MappingProxyType`` to close that gap. - """ - - @pytest.mark.unit - def test_field_spec_rejects_unknown_kind(self) -> None: - with pytest.raises(ValueError, match=r"kind="): - FieldSpec(name="u", kind="matrix") - - @pytest.mark.unit - def test_field_spec_accepts_every_allowlisted_kind(self) -> None: - for kind in ALLOWED_FIELD_KINDS: - FieldSpec(name="u", kind=kind) - - @pytest.mark.unit - def test_allowed_field_kinds_is_frozenset(self) -> None: - assert isinstance(ALLOWED_FIELD_KINDS, frozenset) - assert frozenset({"scalar", "vector", "tensor"}) == ALLOWED_FIELD_KINDS - - @pytest.mark.unit - def test_domain_spec_metadata_is_immutable(self) -> None: - d = DomainSpec(name="cube", metadata={"bbox": [0, 1]}) - with pytest.raises(TypeError): - d.metadata["bbox"] = [2, 3] - # MappingProxyType has no `pop`; the AttributeError is itself the - # invariant we want — write-style methods are not exposed at all. - with pytest.raises(AttributeError): - d.metadata.pop("bbox") - - @pytest.mark.unit - def test_mesh_contract_metadata_is_immutable(self) -> None: - m = MeshContract(region_tags=("a",), metadata={"min_elements": 8}) - with pytest.raises(TypeError): - m.metadata["min_elements"] = 16 - - @pytest.mark.unit - def test_residual_contract_metadata_is_immutable(self) -> None: - r = ResidualContract(terms=("internal",), metadata={"linearity": "nonlinear"}) - with pytest.raises(TypeError): - r.metadata["linearity"] = "linear" - - @pytest.mark.unit - def test_immutable_metadata_still_round_trips(self) -> None: - d = DomainSpec(name="cube", metadata={"bbox": [0, 1]}) - m = MeshContract(region_tags=("a",), metadata={"min_elements": 8}) - r = ResidualContract(terms=("internal",), metadata={"linearity": "nonlinear"}) - assert DomainSpec.from_dict(d.to_dict()) == d - assert MeshContract.from_dict(m.to_dict()) == m - assert ResidualContract.from_dict(r.to_dict()) == r diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_2.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_2.py deleted file mode 100644 index 1b56477..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_2.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Live audit for recovery-plan P3-2: compatibility constructors / adapters. - -P3-2 promotes the previously-private ``_problem_ir_from_context`` adapter -to a first-class :meth:`ProblemIR.from_context` classmethod, with a sibling -:meth:`BoundaryCondition.from_context` for the boundary subschema. Three -private duplicates (in ``mechdsl/__init__.py``, ``test_full_pipeline.py``, -``test_formulation_switching.py``) are retired in the same change. - -These tests verify: - -1. The classmethods exist and adapt every documented context-dict shape - (canonical ``name`` / legacy ``region`` / face-tagged ``face`` / - missing-name fallback) into an equivalent :class:`ProblemIR`. -2. The legacy private symbol ``_problem_ir_from_context`` is gone from - :mod:`mechdsl` (so re-introducing it is a discoverable regression). -3. The end-to-end :func:`compile_latex` pipeline still works after the - refactor (the user-visible behaviour is unchanged). -4. The adapter accepts optional context keys (``params``, ``traction``) - without breaking when omitted. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from mechdsl import compile_latex -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.frontend import build_context -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - ProblemIR, -) - - -def _mvp_context(**overrides: Any) -> dict[str, Any]: - """Build a canonical MVP context dict that downstream tests can perturb.""" - base: dict[str, Any] = dict( - dim=3, - cell_type="hex8", - formulation="total_lagrangian", - material_type="svk", - params={"E": 200e3, "nu": 0.3}, - boundaries=[ - {"name": "fix", "type": "dirichlet", "value": 0.0, "components": [0, 1, 2]}, - {"name": "load", "type": "neumann", "traction": "t_bar"}, - ], - ) - base.update(overrides) - return base - - -class TestProblemIRFromContext: - """`ProblemIR.from_context` is the canonical context-dict adapter.""" - - @pytest.mark.unit - def test_classmethod_exists(self) -> None: - # Sanity guard against a refactor that silently retires the API. - assert hasattr(ProblemIR, "from_context") - assert callable(ProblemIR.from_context) - - @pytest.mark.unit - def test_canonical_context_round_trips_to_ir(self) -> None: - ctx = _mvp_context() - ir = ProblemIR.from_context(ctx) - assert ir.dim == 3 - assert ir.formulation is Formulation.TOTAL_LAGRANGIAN - assert ir.element_type is ElementType.HEX8 - assert ir.material.model == "svk" - assert ir.material.params == {"E": 200e3, "nu": 0.3} - assert len(ir.boundaries) == 2 - assert ir.boundaries[0].name == "fix" - assert ir.boundaries[0].bc_type is BCType.DIRICHLET - assert ir.boundaries[1].name == "load" - assert ir.boundaries[1].bc_type is BCType.NEUMANN - assert ir.boundaries[1].traction == "t_bar" - - @pytest.mark.unit - def test_omitted_params_default_to_empty_dict(self) -> None: - # The MVP context emitted by build_context always carries `params`, - # but third-party callers may construct context dicts directly. The - # adapter must default the optional key to an empty dict (the IR's - # P3-5 validation surfaces the missing-required-params error from - # there — that's a separate failure mode from this test). - ctx = _mvp_context() - ctx.pop("params") - # Use a model that does not have required params in the MVP table so - # this test isolates the adapter behaviour from the P3-5 validation. - ctx["material_type"] = "neo_hookean" - ir = ProblemIR.from_context(ctx) - assert ir.material.params == {} - - @pytest.mark.unit - def test_build_context_output_round_trips(self) -> None: - # `build_context` is the documented programmatic entry point; its - # output must be directly usable by `from_context`. This guards - # against a future schema drift between the two helpers. - ctx = build_context( - dim=3, - cell_type="hex8", - formulation="total_lagrangian", - material_type="svk", - params={"E": 200.0e3, "nu": 0.3}, - boundaries=[ - {"name": "fix", "face": "x0", "type": "dirichlet", "dofs": [0, 1, 2]}, - {"name": "load", "face": "x1", "type": "neumann", "traction": "t_bar"}, - ], - ) - ir = ProblemIR.from_context(ctx) - assert ir.boundaries[0].components == (0, 1, 2) - - -class TestBoundaryConditionFromContext: - """`BoundaryCondition.from_context` covers all three name shapes.""" - - @pytest.mark.unit - @pytest.mark.parametrize( - "name_keys, expected", - [ - ({"name": "fix"}, "fix"), - ({"region": "Omega_d"}, "Omega_d"), - ({"face": "x0"}, "x0"), - ({}, "bc_3"), # fallback uses the index parameter - ], - ) - def test_name_priority_chain(self, name_keys: dict[str, Any], expected: str) -> None: - raw: dict[str, Any] = {"type": "dirichlet"} - raw.update(name_keys) - bc = BoundaryCondition.from_context(raw, index=3) - assert bc.name == expected - - @pytest.mark.unit - def test_dofs_alias_components(self) -> None: - # Frontend dicts emitted by older codepaths use `dofs`, not the - # canonical `components`. The adapter accepts both. - bc = BoundaryCondition.from_context({"name": "fix", "type": "dirichlet", "dofs": [0, 2]}) - assert bc.components == (0, 2) - - @pytest.mark.unit - def test_components_takes_priority_over_dofs(self) -> None: - # If both keys are present, `components` wins (it is the canonical - # name introduced by P1-1). - bc = BoundaryCondition.from_context( - {"name": "fix", "type": "dirichlet", "components": [1], "dofs": [0, 2]} - ) - assert bc.components == (1,) - - @pytest.mark.unit - def test_optional_keys_default_correctly(self) -> None: - # post_recovery_plan P1-1 requires Neumann BCs to carry a traction - # spec; default-construction now uses Dirichlet so unrelated - # default-handling assertions still apply. - bc = BoundaryCondition.from_context({"name": "fix", "type": "dirichlet"}) - assert bc.field_name == "u" - assert bc.value == 0.0 - assert bc.traction is None - assert bc.components == (0, 1, 2) - - @pytest.mark.unit - def test_neumann_without_traction_rejected_post_p1_1(self) -> None: - # post_recovery_plan P1-1 added validation that rejects Neumann BCs - # missing a traction. from_context surfaces the same error. - with pytest.raises(ValueError, match="post_recovery_plan Phase 1"): - BoundaryCondition.from_context({"name": "load", "type": "neumann"}) - - -class TestPrivateDuplicatesRetired: - """The three pre-P3-2 private adapters must be gone.""" - - @pytest.mark.unit - def test_mechdsl_module_no_longer_exposes_private_helper(self) -> None: - import mechdsl - - assert not hasattr(mechdsl, "_problem_ir_from_context"), ( - "P3-2 retired the private `_problem_ir_from_context` helper in " - "favour of `ProblemIR.from_context`. Re-introducing it would " - "split the canonical adapter again — extend the classmethod " - "instead." - ) - - @pytest.mark.unit - def test_test_modules_no_longer_define_private_helpers(self) -> None: - # The two pre-existing test files duplicated the adapter logic - # because there was no canonical home. After P3-2, that home exists - # — so the duplicates must be removed. - from tests import test_formulation_switching, test_full_pipeline - - assert not hasattr(test_full_pipeline, "_problem_ir_from_context") - assert not hasattr(test_full_pipeline, "_boundary_condition_from_context") - assert not hasattr(test_formulation_switching, "_boundary_condition_from_context") - - -class TestCompileLatexEndToEndStillWorks: - """The user-visible `compile_latex` pipeline must be unchanged after P3-2.""" - - @pytest.mark.integration - def test_compile_latex_end_to_end_returns_artifact_bundle(self) -> None: - source = """ -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "t_bar" -""" - bundle = compile_latex(source) - assert isinstance(bundle, ArtifactBundle) - # The IR carried inside the bundle came from `ProblemIR.from_context` - # — its acceptance is the integration check that P3-2's refactor did - # not silently drift from the pre-P3-2 path. - assert bundle.problem_ir_dict["element_type"] == ElementType.HEX8.value - assert bundle.problem_ir_dict["material"]["model"] == "svk" diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_3.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_3.py deleted file mode 100644 index 660fd1c..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_3.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Live audit for recovery-plan P3-3: boundary/domain assumptions on the IR. - -P3-3 moves the previously-scattered "BC name == mesh boundary tag" -assumption into the IR layer: - -- :meth:`ProblemIR.required_region_tags` enumerates the mesh region tags - the problem needs in one place. -- :meth:`ProblemIR.derived_mesh_contract` materializes a - :class:`MeshContract` (either the explicit one or one synthesized from - the BC names) so downstream consumers never branch on - ``mesh_contract is None``. -- :func:`mechdsl.solver.mesh_io.validate_mesh_against_contract` is the - single check that the runtime mesh carries every region the IR needs; - pre-P3-3 each consumer (assemblers, codegen runtime, BC compilers) - re-derived the lookup as ``mesh.boundary_tags[bc.name]`` and surfaced - the failure as a deep ``KeyError``. - -These tests verify the new helpers exist, return consistent answers, and -that the duplication-reduction goal is met by exercising the full -``ProblemIR → MeshContract → HexMesh`` validation path. -""" - -from __future__ import annotations - -import pytest - -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - BoundaryRegionError, - ElementType, - Formulation, - MaterialSpec, - MeshContract, - ProblemIR, -) -from mechdsl.solver.mesh_io import generate_hex8_mesh, validate_mesh_against_contract - - -def _mvp_problem(*, mesh_contract: MeshContract | None = None) -> ProblemIR: - return ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=( - BoundaryCondition(name="x0", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="x1", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - mesh_contract=mesh_contract, - ) - - -class TestRequiredRegionTags: - """`required_region_tags` is the single source of truth for required regions.""" - - @pytest.mark.regression - def test_default_returns_bc_names_in_order(self) -> None: - ir = _mvp_problem() - assert ir.required_region_tags() == ("x0", "x1") - - @pytest.mark.regression - def test_explicit_mesh_contract_takes_priority(self) -> None: - # Explicit names lead so documentation order is preserved. - ir = _mvp_problem(mesh_contract=MeshContract(region_tags=("x0", "x1"))) - assert ir.required_region_tags() == ("x0", "x1") - - @pytest.mark.regression - def test_explicit_contract_extras_appear_first(self) -> None: - # MeshContract may declare regions the BCs do not yet reference - # (planned future BCs, mesh-validation-only tags). They must come - # first in the result so MeshContract's own ordering wins. - ir = _mvp_problem( - mesh_contract=MeshContract(region_tags=("future_bc", "x0", "x1")), - ) - assert ir.required_region_tags() == ("future_bc", "x0", "x1") - - @pytest.mark.regression - def test_bc_only_names_appended_when_missing_from_contract(self) -> None: - # If the contract enumerates only some BC names, the rest are - # appended in BC-declaration order (so nothing silently drops). - ir = _mvp_problem(mesh_contract=MeshContract(region_tags=("x0",))) - assert ir.required_region_tags() == ("x0", "x1") - - -class TestDerivedMeshContract: - """`derived_mesh_contract` materializes a usable contract for every IR.""" - - @pytest.mark.regression - def test_returns_explicit_contract_unchanged(self) -> None: - explicit = MeshContract(region_tags=("x0", "x1"), metadata={"min_elements": 8}) - ir = _mvp_problem(mesh_contract=explicit) - # Identity, not equality — same object passes through, so callers - # see the explicit metadata bag without a copy round-trip. - assert ir.derived_mesh_contract() is explicit - - @pytest.mark.regression - def test_synthesizes_when_implicit(self) -> None: - ir = _mvp_problem() - derived = ir.derived_mesh_contract() - assert isinstance(derived, MeshContract) - assert derived.region_tags == ("x0", "x1") - # The synthesized contract has empty metadata; that is the marker - # downstream tests can use to tell synthesized from explicit. - assert dict(derived.metadata) == {} - - @pytest.mark.regression - def test_required_tags_match_derived_contract_tags(self) -> None: - # Invariant: the two helpers always agree on the tag set so - # downstream layers can pick either entry point. - ir = _mvp_problem( - mesh_contract=MeshContract(region_tags=("future_bc", "x0", "x1")), - ) - assert ir.required_region_tags() == ir.derived_mesh_contract().region_tags - - -class TestValidateMeshAgainstContract: - """The mesh / IR boundary check raises BoundaryRegionError on mismatch.""" - - @pytest.mark.regression - def test_fully_tagged_mesh_passes(self) -> None: - mesh = generate_hex8_mesh(nx=2, ny=2, nz=2, Lx=1.0, Ly=1.0, Lz=1.0) - ir = _mvp_problem() - # Should not raise — the structured mesh tags x0/x1/y0/y1/z0/z1 by - # default, which covers what the IR needs. - validate_mesh_against_contract(mesh, ir.derived_mesh_contract()) - - @pytest.mark.regression - def test_missing_tag_raises_boundary_region_error(self) -> None: - mesh = generate_hex8_mesh(nx=2, ny=2, nz=2, Lx=1.0, Ly=1.0, Lz=1.0) - # Drop the tag the IR will require. The mesh is now incomplete. - del mesh.boundary_tags["x1"] - ir = _mvp_problem() - with pytest.raises(BoundaryRegionError, match=r"missing required boundary tags \['x1'\]"): - validate_mesh_against_contract(mesh, ir.derived_mesh_contract()) - - @pytest.mark.regression - def test_error_message_lists_mesh_and_contract_tags(self) -> None: - mesh = generate_hex8_mesh(nx=2, ny=2, nz=2, Lx=1.0, Ly=1.0, Lz=1.0) - ir = _mvp_problem( - mesh_contract=MeshContract(region_tags=("nonexistent", "x0", "x1")), - ) - with pytest.raises(BoundaryRegionError) as exc: - validate_mesh_against_contract(mesh, ir.derived_mesh_contract()) - # The user needs both halves of the mismatch in the message to see - # what's wrong — pre-P3-3 a bare KeyError gave them only one half. - assert "nonexistent" in str(exc.value) - assert "x0" in str(exc.value) - - -class TestDuplicationReductionInvariant: - """Sanity guard: helpers ship from the IR module, not duplicated downstream.""" - - @pytest.mark.regression - def test_helpers_live_on_problem_ir(self) -> None: - # If a future refactor moves these to a downstream layer, the - # duplication this PR removed will return — surface the regression. - assert hasattr(ProblemIR, "required_region_tags") - assert hasattr(ProblemIR, "derived_mesh_contract") - assert callable(ProblemIR.required_region_tags) - assert callable(ProblemIR.derived_mesh_contract) - - @pytest.mark.regression - def test_validate_helper_lives_on_solver_mesh_io(self) -> None: - # The mesh ↔ IR-contract bridge is owned by `solver.mesh_io` — - # asserting the import path here keeps consumers off ad-hoc copies. - from mechdsl.solver import mesh_io - - assert hasattr(mesh_io, "validate_mesh_against_contract") - assert callable(mesh_io.validate_mesh_against_contract) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_4.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_4.py deleted file mode 100644 index 2c78fb4..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_4.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Live audit for recovery-plan P3-4: stable `ProblemIR` minimal subset. - -Asserts that: - -1. ``MVP_STABLE_SUBSET`` is an immutable, well-typed snapshot of the - contract. -2. Every value enumerated in ``MVP_STABLE_SUBSET`` produces an IR that - passes ``assert_mvp_stable`` and reports ``is_mvp_stable() == True``. -3. Every dimension that the broader ``ProblemIR`` schema accepts but the - MVP-stable subset rejects (UL formulation, TET4/TET10/HEX20 elements, - non-MVP materials, EXPLICIT dynamics) raises ``MvpSubsetViolation`` - with a message that names the offending field and points at the - Plan-B phase that adds support. -4. ``MvpSubsetViolation`` subclasses ``UnsupportedError`` so callers that - catch the broader supported-subset exception still work. -5. ``MVP_STABLE_SUBSET`` stays in lock-step with - ``ALLOWED_PROFILES`` in :mod:`mechdsl` and the README support tier. -""" - -from __future__ import annotations - -import pytest - -from mechdsl import ALLOWED_PROFILES -from mechdsl.ir.mechanics_ir import ( - MVP_STABLE_SUBSET, - BCType, - BoundaryCondition, - Configuration, - DynamicsMode, - ElementType, - Formulation, - MaterialSpec, - MvpStableSubset, - MvpSubsetViolation, - ProblemIR, -) -from mechdsl.symbolic.convected import UnsupportedError - -# Per-model param packs that satisfy the P3-5 required-parameters validation -# for every model the MVP-stable subset enumerates. Tests that swap models -# pull from this table so they do not collide with P3-5 surface-level checks. -_PARAMS_BY_MODEL: dict[str, dict[str, float]] = { - "svk": {"E": 200e3, "nu": 0.3}, - "j2_power_law": {"E": 200e3, "nu": 0.3, "sigma_y0": 250.0, "K": 1000.0, "n": 10.0}, -} - - -def _mvp_problem_ir( - *, - formulation: Formulation = Formulation.TOTAL_LAGRANGIAN, - element_type: ElementType = ElementType.HEX8, - material_model: str = "svk", - dynamics_mode: DynamicsMode | None = None, -) -> ProblemIR: - """Build a ProblemIR pre-loaded with MVP-stable defaults. - - Parameters allow swapping a single axis at a time so negative tests can - exercise one violation per case without redundant setup. - """ - params = _PARAMS_BY_MODEL.get(material_model, {"E": 200e3, "nu": 0.3}) - return ProblemIR( - dim=3, - formulation=formulation, - element_type=element_type, - material=MaterialSpec(model=material_model, params=params), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - dynamics_mode=dynamics_mode, - ) - - -class TestMvpStableSubsetDescriptor: - """The descriptor must expose an immutable, well-typed contract snapshot.""" - - @pytest.mark.unit - def test_subset_is_frozen_dataclass_instance(self) -> None: - from dataclasses import FrozenInstanceError - - assert isinstance(MVP_STABLE_SUBSET, MvpStableSubset) - with pytest.raises(FrozenInstanceError): - MVP_STABLE_SUBSET.dim = (2, 3) # type: ignore[misc] - - @pytest.mark.unit - def test_subset_axes_are_tuples_not_sets(self) -> None: - # Tuples preserve documentation order; sets would shuffle it. - assert isinstance(MVP_STABLE_SUBSET.dim, tuple) - assert isinstance(MVP_STABLE_SUBSET.formulations, tuple) - assert isinstance(MVP_STABLE_SUBSET.element_types, tuple) - assert isinstance(MVP_STABLE_SUBSET.materials, tuple) - assert isinstance(MVP_STABLE_SUBSET.dynamics_modes, tuple) - assert isinstance(MVP_STABLE_SUBSET.configurations, tuple) - - @pytest.mark.unit - def test_subset_documents_canonical_mvp_axes(self) -> None: - assert MVP_STABLE_SUBSET.dim == (3,) - assert MVP_STABLE_SUBSET.formulations == (Formulation.TOTAL_LAGRANGIAN,) - assert MVP_STABLE_SUBSET.element_types == (ElementType.HEX8,) - assert MVP_STABLE_SUBSET.materials == ("svk", "j2_power_law") - assert MVP_STABLE_SUBSET.dynamics_modes == (DynamicsMode.STATIC,) - assert MVP_STABLE_SUBSET.configurations == (Configuration.REFERENCE,) - - -class TestMvpStableSubsetPositive: - """Every value enumerated in the subset must accept cleanly.""" - - @pytest.mark.unit - def test_canonical_mvp_ir_passes_assert(self) -> None: - ir = _mvp_problem_ir() - ir.assert_mvp_stable() - assert ir.is_mvp_stable() is True - - @pytest.mark.unit - @pytest.mark.parametrize("model", MVP_STABLE_SUBSET.materials) - def test_every_listed_material_accepts(self, model: str) -> None: - ir = _mvp_problem_ir(material_model=model) - ir.assert_mvp_stable() - assert ir.is_mvp_stable() is True - - @pytest.mark.unit - def test_explicit_static_dynamics_mode_accepts(self) -> None: - # Passing dynamics_mode explicitly (instead of letting it auto-infer - # to STATIC) must not change the verdict. - ir = _mvp_problem_ir(dynamics_mode=DynamicsMode.STATIC) - assert ir.is_mvp_stable() is True - - -class TestMvpSubsetViolationRejections: - """Each axis outside the subset must raise with a pointed message.""" - - @pytest.mark.unit - def test_updated_lagrangian_rejected(self) -> None: - ir = _mvp_problem_ir(formulation=Formulation.UPDATED_LAGRANGIAN) - assert ir.is_mvp_stable() is False - with pytest.raises(MvpSubsetViolation, match=r"formulation=") as exc: - ir.assert_mvp_stable() - # Plan-phase pointer is required by .claude/rules/ir.md. - assert "Plan B" in str(exc.value) - - @pytest.mark.unit - @pytest.mark.parametrize("elem", [ElementType.TET4, ElementType.TET10, ElementType.HEX20]) - def test_experimental_element_rejected(self, elem: ElementType) -> None: - ir = _mvp_problem_ir(element_type=elem) - assert ir.is_mvp_stable() is False - with pytest.raises(MvpSubsetViolation, match=r"element_type=") as exc: - ir.assert_mvp_stable() - assert "Plan B phase B5" in str(exc.value) - - @pytest.mark.unit - @pytest.mark.parametrize( - "model", - ["neo_hookean", "mooney_rivlin", "ogden", "hgo", "lemaitre", "perzyna"], - ) - def test_non_mvp_material_rejected(self, model: str) -> None: - ir = _mvp_problem_ir(material_model=model) - assert ir.is_mvp_stable() is False - with pytest.raises(MvpSubsetViolation, match=r"material\.model=") as exc: - ir.assert_mvp_stable() - assert "Plan B" in str(exc.value) - - @pytest.mark.unit - def test_explicit_dynamics_rejected(self) -> None: - ir = _mvp_problem_ir(dynamics_mode=DynamicsMode.EXPLICIT) - assert ir.is_mvp_stable() is False - with pytest.raises(MvpSubsetViolation, match=r"dynamics_mode=") as exc: - ir.assert_mvp_stable() - assert "Plan B phase B7" in str(exc.value) - - @pytest.mark.unit - def test_violation_subclasses_unsupported_error(self) -> None: - # Callers catching UnsupportedError per .claude/rules/ir.md must keep - # working when the IR raises the more specific MvpSubsetViolation. - ir = _mvp_problem_ir(formulation=Formulation.UPDATED_LAGRANGIAN) - with pytest.raises(UnsupportedError): - ir.assert_mvp_stable() - - -class TestMvpSubsetContractAlignment: - """Cross-check `MVP_STABLE_SUBSET` stays aligned with sibling contracts.""" - - @pytest.mark.unit - def test_allowed_profiles_only_lists_mvp(self) -> None: - # If a new profile lands in `compile_latex` the subset descriptor and - # docs must follow. This guard fails loudly so the contract does not - # silently drift. - assert frozenset({"mvp"}) == ALLOWED_PROFILES, ( - "Adding a profile to ALLOWED_PROFILES requires extending " - "MVP_STABLE_SUBSET (and `dev/design_docs/04-MECHANICS-IR.md` " - "§3.1) in lock-step." - ) - - @pytest.mark.unit - def test_subset_documented_in_ir_architecture_doc(self) -> None: - # IR architecture lives in `mechdsl/ir/ARCHITECTURE.md` (sibling of - # the implementation) because `dev/design_docs/` is hook-protected - # and the contract must move in lock-step with code changes. - from pathlib import Path - - import mechdsl.ir as ir_pkg - - ir_pkg_dir = Path(ir_pkg.__file__).resolve().parent - ir_arch = ir_pkg_dir / "ARCHITECTURE.md" - assert ir_arch.is_file(), f"IR architecture doc missing: {ir_arch}" - body = ir_arch.read_text() - assert "MVP-stable subset" in body, ( - "ir/ARCHITECTURE.md must document the MVP-stable subset that " - "MVP_STABLE_SUBSET encodes (see P3-4)." - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_5.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_5.py deleted file mode 100644 index 2fbcd5a..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p3_5.py +++ /dev/null @@ -1,322 +0,0 @@ -"""Live audit for recovery-plan P3-5: targeted IR validation. - -P3-5 closes a class of silent acceptance: malformed IRs that used to -construct without error and surface as cryptic codegen / runtime -failures. Each block below pairs a positive case (valid IR builds) with a -negative case (the previously-silent malformed IR now raises a clear -``ValueError`` with a message that names the offending field). - -The validations cover: - -a. Duplicate boundary region names. -b. BC component indices out of [0, dim). -c. Coordinate-name uniqueness (``coord_spatial`` / ``coord_material``). -d. Duplicate ``FieldSpec.name`` entries. -e. BC ``field_name`` references unknown field. -f. Required material parameters missing for MVP-stable models. - -Each negative case must raise *at construction time* (not later in -codegen / runtime), and the message must mention the offending field so -the failure points at the user's IR construction site. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - FieldSpec, - Formulation, - MaterialSpec, - MvpSubsetViolation, - ProblemIR, -) - - -def _valid_kwargs(**overrides: Any) -> dict[str, Any]: - """Canonical valid-IR kwarg dict that downstream tests can perturb.""" - base: dict[str, Any] = dict( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - ) - base.update(overrides) - return base - - -class TestBoundaryNameUniqueness: - """P3-5a: duplicate boundary region names raise at construction time.""" - - @pytest.mark.integration - def test_unique_names_pass(self) -> None: - ir = ProblemIR(**_valid_kwargs()) - assert {bc.name for bc in ir.boundaries} == {"fix", "load"} - - @pytest.mark.integration - def test_duplicate_names_rejected(self) -> None: - with pytest.raises(ValueError, match=r"Duplicate boundary condition name 'fix'"): - ProblemIR( - **_valid_kwargs( - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="fix", bc_type=BCType.NEUMANN, traction="t_bar"), - ) - ) - ) - - -class TestComponentRange: - """P3-5b: BC component indices must lie in [0, dim).""" - - @pytest.mark.integration - def test_in_range_components_pass(self) -> None: - ir = ProblemIR( - **_valid_kwargs( - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET, components=(0, 1, 2)), - ), - ) - ) - assert ir.boundaries[0].components == (0, 1, 2) - - @pytest.mark.integration - def test_negative_component_rejected(self) -> None: - with pytest.raises(ValueError, match=r"component index -1 is out of range for dim=3"): - ProblemIR( - **_valid_kwargs( - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET, components=(-1,)), - ) - ) - ) - - @pytest.mark.integration - def test_overflow_component_rejected(self) -> None: - with pytest.raises(ValueError, match=r"component index 3 is out of range for dim=3"): - ProblemIR( - **_valid_kwargs( - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET, components=(0, 3)), - ) - ) - ) - - -class TestCoordinateUniqueness: - """P3-5c: spatial / material coordinate names must be unique.""" - - @pytest.mark.integration - def test_unique_coord_spatial_pass(self) -> None: - ir = ProblemIR(**_valid_kwargs(coord_spatial=("x", "y", "z"))) - assert ir.coord_spatial == ("x", "y", "z") - - @pytest.mark.integration - def test_duplicate_coord_spatial_rejected(self) -> None: - with pytest.raises(ValueError, match=r"coord_spatial=.+duplicate"): - ProblemIR(**_valid_kwargs(coord_spatial=("x", "x", "z"))) - - @pytest.mark.integration - def test_duplicate_coord_material_rejected(self) -> None: - with pytest.raises(ValueError, match=r"coord_material=.+duplicate"): - ProblemIR(**_valid_kwargs(coord_material=("X", "Y", "X"))) - - -class TestFieldNameUniqueness: - """P3-5d: declared FieldSpec names must be unique.""" - - @pytest.mark.integration - def test_unique_field_names_pass(self) -> None: - ir = ProblemIR( - **_valid_kwargs( - fields=( - FieldSpec(name="u", kind="vector"), - FieldSpec(name="p", kind="scalar"), - ), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET, field_name="u"), - ), - ) - ) - assert {f.name for f in ir.fields} == {"u", "p"} - - @pytest.mark.integration - def test_duplicate_field_names_rejected(self) -> None: - with pytest.raises(ValueError, match=r"Duplicate field name 'u'"): - ProblemIR( - **_valid_kwargs( - fields=( - FieldSpec(name="u", kind="vector"), - FieldSpec(name="u", kind="scalar"), - ), - ) - ) - - -class TestBcFieldNameConsistency: - """P3-5e: BC field_name must reference a declared FieldSpec.name.""" - - @pytest.mark.integration - def test_matching_field_name_passes(self) -> None: - ir = ProblemIR( - **_valid_kwargs( - fields=(FieldSpec(name="u", kind="vector"),), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET, field_name="u"), - ), - ) - ) - assert ir.boundaries[0].field_name == "u" - - @pytest.mark.integration - def test_unknown_field_name_rejected(self) -> None: - with pytest.raises(ValueError, match=r"references field 'ux'"): - ProblemIR( - **_valid_kwargs( - fields=(FieldSpec(name="u", kind="vector"),), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET, field_name="ux"), - ), - ) - ) - - @pytest.mark.integration - def test_validation_skipped_when_fields_empty(self) -> None: - # When `fields` is the default empty tuple, the consistency check is - # off — pre-P3-1 callers that never set FieldSpec entries continue - # to construct with the implicit "u" default field_name. - ir = ProblemIR( - **_valid_kwargs( - boundaries=( - BoundaryCondition( - name="fix", bc_type=BCType.DIRICHLET, field_name="some_other" - ), - ) - ) - ) - assert ir.fields == () - - -class TestMvpMaterialParamCompleteness: - """P3-5f: MVP-stable material models require their full parameter set. - - The check lives in :meth:`ProblemIR.assert_mvp_stable` (not - ``__post_init__``) so in-tree research code that builds minimal IRs - for shape-only testing keeps working. The canonical compile path - (:func:`mechdsl.compile_latex`) calls ``assert_mvp_stable`` and - therefore enforces the contract at the user-visible boundary. - """ - - @pytest.mark.integration - def test_complete_svk_params_pass_mvp_check(self) -> None: - ir = ProblemIR( - **_valid_kwargs( - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - ) - ) - ir.assert_mvp_stable() - assert ir.is_mvp_stable() is True - - @pytest.mark.integration - def test_minimal_svk_constructs_but_fails_mvp_check(self) -> None: - ir = ProblemIR( - **_valid_kwargs( - material=MaterialSpec(model="svk", params={"E": 200e3}), - ) - ) - assert ir.is_mvp_stable() is False - with pytest.raises(MvpSubsetViolation, match=r"Material model 'svk' requires"): - ir.assert_mvp_stable() - - @pytest.mark.integration - def test_complete_j2_power_law_params_pass_mvp_check(self) -> None: - ir = ProblemIR( - **_valid_kwargs( - material=MaterialSpec( - model="j2_power_law", - params={ - "E": 200e3, - "nu": 0.3, - "sigma_y0": 250.0, - "K": 1000.0, - "n": 10.0, - }, - ), - ) - ) - ir.assert_mvp_stable() - - @pytest.mark.integration - def test_missing_j2_power_law_params_fails_mvp_check(self) -> None: - ir = ProblemIR( - **_valid_kwargs( - material=MaterialSpec( - model="j2_power_law", - params={"E": 200e3, "nu": 0.3}, - ), - ) - ) - with pytest.raises(MvpSubsetViolation, match=r"Material model 'j2_power_law' requires"): - ir.assert_mvp_stable() - - @pytest.mark.integration - def test_experimental_model_skips_required_check_at_post_init(self) -> None: - # `lemaitre` is a known-but-experimental model — it still passes - # the model-name allowlist (so legacy code keeps working). The IR - # constructs cleanly, and the required-params table omits the - # model so no spurious failure surfaces at construction time. - ir = ProblemIR( - **_valid_kwargs( - material=MaterialSpec(model="lemaitre", params={}), - ) - ) - assert ir.material.model == "lemaitre" - # `assert_mvp_stable` still rejects it, but on the higher-level - # "lemaitre is not in the MVP-stable materials axis" ground. - assert ir.is_mvp_stable() is False - - -class TestErrorsRaiseAtConstructionTime: - """Aggregate acceptance criterion: schema-shape violations raise in __post_init__. - - The required-material-params check (P3-5f) lives on the compile-path - boundary instead of ``__post_init__`` — see - :class:`TestMvpMaterialParamCompleteness` for that surface. - """ - - @pytest.mark.integration - @pytest.mark.parametrize( - "kwargs, match", - [ - ( - { - "boundaries": ( - BoundaryCondition(name="dup", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="dup", bc_type=BCType.NEUMANN, traction="t_bar"), - ) - }, - r"Duplicate boundary", - ), - ( - { - "boundaries": ( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET, components=(99,)), - ) - }, - r"out of range", - ), - ({"coord_spatial": ("x", "x", "z")}, r"coord_spatial=.+duplicate"), - ], - ) - def test_each_violation_raises_in_post_init(self, kwargs: dict[str, Any], match: str) -> None: - with pytest.raises(ValueError, match=match): - ProblemIR(**_valid_kwargs(**kwargs)) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_1.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_1.py deleted file mode 100644 index 9707605..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_1.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Live audit for recovery-plan P4-1: ElementIR execution-contract enrichment. - -Asserts that: - -1. The four optional enrichment dataclasses (``GeometrySummary``, - ``MaterialEvalContract``, ``LocalForceDescriptor``, - ``LocalTangentDescriptor``) round-trip standalone and reject malformed - inputs at construction time. -2. ``ElementIR`` carries the four optional fields with safe ``None`` - defaults so legacy callers continue working. -3. ``ElementIR.to_dict / from_dict`` round-trip both legacy and enriched - forms. -4. Construction-time consistency checks fire on mismatched descriptors — - wrong ``n_dof``, wrong ``n_quad``, formulation / stress-measure - conflict. -5. The exported ``ALLOWED_STRESS_MEASURES`` / ``ALLOWED_STRAIN_MEASURES`` - sets stay aligned with the dataclass validators. -""" - -from __future__ import annotations - -from dataclasses import replace - -import pytest - -from mechdsl.ir.element_ir import ( - ALLOWED_STRAIN_MEASURES, - ALLOWED_STRESS_MEASURES, - ElementIR, - GeometrySummary, - LocalForceDescriptor, - LocalTangentDescriptor, - MaterialEvalContract, - create_hex8_element_ir, -) - -# --------------------------------------------------------------------------- -# 1. Enrichment dataclasses round-trip and validate -# --------------------------------------------------------------------------- - - -class TestGeometrySummary: - @pytest.mark.integration - def test_round_trip(self) -> None: - g = GeometrySummary(n_quad=8, reference_volume=8.0, natural_coord_dim=3) - assert GeometrySummary.from_dict(g.to_dict()) == g - - @pytest.mark.integration - def test_rejects_zero_n_quad(self) -> None: - with pytest.raises(ValueError, match=r"n_quad must be >= 1"): - GeometrySummary(n_quad=0, reference_volume=8.0) - - @pytest.mark.integration - def test_rejects_nonpositive_volume(self) -> None: - with pytest.raises(ValueError, match=r"reference_volume must be > 0"): - GeometrySummary(n_quad=8, reference_volume=-1.0) - - @pytest.mark.integration - def test_rejects_invalid_natural_dim(self) -> None: - with pytest.raises(ValueError, match=r"natural_coord_dim must be 1, 2, or 3"): - GeometrySummary(n_quad=8, reference_volume=8.0, natural_coord_dim=4) - - -class TestMaterialEvalContract: - @pytest.mark.integration - def test_round_trip(self) -> None: - me = MaterialEvalContract( - stress_measure="pk2", - strain_measure="green_lagrange", - tangent_rank=4, - voigt_size=6, - metadata={"family": "elastic"}, - ) - assert MaterialEvalContract.from_dict(me.to_dict()) == me - - @pytest.mark.integration - def test_rejects_unknown_stress_measure(self) -> None: - with pytest.raises(ValueError, match=r"stress_measure='kirchhoff'"): - MaterialEvalContract(stress_measure="kirchhoff") - - @pytest.mark.integration - def test_rejects_unknown_strain_measure(self) -> None: - with pytest.raises(ValueError, match=r"strain_measure='hencky'"): - MaterialEvalContract(strain_measure="hencky") - - @pytest.mark.integration - def test_rejects_invalid_tangent_rank(self) -> None: - with pytest.raises(ValueError, match=r"tangent_rank must be 2"): - MaterialEvalContract(tangent_rank=3) - - @pytest.mark.integration - def test_metadata_is_immutable(self) -> None: - me = MaterialEvalContract(metadata={"k": 1}) - with pytest.raises(TypeError): - me.metadata["k"] = 2 # type: ignore[index] - with pytest.raises(AttributeError): - me.metadata.pop("k") # type: ignore[attr-defined] - - @pytest.mark.integration - def test_allowed_measure_sets_are_frozensets(self) -> None: - assert isinstance(ALLOWED_STRESS_MEASURES, frozenset) - assert isinstance(ALLOWED_STRAIN_MEASURES, frozenset) - assert frozenset({"pk2", "cauchy"}) == ALLOWED_STRESS_MEASURES - - -class TestLocalForceAndTangentDescriptors: - @pytest.mark.integration - def test_force_round_trip(self) -> None: - lf = LocalForceDescriptor(n_dof=24, contraction_sketch="aI,iI->ai") - assert LocalForceDescriptor.from_dict(lf.to_dict()) == lf - - @pytest.mark.integration - def test_tangent_round_trip(self) -> None: - lt = LocalTangentDescriptor(n_dof=24, is_symmetric=True) - assert LocalTangentDescriptor.from_dict(lt.to_dict()) == lt - - @pytest.mark.integration - def test_force_rejects_zero_n_dof(self) -> None: - with pytest.raises(ValueError, match=r"LocalForceDescriptor.n_dof"): - LocalForceDescriptor(n_dof=0) - - @pytest.mark.integration - def test_tangent_rejects_zero_n_dof(self) -> None: - with pytest.raises(ValueError, match=r"LocalTangentDescriptor.n_dof"): - LocalTangentDescriptor(n_dof=0) - - -# --------------------------------------------------------------------------- -# 2. ElementIR carries the four optional fields with safe defaults -# --------------------------------------------------------------------------- - - -class TestElementIREnrichmentFields: - @pytest.mark.integration - def test_legacy_ir_has_safe_default_enrichment(self) -> None: - ir = create_hex8_element_ir() - assert ir.geometry is None - assert ir.material_eval is None - assert ir.local_force is None - assert ir.local_tangent is None - - @pytest.mark.integration - def test_enriched_ir_carries_populated_fields(self) -> None: - legacy = create_hex8_element_ir() - enriched = replace( - legacy, - geometry=GeometrySummary(n_quad=8, reference_volume=8.0), - material_eval=MaterialEvalContract(), - local_force=LocalForceDescriptor(n_dof=24), - local_tangent=LocalTangentDescriptor(n_dof=24), - ) - assert enriched.geometry is not None and enriched.geometry.n_quad == 8 - assert enriched.material_eval is not None - assert enriched.local_force is not None and enriched.local_force.n_dof == 24 - assert enriched.local_tangent is not None and enriched.local_tangent.is_symmetric - - -# --------------------------------------------------------------------------- -# 3. Construction-time consistency checks -# --------------------------------------------------------------------------- - - -class TestEnrichmentConsistencyChecks: - @pytest.mark.integration - def test_geometry_n_quad_must_match_quadrature_n_points(self) -> None: - legacy = create_hex8_element_ir() - with pytest.raises(ValueError, match=r"GeometrySummary.n_quad"): - replace(legacy, geometry=GeometrySummary(n_quad=4, reference_volume=8.0)) - - @pytest.mark.integration - def test_local_force_n_dof_must_match_n_nodes_times_dim(self) -> None: - legacy = create_hex8_element_ir() - with pytest.raises(ValueError, match=r"LocalForceDescriptor.n_dof"): - replace(legacy, local_force=LocalForceDescriptor(n_dof=12)) - - @pytest.mark.integration - def test_local_tangent_n_dof_must_match_n_nodes_times_dim(self) -> None: - legacy = create_hex8_element_ir() - with pytest.raises(ValueError, match=r"LocalTangentDescriptor.n_dof"): - replace(legacy, local_tangent=LocalTangentDescriptor(n_dof=12)) - - @pytest.mark.integration - def test_reference_configuration_requires_pk2(self) -> None: - legacy = create_hex8_element_ir() - with pytest.raises(ValueError, match=r"configuration='reference' requires"): - replace(legacy, material_eval=MaterialEvalContract(stress_measure="cauchy")) - - @pytest.mark.integration - def test_current_configuration_requires_cauchy(self) -> None: - legacy = create_hex8_element_ir(formulation="updated_lagrangian", configuration="current") - with pytest.raises(ValueError, match=r"configuration='current' requires"): - replace(legacy, material_eval=MaterialEvalContract(stress_measure="pk2")) - - -# --------------------------------------------------------------------------- -# 4. ElementIR.to_dict / from_dict round-trip -# --------------------------------------------------------------------------- - - -class TestElementIRRoundTrip: - @pytest.mark.integration - def test_legacy_round_trip(self) -> None: - legacy = create_hex8_element_ir() - rebuilt = ElementIR.from_dict(legacy.to_dict()) - assert rebuilt.element_type == legacy.element_type - assert rebuilt.n_nodes == legacy.n_nodes - assert rebuilt.formulation == legacy.formulation - assert rebuilt.configuration == legacy.configuration - assert rebuilt.geometry is None - assert rebuilt.material_eval is None - assert rebuilt.local_force is None - assert rebuilt.local_tangent is None - - @pytest.mark.integration - def test_enriched_round_trip(self) -> None: - legacy = create_hex8_element_ir() - enriched = replace( - legacy, - geometry=GeometrySummary(n_quad=8, reference_volume=8.0), - material_eval=MaterialEvalContract(metadata={"family": "elastic"}), - local_force=LocalForceDescriptor(n_dof=24, contraction_sketch="aI,iI->ai"), - local_tangent=LocalTangentDescriptor(n_dof=24, is_symmetric=True), - ) - rebuilt = ElementIR.from_dict(enriched.to_dict()) - assert rebuilt.geometry == enriched.geometry - assert rebuilt.material_eval == enriched.material_eval - assert rebuilt.local_force == enriched.local_force - assert rebuilt.local_tangent == enriched.local_tangent - - @pytest.mark.integration - def test_to_dict_always_emits_enrichment_keys(self) -> None: - legacy = create_hex8_element_ir() - d = legacy.to_dict() - for key in ("geometry", "material_eval", "local_force", "local_tangent"): - assert key in d, f"to_dict() must always emit `{key}` for forward consumers" - assert d[key] is None # safe default for the legacy IR - - @pytest.mark.integration - def test_from_dict_accepts_legacy_dict(self) -> None: - # Pre-P4-1 golden dict — no enrichment keys, no integration_rule key. - legacy_dict = { - "element_type": "hex8", - "n_nodes": 8, - "dim": 3, - "formulation": "total_lagrangian", - "configuration": "reference", - } - rebuilt = ElementIR.from_dict(legacy_dict) - assert rebuilt.element_type == "hex8" - assert rebuilt.geometry is None diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_2.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_2.py deleted file mode 100644 index d9446f3..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_2.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Live audit for recovery-plan P4-2: EinsumSpec / LocalisationResult demoted. - -Asserts that: - -1. The docstrings on ``EinsumSpec`` and ``LocalisationResult`` mark them - explicitly as derived views over :class:`ElementIR` (so their role is - discoverable from ``help()`` and IDE tooltips, not just from the - recovery plan). -2. ``LocalisationResult.from_element_ir(element_ir, problem_ir)`` exists - and produces a bundle whose ``einsum_specs`` are freshly derived from - the provided ``ElementIR`` — making the derived-view relationship - explicit in code. -3. The enriched ``ElementIR`` survives independently of the optimizer - view: building an ``ElementIR`` (with the P4-1 enrichment fields - populated), then deriving a ``LocalisationResult`` from it, leaves - the original ``ElementIR`` unchanged and equal-to-itself. -4. The pre-P4-2 production path ``localise(problem_ir)`` still produces - the same shape of result (no regression). -""" - -from __future__ import annotations - -from dataclasses import replace - -import pytest - -from mechdsl.ir.element_ir import ( - ElementIR, - GeometrySummary, - LocalForceDescriptor, - LocalTangentDescriptor, - MaterialEvalContract, - create_hex8_element_ir, -) -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import EinsumSpec, LocalisationResult, localise - - -def _mvp_problem_ir() -> ProblemIR: - return ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - ) - - -def _enriched_hex8_element_ir() -> ElementIR: - base = create_hex8_element_ir() - return replace( - base, - geometry=GeometrySummary(n_quad=8, reference_volume=8.0), - material_eval=MaterialEvalContract(stress_measure="pk2"), - local_force=LocalForceDescriptor(n_dof=24), - local_tangent=LocalTangentDescriptor(n_dof=24, is_symmetric=True), - ) - - -# --------------------------------------------------------------------------- -# 1. Docstrings document the derived-view status -# --------------------------------------------------------------------------- - - -class TestDerivedViewDocumentation: - @pytest.mark.unit - def test_einsum_spec_docstring_marks_as_derived_view(self) -> None: - # The "derived view" label must be discoverable from `help()` so a - # consumer who reads the docstring sees the post-P4-2 status. - assert EinsumSpec.__doc__ is not None - assert "derived" in EinsumSpec.__doc__.lower() - - @pytest.mark.unit - def test_localisation_result_docstring_marks_as_derived_view(self) -> None: - assert LocalisationResult.__doc__ is not None - assert "derived" in LocalisationResult.__doc__.lower() - # Also call out the recovery-plan task that introduced the demotion - # so future readers can find the rationale. - assert "P4-2" in LocalisationResult.__doc__ - - -# --------------------------------------------------------------------------- -# 2. `from_element_ir` makes the derivation explicit -# --------------------------------------------------------------------------- - - -class TestFromElementIR: - @pytest.mark.unit - def test_classmethod_exists(self) -> None: - assert hasattr(LocalisationResult, "from_element_ir") - assert callable(LocalisationResult.from_element_ir) - - @pytest.mark.unit - def test_from_element_ir_with_legacy_ir_yields_specs(self) -> None: - problem_ir = _mvp_problem_ir() - element_ir = create_hex8_element_ir() - result = LocalisationResult.from_element_ir(element_ir, problem_ir) - assert result.element_ir is element_ir - assert result.problem_ir is problem_ir - # The derivation must materialize at least one optimizer spec; the - # exact spec set is the einsum_extract test's concern. - assert len(result.einsum_specs) > 0 - - @pytest.mark.unit - def test_from_element_ir_with_enriched_ir_preserves_enrichment(self) -> None: - problem_ir = _mvp_problem_ir() - enriched = _enriched_hex8_element_ir() - result = LocalisationResult.from_element_ir(enriched, problem_ir) - # Enrichment fields survive end-to-end through the bundle. - assert result.element_ir.geometry == enriched.geometry - assert result.element_ir.material_eval == enriched.material_eval - assert result.element_ir.local_force == enriched.local_force - assert result.element_ir.local_tangent == enriched.local_tangent - - @pytest.mark.unit - def test_from_element_ir_does_not_mutate_input(self) -> None: - problem_ir = _mvp_problem_ir() - enriched = _enriched_hex8_element_ir() - snapshot = enriched.to_dict() - LocalisationResult.from_element_ir(enriched, problem_ir) - # Frozen dataclass + careful derivation = no mutation. - assert enriched.to_dict() == snapshot - - -# --------------------------------------------------------------------------- -# 3. Enriched ElementIR survives independently of the optimizer view -# --------------------------------------------------------------------------- - - -class TestEnrichedIRIndependence: - @pytest.mark.unit - def test_enriched_ir_can_be_built_without_localisation_result(self) -> None: - # If P4-2's demotion leaked, building an ElementIR-only path would - # require also building the optimizer view. It must not. - ir = _enriched_hex8_element_ir() - assert ir.element_type == "hex8" - assert ir.geometry is not None and ir.geometry.n_quad == 8 - - @pytest.mark.unit - def test_localise_still_produces_localisation_result(self) -> None: - # Pre-P4-2 callers still get the same shape of result back from the - # production path — no regression in the public API. - result = localise(_mvp_problem_ir()) - assert isinstance(result, LocalisationResult) - assert isinstance(result.element_ir, ElementIR) - assert all(isinstance(s, EinsumSpec) for s in result.einsum_specs) - - @pytest.mark.unit - def test_two_localise_results_share_einsum_spec_shape(self) -> None: - # The derivation is deterministic per-ElementIR shape: building the - # bundle twice from the same inputs produces matching specs. - problem_ir = _mvp_problem_ir() - element_ir = create_hex8_element_ir() - a = LocalisationResult.from_element_ir(element_ir, problem_ir) - b = LocalisationResult.from_element_ir(element_ir, problem_ir) - assert tuple(s.einsum_string for s in a.einsum_specs) == tuple( - s.einsum_string for s in b.einsum_specs - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_3.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_3.py deleted file mode 100644 index f9b1d37..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_3.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Live audit for recovery-plan P4-3: lowering emits richer ElementIR first. - -Asserts that: - -1. ``localise(problem_ir)`` populates the four P4-1 execution-contract - dataclasses on the returned ``ElementIR`` (geometry, material_eval, - local_force, local_tangent). -2. The enrichment is consistent with the originating ``ProblemIR``: TL - formulations carry PK2 + Green-Lagrange; UL would carry Cauchy + - Almansi (validated through the public API even though UL lowering is - gated elsewhere). -3. Symmetric-tangent models (svk, j2_power_law) flip ``local_tangent. - is_symmetric=True``; non-symmetric models (perzyna, johnson_cook, - lemaitre) flip it ``False``. -4. The optimizer view (einsum_specs / contraction plans) is derived - *from* the enriched ``ElementIR`` — same ElementIR ⇒ same plan set. -5. ``ArtifactBundle.from_pipeline`` surfaces the enriched contract - blocks in ``element_ir_summary`` so golden artifacts capture the - enrichment. -6. Pre-P4-3 ``localise()`` consumers continue to receive a - ``LocalisationResult`` with the same surface shape (back-compat). -""" - -from __future__ import annotations - -import pytest - -from mechdsl.codegen.artifact import ArtifactBundle, ContractionPlan -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import LocalisationResult, localise, localise_and_optimize - - -def _mvp_problem_ir( - *, - formulation: Formulation = Formulation.TOTAL_LAGRANGIAN, - material_model: str = "svk", -) -> ProblemIR: - params: dict[str, float] = ( - { - "E": 200e3, - "nu": 0.3, - "sigma_y0": 250.0, - "K": 1000.0, - "n": 10.0, - } - if material_model == "j2_power_law" - else {"E": 200e3, "nu": 0.3} - ) - return ProblemIR( - dim=3, - formulation=formulation, - element_type=ElementType.HEX8, - material=MaterialSpec(model=material_model, params=params), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - ) - - -# --------------------------------------------------------------------------- -# 1. localise() emits the enriched ElementIR -# --------------------------------------------------------------------------- - - -class TestLocaliseEmitsEnrichedIR: - @pytest.mark.regression - def test_geometry_block_populated(self) -> None: - result = localise(_mvp_problem_ir()) - geom = result.element_ir.geometry - assert geom is not None - assert geom.n_quad == result.element_ir.quadrature.n_points - assert geom.reference_volume == 8.0 # Hex8 reference volume - assert geom.natural_coord_dim == 3 - - @pytest.mark.regression - def test_material_eval_block_populated_for_tl(self) -> None: - result = localise(_mvp_problem_ir(formulation=Formulation.TOTAL_LAGRANGIAN)) - me = result.element_ir.material_eval - assert me is not None - assert me.stress_measure == "pk2" - assert me.strain_measure == "green_lagrange" - - @pytest.mark.regression - def test_material_eval_block_populated_for_ul(self) -> None: - result = localise(_mvp_problem_ir(formulation=Formulation.UPDATED_LAGRANGIAN)) - me = result.element_ir.material_eval - assert me is not None - assert me.stress_measure == "cauchy" - assert me.strain_measure == "almansi" - - @pytest.mark.regression - def test_local_force_block_populated(self) -> None: - result = localise(_mvp_problem_ir()) - lf = result.element_ir.local_force - assert lf is not None - assert lf.n_dof == result.element_ir.n_nodes * result.element_ir.dim - assert lf.contraction_sketch # non-empty - - @pytest.mark.regression - def test_local_tangent_block_populated(self) -> None: - result = localise(_mvp_problem_ir()) - lt = result.element_ir.local_tangent - assert lt is not None - assert lt.n_dof == 24 - assert lt.contraction_sketch # non-empty - - -class TestSymmetricTangentFlag: - @pytest.mark.regression - @pytest.mark.parametrize("model", ["svk", "j2_power_law"]) - def test_symmetric_models_flag_symmetric(self, model: str) -> None: - result = localise(_mvp_problem_ir(material_model=model)) - assert result.element_ir.local_tangent is not None - assert result.element_ir.local_tangent.is_symmetric is True - - @pytest.mark.regression - @pytest.mark.parametrize("model", ["perzyna", "johnson_cook", "lemaitre"]) - def test_non_symmetric_models_flag_non_symmetric(self, model: str) -> None: - # These models have non-MVP param requirements but the lowering - # path doesn't enforce P3-5f's required-params at construction — - # the test exercises the symmetric-flag wiring only. - result = localise(_mvp_problem_ir(material_model=model)) - assert result.element_ir.local_tangent is not None - assert result.element_ir.local_tangent.is_symmetric is False - - -# --------------------------------------------------------------------------- -# 2. Optimizer view is derived from the enriched ElementIR -# --------------------------------------------------------------------------- - - -class TestOptimizerViewDerivedFromEnrichedIR: - @pytest.mark.regression - def test_einsum_specs_match_from_element_ir(self) -> None: - problem_ir = _mvp_problem_ir() - result = localise(problem_ir) - # Re-derive directly from the same enriched ElementIR via the - # `from_element_ir` classmethod — identical specs are expected. - rederived = LocalisationResult.from_element_ir(result.element_ir, problem_ir) - assert tuple(s.einsum_string for s in rederived.einsum_specs) == tuple( - s.einsum_string for s in result.einsum_specs - ) - - @pytest.mark.regression - def test_localise_and_optimize_returns_plans(self) -> None: - loc, plans = localise_and_optimize(_mvp_problem_ir()) - assert isinstance(loc, LocalisationResult) - assert all(isinstance(p, ContractionPlan) for p in plans) - # One plan per einsum spec, in the same order. - assert len(plans) == len(loc.einsum_specs) - - -# --------------------------------------------------------------------------- -# 3. ArtifactBundle.from_pipeline surfaces the enrichment -# --------------------------------------------------------------------------- - - -class TestArtifactBundleSurfaceEnrichment: - @pytest.mark.regression - def test_element_ir_summary_carries_enrichment_keys(self) -> None: - loc, plans = localise_and_optimize(_mvp_problem_ir()) - bundle = ArtifactBundle.from_pipeline( - problem_ir=loc.problem_ir, - localisation=loc, - contraction_plans=plans, - ) - summary = bundle.element_ir_summary - for key in ("geometry", "material_eval", "local_force", "local_tangent"): - assert key in summary, f"element_ir_summary must carry {key!r}" - assert summary[key] is not None # populated since localise() enriches - - # Spot-check one block end-to-end through the bundle. - assert summary["geometry"]["n_quad"] == 8 - assert summary["material_eval"]["stress_measure"] == "pk2" - assert summary["local_force"]["n_dof"] == 24 - assert summary["local_tangent"]["is_symmetric"] is True - - @pytest.mark.regression - def test_bundle_round_trips_with_enrichment(self) -> None: - loc, plans = localise_and_optimize(_mvp_problem_ir()) - bundle = ArtifactBundle.from_pipeline(loc.problem_ir, loc, plans) - rebuilt = ArtifactBundle.from_dict(bundle.to_dict()) - assert rebuilt.element_ir_summary["geometry"] == bundle.element_ir_summary["geometry"] - assert ( - rebuilt.element_ir_summary["material_eval"] - == bundle.element_ir_summary["material_eval"] - ) - - -# --------------------------------------------------------------------------- -# 4. Back-compat: localise() still produces the LocalisationResult shape -# --------------------------------------------------------------------------- - - -class TestLocaliseBackCompat: - @pytest.mark.regression - def test_localise_returns_localisation_result(self) -> None: - result = localise(_mvp_problem_ir()) - assert isinstance(result, LocalisationResult) - # The pre-P4-3 fields all still exist with the same names. - assert result.element_ir is not None - assert result.einsum_specs - assert result.problem_ir is not None diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_4.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_4.py deleted file mode 100644 index 2d1b356..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_4.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Live audit for recovery-plan P4-4: lowering rejections are deterministic. - -Asserts that: - -1. Every unsupported stable-path combination raises - :class:`LocalisationError` (a subclass of :class:`UnsupportedError`). -2. Each rejection message names the offending construct AND the Plan-B - phase that adds support. -3. The rejection axes fire in deterministic order - (formulation → element → material) so error messages stay stable - across runs. -4. ``LocalisationError`` is exported from - :mod:`mechdsl.lowering` so callers can ``except`` it cleanly. -5. Catching the broader :class:`UnsupportedError` still works - (back-compat with the ``.claude/rules/ir.md`` rejection contract). -""" - -from __future__ import annotations - -import pytest - -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering import LocalisationError, localise -from mechdsl.symbolic.convected import UnsupportedError - - -def _problem_ir( - *, - formulation: Formulation = Formulation.TOTAL_LAGRANGIAN, - element_type: ElementType = ElementType.HEX8, - material_model: str = "svk", -) -> ProblemIR: - return ProblemIR( - dim=3, - formulation=formulation, - element_type=element_type, - material=MaterialSpec(model=material_model, params={"E": 200e3, "nu": 0.3}), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - ) - - -# --------------------------------------------------------------------------- -# 1. Rejection class hierarchy and exports -# --------------------------------------------------------------------------- - - -class TestRejectionClassHierarchy: - @pytest.mark.integration - def test_localisation_error_subclasses_unsupported_error(self) -> None: - # Catching UnsupportedError must still trip on a LocalisationError. - assert issubclass(LocalisationError, UnsupportedError) - - @pytest.mark.integration - def test_localisation_error_is_re_exported(self) -> None: - from mechdsl import lowering - - assert hasattr(lowering, "LocalisationError") - assert "LocalisationError" in lowering.__all__ - - -# --------------------------------------------------------------------------- -# 2. Per-axis rejection: each axis fires deterministically -# --------------------------------------------------------------------------- - - -class TestPerAxisRejections: - @pytest.mark.integration - @pytest.mark.parametrize("elem", [ElementType.TET4, ElementType.TET10, ElementType.HEX20]) - def test_non_hex8_element_rejected_with_phase_pointer(self, elem: ElementType) -> None: - ir = _problem_ir(element_type=elem) - with pytest.raises(LocalisationError) as exc: - localise(ir) - msg = str(exc.value) - # Must name the offending element value and the Plan-B phase. - assert elem.value in msg - assert "Plan B phase B5" in msg - - @pytest.mark.integration - @pytest.mark.parametrize( - "model", - # Models valid in the IR but outside the lowering allowlist would - # otherwise be silently accepted. Note: every model below is on the - # lowering allowlist already; the negative path here is "garbage - # model name" caught earlier in ProblemIR.__post_init__. So we - # exercise the lowering rejection by patching `_SUPPORTED_MODELS` - # via direct call to `_check_stable_path_combo` in the next class. - ["lemaitre", "perzyna", "neo_hookean"], - ) - def test_supported_models_dont_raise_at_localise(self, model: str) -> None: - # Models on the lowering allowlist should NOT trip the rejection - # path. They may fail later in codegen (Plan-B-specific support) - # but `localise()` itself accepts them. - ir = _problem_ir(material_model=model) - result = localise(ir) - assert result.problem_ir.material.model == model - - -class TestSupportedModelsAllowlistGate: - """Direct exercise of the rejection helper for off-allowlist models.""" - - @pytest.mark.integration - def test_off_allowlist_model_raises_with_phase_pointer(self) -> None: - # Bypass ProblemIR's own model allowlist to feed the lowering - # rejection a fabricated unsupported name. The rejection message - # must name the offending model AND a Plan-B phase pointer. - from mechdsl.lowering.fe_localise import _check_stable_path_combo - - # Build a minimal IR shell — we only need the .formulation, - # .element_type, and .material.model fields read by the helper. - class _FakeMaterial: - model = "fictional_model_x" - - class _FakeIR: - formulation = Formulation.TOTAL_LAGRANGIAN - element_type = ElementType.HEX8 - material = _FakeMaterial() - - with pytest.raises(LocalisationError) as exc: - _check_stable_path_combo(_FakeIR()) # type: ignore[arg-type] - msg = str(exc.value) - assert "fictional_model_x" in msg - assert "Plan B" in msg - - -# --------------------------------------------------------------------------- -# 3. Deterministic rejection order (formulation → element → material) -# --------------------------------------------------------------------------- - - -class TestDeterministicRejectionOrder: - @pytest.mark.integration - def test_element_rejection_fires_before_material_rejection(self) -> None: - # If both axes are unsupported, the element rejection must fire - # first. We can't easily build a ProblemIR with two unsupported - # axes (the IR rejects unknown materials at construction), so we - # exercise the helper directly with a fake IR instead. - from mechdsl.lowering.fe_localise import _check_stable_path_combo - - class _FakeMaterial: - model = "fictional_model_x" - - class _FakeIR: - formulation = Formulation.TOTAL_LAGRANGIAN - element_type = ElementType.TET4 # unsupported - material = _FakeMaterial() # also unsupported - - with pytest.raises(LocalisationError) as exc: - _check_stable_path_combo(_FakeIR()) # type: ignore[arg-type] - # The element rejection (axis 2) fires before the material - # rejection (axis 3) — message must mention the element type, not - # the material model. - msg = str(exc.value) - assert "tet4" in msg - assert "fictional_model_x" not in msg - - -# --------------------------------------------------------------------------- -# 4. Catch-as-UnsupportedError back-compat -# --------------------------------------------------------------------------- - - -class TestUnsupportedErrorBackCompat: - @pytest.mark.integration - def test_unsupported_error_catches_localisation_error(self) -> None: - ir = _problem_ir(element_type=ElementType.TET4) - with pytest.raises(UnsupportedError) as exc: - localise(ir) - assert isinstance(exc.value, LocalisationError) - # And the broader catch surfaces the same Plan-B pointer. - assert "Plan B phase B5" in str(exc.value) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_5.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_5.py deleted file mode 100644 index 146e227..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p4_5.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Live audit for recovery-plan P4-5: artifact bundling reflects enriched IR. - -Asserts that: - -1. ``ArtifactBundle.from_pipeline`` populates a new ``element_ir_dict`` - field carrying the canonical ``ElementIR.to_dict()`` output (the P4-1 - contract surface). -2. The bundle's ``to_dict / from_dict`` round-trips ``element_ir_dict`` - through a JSON pass; the round-tripped bundle is equal to the - original. -3. Legacy bundles without ``element_ir_dict`` deserialise cleanly with - the new field defaulting to an empty dict — golden files from earlier - phases continue to round-trip. -4. ``content_hash`` is stable for pre-P4-5 bundles (the legacy - ``element_ir_summary`` already carries the P4-3 enrichment that the - hash covers; ``element_ir_dict`` is informational and does not - participate in the hash). -5. The IR-ownership hierarchy documented in the bundle docstring matches - the runtime structure: ``element_ir_dict`` carries the four P4-1 - contract blocks; ``contraction_plans`` are present alongside as the - derived optimizer view. -""" - -from __future__ import annotations - -import pytest - -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import localise_and_optimize - - -def _bundle() -> ArtifactBundle: - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - ) - loc, plans = localise_and_optimize(problem_ir) - return ArtifactBundle.from_pipeline(loc.problem_ir, loc, plans) - - -# --------------------------------------------------------------------------- -# 1. element_ir_dict carries the canonical contract surface -# --------------------------------------------------------------------------- - - -class TestElementIRDictPopulated: - @pytest.mark.unit - def test_field_exists_on_bundle(self) -> None: - bundle = _bundle() - assert hasattr(bundle, "element_ir_dict") - assert isinstance(bundle.element_ir_dict, dict) - - @pytest.mark.unit - def test_field_carries_canonical_contract_surface(self) -> None: - bundle = _bundle() - d = bundle.element_ir_dict - # Every key documented on ElementIR.to_dict() must be present. - for key in ( - "element_type", - "n_nodes", - "dim", - "formulation", - "configuration", - "integration_rule", - "geometry", - "material_eval", - "local_force", - "local_tangent", - ): - assert key in d, f"element_ir_dict missing key {key!r}" - - @pytest.mark.unit - def test_field_carries_p4_1_enrichment(self) -> None: - bundle = _bundle() - d = bundle.element_ir_dict - # The four P4-1 contract blocks come back fully populated because - # localise() (post-P4-3) enriches the IR. Each must serialize as a - # dict, not None. - assert isinstance(d["geometry"], dict) - assert isinstance(d["material_eval"], dict) - assert isinstance(d["local_force"], dict) - assert isinstance(d["local_tangent"], dict) - # Spot-check one nested value end-to-end. - assert d["geometry"]["n_quad"] == 8 - assert d["material_eval"]["stress_measure"] == "pk2" - assert d["local_force"]["n_dof"] == 24 - assert d["local_tangent"]["is_symmetric"] is True - - -# --------------------------------------------------------------------------- -# 2. JSON round-trip preserves element_ir_dict + plans -# --------------------------------------------------------------------------- - - -class TestRoundTrip: - @pytest.mark.unit - def test_to_dict_includes_element_ir_dict(self) -> None: - bundle = _bundle() - d = bundle.to_dict() - assert "element_ir_dict" in d - assert d["element_ir_dict"] == bundle.element_ir_dict - - @pytest.mark.unit - def test_round_trip_preserves_enrichment(self) -> None: - bundle = _bundle() - rebuilt = ArtifactBundle.from_dict(bundle.to_dict()) - assert rebuilt.element_ir_dict == bundle.element_ir_dict - assert rebuilt.contraction_plans == bundle.contraction_plans - - @pytest.mark.unit - def test_round_trip_through_json_string(self) -> None: - bundle = _bundle() - json_text = bundle.to_json() - rebuilt = ArtifactBundle.from_json(json_str=json_text) - assert rebuilt.element_ir_dict == bundle.element_ir_dict - assert tuple(p.to_dict() for p in rebuilt.contraction_plans) == tuple( - p.to_dict() for p in bundle.contraction_plans - ) - - -# --------------------------------------------------------------------------- -# 3. Legacy bundles round-trip without element_ir_dict -# --------------------------------------------------------------------------- - - -class TestLegacyBundleCompat: - @pytest.mark.unit - def test_legacy_dict_without_element_ir_dict_deserialises(self) -> None: - # Hand-build a pre-P4-5 dict with the only-required keys. - legacy_dict = { - "problem_ir_dict": {"dim": 3, "formulation": "total_lagrangian"}, - "element_ir_summary": {"element_type": "hex8", "n_nodes": 8}, - } - bundle = ArtifactBundle.from_dict(legacy_dict) - assert bundle.element_ir_dict == {} - - @pytest.mark.unit - def test_legacy_bundle_round_trips_without_loss(self) -> None: - legacy_dict = { - "problem_ir_dict": {"dim": 3, "formulation": "total_lagrangian"}, - "element_ir_summary": {"element_type": "hex8", "n_nodes": 8}, - } - rebuilt = ArtifactBundle.from_dict(legacy_dict).to_dict() - # New key emitted but empty — does not change the legacy semantic - # content. Pre-P4-5 consumers ignore the unfamiliar key. - assert rebuilt["element_ir_dict"] == {} - assert rebuilt["problem_ir_dict"] == legacy_dict["problem_ir_dict"] - assert rebuilt["element_ir_summary"] == legacy_dict["element_ir_summary"] - - -# --------------------------------------------------------------------------- -# 4. content_hash unchanged for pre-P4-5 inputs -# --------------------------------------------------------------------------- - - -class TestContentHashStability: - @pytest.mark.unit - def test_hash_does_not_depend_on_element_ir_dict_value(self) -> None: - bundle = _bundle() - baseline_hash = bundle.content_hash() - # Mutate `element_ir_dict` (via dataclasses.replace) and confirm - # the hash is unchanged — content_hash deliberately covers the - # legacy summary + plans only, so the new field stays - # informational and pre-P4-5 golden hashes survive. - from dataclasses import replace as _replace - - mutated = _replace(bundle, element_ir_dict={"injected": "value"}) - assert mutated.content_hash() == baseline_hash - - -# --------------------------------------------------------------------------- -# 5. Ownership hierarchy is reflected in the bundle docstring -# --------------------------------------------------------------------------- - - -class TestOwnershipDocumented: - @pytest.mark.unit - def test_docstring_describes_post_p4_5_hierarchy(self) -> None: - assert ArtifactBundle.__doc__ is not None - doc = ArtifactBundle.__doc__ - assert "element_ir_dict" in doc - assert "P4-5" in doc - # The "primary semantic carrier" / "derived optimizer view" framing - # makes the ownership question concrete in the docstring. - assert "primary" in doc.lower() - assert "derived" in doc.lower() diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_1.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_1.py deleted file mode 100644 index 4fa0d2c..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_1.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Live audit for recovery-plan P5-1: Taichi as the only stable backend. - -Asserts that: - -1. The canonical ``compile_latex()`` and ``compile()`` API surfaces only - emit Taichi code (no MFEM, MOOSE, or other experimental backends). -2. All stable example scripts in ``dev/examples/`` only import Taichi - codegen or the programmatic API, never experimental backends. -3. Public API documentation marks Taichi as the MVP-stable backend and - clearly indicates that MFEM/MOOSE are experimental (deferred to Plan B). -4. No regressions on existing tests (acceptance criterion: test suite passes). -""" - -from __future__ import annotations - -import inspect -from pathlib import Path - -import pytest - -_ROOT = Path(__file__).resolve().parents[5] -_EXAMPLES = _ROOT / "dev" / "examples" -_CODEGEN_SRC = _ROOT / "packages" / "mechdsl-core" / "src" / "mechdsl" / "codegen" - - -def _read_text(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def _stable_example_files() -> list[Path]: - """Return all .py example files from dev/examples/. - - All examples in this directory are considered stable unless they live - in a sub-folder explicitly marked experimental (e.g. ``_experimental/``). - """ - return list(_EXAMPLES.glob("*.py")) - - -class TestP5_1: - """ - Tests for Task P5-1: Define Taichi as the only stable backend. - Tier: docs (documentation/example audit) - """ - - @pytest.mark.integration - def test_compile_latex_docstring_documents_taichi_stability(self) -> None: - """ - Verifies: compile_latex() docstring explains that it produces Taichi code. - Acceptance criterion: P5-1-c2 (deliverables present at canonical surface). - Passes when: compile_latex() docstring mentions Taichi and describes the pipeline. - Expected: docstring in mechdsl/__init__.py naming Taichi and the MVP contract. - """ - from mechdsl import compile_latex - - doc = inspect.getdoc(compile_latex) - assert doc is not None, "compile_latex() has no docstring" - - # Must name Taichi explicitly as the only MVP-stable backend - assert "Taichi" in doc, "compile_latex() docstring must mention Taichi" - assert "MVP-stable" in doc, "compile_latex() docstring must say 'MVP-stable'" - - # Must flag MFEM and MOOSE as not reachable / experimental - assert "MFEM" in doc, "compile_latex() docstring must mention MFEM as experimental" - assert "MOOSE" in doc, "compile_latex() docstring must mention MOOSE as experimental" - assert "experimental" in doc, "compile_latex() docstring must use the word 'experimental'" - - @pytest.mark.integration - def test_compile_api_docstring_documents_taichi_stability(self) -> None: - """ - Verifies: compile() docstring explains that it produces Taichi code. - Acceptance criterion: P5-1-c2 (deliverables present at package API surface). - Passes when: compile() docstring in mechdsl/codegen/__init__.py mentions Taichi. - Expected: docstring naming Taichi as the sole supported backend for MVP. - """ - from mechdsl.codegen import compile as compile_api - - doc = inspect.getdoc(compile_api) - assert doc is not None, "compile() has no docstring" - - # Must name Taichi as the only MVP-stable backend - assert "Taichi" in doc, "compile() docstring must mention Taichi" - assert "MVP-stable" in doc, "compile() docstring must say 'MVP-stable'" - - # Must acknowledge the existence of experimental alternatives - assert "MFEM" in doc, "compile() docstring must mention MFEM as experimental" - assert "MOOSE" in doc, "compile() docstring must mention MOOSE as experimental" - assert "experimental" in doc, "compile() docstring must use the word 'experimental'" - - @pytest.mark.integration - def test_stable_examples_do_not_import_mfem_printer(self) -> None: - """ - Verifies: Stable example scripts do not import mechdsl.codegen.mfem_printer. - Acceptance criterion: P5-1-c1 (stable examples use Taichi only). - Passes when: all examples in dev/examples/*.py are free of MFEM imports. - Expected: no "from mechdsl.codegen.mfem_printer" or "from mechdsl.codegen import mfem". - """ - violations: list[str] = [] - for path in _stable_example_files(): - source = _read_text(path) - if "mfem_printer" in source: - violations.append(path.name) - - assert not violations, ( - f"Stable examples must not import mfem_printer. Violations: {violations}" - ) - - @pytest.mark.integration - def test_stable_examples_do_not_import_moose_printer(self) -> None: - """ - Verifies: Stable example scripts do not import mechdsl.codegen.moose_printer. - Acceptance criterion: P5-1-c1 (stable examples use Taichi only). - Passes when: all examples in dev/examples/*.py are free of MOOSE imports. - Expected: no "from mechdsl.codegen.moose_printer" or "from mechdsl.codegen import moose". - """ - violations: list[str] = [] - for path in _stable_example_files(): - source = _read_text(path) - if "moose_printer" in source: - violations.append(path.name) - - assert not violations, ( - f"Stable examples must not import moose_printer. Violations: {violations}" - ) - - @pytest.mark.integration - def test_stable_examples_only_use_public_api_or_taichi_printer(self) -> None: - """ - Verifies: Stable example scripts use only compile() / compile_latex() - or taichi_printer emit() at most. - Acceptance criterion: P5-1-c1 (stable examples use Taichi only). - Passes when: all examples import from mechdsl (public), mechdsl.frontend, - mechdsl.ir, or mechdsl.solver — never experimental codegen. - Expected: imports limited to {compile, compile_latex, frontend, ir, solver}. - """ - # Forbidden import patterns (experimental backend modules) - forbidden_patterns = [ - "mfem_printer", - "moose_printer", - ] - violations: list[str] = [] - for path in _stable_example_files(): - source = _read_text(path) - for pattern in forbidden_patterns: - if pattern in source: - violations.append(f"{path.name}: contains '{pattern}'") - - assert not violations, ( - f"Stable examples must only use public API or taichi_printer. Violations: {violations}" - ) - - @pytest.mark.integration - def test_readme_states_taichi_is_mvp_stable_backend(self) -> None: - """ - Verifies: README.md documents that Taichi is the MVP-stable backend. - Acceptance criterion: P5-1-c2 (deliverables present at documentation surface). - Passes when: README names Taichi as the supported backend for the MVP phase. - Expected: text like "Taichi is the stable backend" or "MVP supports Taichi". - """ - readme = _read_text(_ROOT / "README.md") - - # README must name Taichi as MVP-stable in the Support tiers section - assert "Taichi" in readme, "README must mention Taichi" - assert "MVP-stable" in readme, "README must use the term 'MVP-stable'" - - # The intro / overview line must identify Taichi as the stable backend - # (not just "primary") — check the intro paragraph and architecture table - assert "Taichi (MVP-stable)" in readme, ( - "README intro / architecture table must read 'Taichi (MVP-stable)', " - "not merely 'Taichi (primary)'" - ) - - @pytest.mark.integration - def test_readme_marks_mfem_and_moose_experimental(self) -> None: - """ - Verifies: README.md marks MFEM and MOOSE as experimental or deferred. - Acceptance criterion: P5-1-c2 (deliverables present at documentation surface). - Passes when: README states that MFEM/MOOSE are experimental (Plan B). - Expected: text like "MFEM and MOOSE are experimental" or "deferred to Plan B". - """ - readme = _read_text(_ROOT / "README.md") - - # Support tiers section must list MFEM and MOOSE as experimental - assert "MFEM" in readme, "README must mention MFEM" - assert "MOOSE" in readme, "README must mention MOOSE" - - # The intro line now says "MFEM (experimental)" and "MOOSE (experimental)" - assert "MFEM (experimental)" in readme, "README intro must label MFEM as '(experimental)'" - assert "MOOSE (experimental)" in readme, "README intro must label MOOSE as '(experimental)'" - - # The MFEM usage example must be marked as experimental - assert "Emitting to the MFEM backend (experimental)" in readme, ( - "README MFEM usage example heading must say '(experimental)'" - ) - - @pytest.mark.integration - def test_no_regression_on_existing_test_suite(self) -> None: - """ - Verifies: No regressions on existing tests after P5-1 changes. - Acceptance criterion: P5-1 acceptance criterion (no regressions). - Passes when: pytest -m "not slow and not gpu" runs with zero failures. - Expected: all non-slow, non-GPU tests pass (run via CI). - """ - pytest.skip( - "Regression sentinel — full suite runs in CI; this skip preserves the " - "acceptance-criterion mapping for tooling that scans test ids per task. " - "Run locally with: uv run pytest packages/mechdsl-core/tests/ " - "-m 'not slow and not gpu and not e2e' -x --timeout=60" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_2.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_2.py deleted file mode 100644 index 644fe85..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_2.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Live audit for recovery-plan P5-2: Mark MFEM/MOOSE printers as experimental backend surfaces. - -Asserts that MFEM and MOOSE printers are explicitly marked as experimental -backend surfaces for Phase 5 (R4), which re-anchors Taichi codegen as the -only stable compile path. The experimental marker surfaces in: - -1. Module docstrings with explicit "experimental" tier label (P1-2 marker - preserved from the prior tier statement). -2. Module-level ``__experimental__: bool = True`` constant for - programmatic detection. -3. ``ExperimentalBackendWarning`` raised on first call to each printer's - public ``emit`` function. -4. Module docstring of :mod:`mechdsl.codegen` describing the convention. - -This task preserves the experimental backend work while making the stable -contract unambiguous — Taichi is the canonical path; MFEM/MOOSE are -research/compatibility layers. -""" - -from __future__ import annotations - -import importlib -import warnings - -import pytest - -from mechdsl.codegen import mfem_printer, moose_printer -from mechdsl.codegen._experimental import ExperimentalBackendWarning -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import localise_and_optimize - - -def _make_svk_bundle() -> ArtifactBundle: - """Minimal Hex8 / SVK bundle accepted by both experimental printers.""" - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=(BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET),), - ) - loc, plans = localise_and_optimize(problem_ir) - return ArtifactBundle.from_pipeline(problem_ir, loc, plans) - - -class TestP5_2: - """ - Tests for Task P5-2: Mark MFEM/MOOSE printers as experimental backend surfaces. - Tier: docs - """ - - @pytest.mark.integration - def test_p5_2_c1_experimental_marker_in_module_docstrings(self) -> None: - """ - Verify that both mfem_printer.py and moose_printer.py module docstrings - explicitly label their support tier as experimental (P1-2 marker - preserved) and expose ``__experimental__ is True``. - - Criterion: P5-2-c1 — Tests/docs label these backends as experimental. - """ - # P1-2 docstring marker still present. - assert mfem_printer.__doc__ is not None - assert "experimental" in mfem_printer.__doc__.lower(), ( - "mfem_printer module docstring must retain the 'experimental' " - "support-tier marker established in P1-2." - ) - assert moose_printer.__doc__ is not None - assert "experimental" in moose_printer.__doc__.lower(), ( - "moose_printer module docstring must retain the 'experimental' " - "support-tier marker established in P1-2." - ) - - # Programmatic flag for tooling/tests. - assert mfem_printer.__experimental__ is True, ( - "mfem_printer must expose `__experimental__ = True` for programmatic detection." - ) - assert moose_printer.__experimental__ is True, ( - "moose_printer must expose `__experimental__ = True` for programmatic detection." - ) - - @pytest.mark.integration - def test_p5_2_c2_deliverables_present_at_surfaces( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """ - Verify that all deliverables for P5-2 are in place at the listed surfaces: - - - ``ExperimentalBackendWarning`` exists and subclasses ``UserWarning``. - - Each experimental printer re-exports it. - - First call to ``mfem_printer.emit`` and ``moose_printer.emit`` raises - a single ``ExperimentalBackendWarning``. - - ``mechdsl.codegen`` module docstring describes the - ``__experimental__`` convention. - - Criterion: P5-2-c2 — deliverables present at the listed surfaces. - """ - # Warning class shape. - assert issubclass(ExperimentalBackendWarning, UserWarning), ( - "ExperimentalBackendWarning must subclass UserWarning so it is " - "filterable with the standard warnings machinery." - ) - - # Re-exports on each experimental printer. - assert mfem_printer.ExperimentalBackendWarning is ExperimentalBackendWarning - assert moose_printer.ExperimentalBackendWarning is ExperimentalBackendWarning - - # codegen package docstring covers the convention (terse). - codegen_pkg = importlib.import_module("mechdsl.codegen") - assert codegen_pkg.__doc__ is not None - assert "__experimental__" in codegen_pkg.__doc__, ( - "mechdsl.codegen package docstring must describe the `__experimental__` convention." - ) - - # First-use warning behaviour: reset the one-shot warn-state via - # monkeypatch so the assertion is hermetic, then check both printers - # raise on the next emit() call. - bundle = _make_svk_bundle() - - monkeypatch.setattr(mfem_printer, "_warn_state", {"warned": False}) - with warnings.catch_warnings(): - warnings.simplefilter("always", ExperimentalBackendWarning) - with pytest.warns(ExperimentalBackendWarning): - mfem_printer.emit(bundle) - - monkeypatch.setattr(moose_printer, "_warn_state", {"warned": False}) - with warnings.catch_warnings(): - warnings.simplefilter("always", ExperimentalBackendWarning) - with pytest.warns(ExperimentalBackendWarning): - moose_printer.emit(bundle) - - @pytest.mark.integration - def test_experimental_warning_only_fires_once_per_session( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """ - Verify the one-shot semantics: after a fresh ``_warn_state``, the first - ``emit()`` call raises ``ExperimentalBackendWarning`` exactly once and a - second call in the same session is silent. - - Criterion: P5-2 follow-up — one warning per session, not per call. - """ - bundle = _make_svk_bundle() - - # MFEM: first call warns, second call must be silent. - monkeypatch.setattr(mfem_printer, "_warn_state", {"warned": False}) - with warnings.catch_warnings(): - warnings.simplefilter("always", ExperimentalBackendWarning) - with pytest.warns(ExperimentalBackendWarning): - mfem_printer.emit(bundle) - - with warnings.catch_warnings(record=True) as recorded: - warnings.simplefilter("always", ExperimentalBackendWarning) - mfem_printer.emit(bundle) - experimental = [w for w in recorded if issubclass(w.category, ExperimentalBackendWarning)] - assert experimental == [], ( - "Second mfem_printer.emit call must NOT raise ExperimentalBackendWarning " - f"in the same session; got {len(experimental)} warning(s)." - ) - - # MOOSE: same contract. - monkeypatch.setattr(moose_printer, "_warn_state", {"warned": False}) - with warnings.catch_warnings(): - warnings.simplefilter("always", ExperimentalBackendWarning) - with pytest.warns(ExperimentalBackendWarning): - moose_printer.emit(bundle) - - with warnings.catch_warnings(record=True) as recorded: - warnings.simplefilter("always", ExperimentalBackendWarning) - moose_printer.emit(bundle) - experimental = [w for w in recorded if issubclass(w.category, ExperimentalBackendWarning)] - assert experimental == [], ( - "Second moose_printer.emit call must NOT raise ExperimentalBackendWarning " - f"in the same session; got {len(experimental)} warning(s)." - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_3.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_3.py deleted file mode 100644 index 76b8ec8..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_3.py +++ /dev/null @@ -1,534 +0,0 @@ -"""Live audit for recovery-plan P5-3: Taichi codegen façade layer. - -Task P5-3 introduces a thin façade over the existing module-level emitters -(``emit_preamble``, ``emit_constants``, ``emit_field_declarations``, -``emit_constitutive_update``) in ``taichi_printer.py:323+``. The façade -should present a design-doc-aligned API (an object/class that aggregates -``emit_*`` helpers under one entry point) while leaving the underlying -emitters intact. - -Acceptance criteria: -1. Snapshot/API tests confirm façade stability without loss of current behavior. -2. All deliverables for P5-3 are in place at the surfaces listed. -3. No regressions on the existing test suite. -""" - -from __future__ import annotations - -import inspect - -import pytest - -import mechdsl.codegen.taichi_printer as tp -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.codegen.taichi_printer import ( - EmissionContext, - TaichiCodegenFacade, - emit, - emit_constants, - emit_constitutive_update, - emit_explicit_driver, - emit_field_declarations, - emit_internal_force_kernel, - emit_main, - emit_newton_driver, - emit_postprocess, - emit_preamble, - emit_tangent_matvec_kernel, - emit_validate_mesh, -) -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import localise_and_optimize - - -def _make_test_bundle() -> ArtifactBundle: - """Create a minimal test bundle for P5-3 façade testing.""" - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=(BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET),), - ) - loc_result, plans = localise_and_optimize(problem_ir) - return ArtifactBundle.from_pipeline(problem_ir, loc_result, plans) - - -def _make_j2_bundle() -> ArtifactBundle: - """Create a minimal test bundle with J2 plasticity for P5-3 façade testing.""" - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec( - model="j2_power_law", - params={"E": 200e3, "nu": 0.3, "sigma_y": 250.0, "n_exp": 10.0}, - ), - boundaries=(BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET),), - ) - loc_result, plans = localise_and_optimize(problem_ir) - return ArtifactBundle.from_pipeline(problem_ir, loc_result, plans) - - -# --------------------------------------------------------------------------- -# P5-3-c1: Snapshot/API tests confirm façade stability -# --------------------------------------------------------------------------- - - -class TestP5_3FacadeAPI: - """P5-3-c1: Façade API stability — object/class aggregates emit_* helpers.""" - - @pytest.mark.unit - def test_facade_class_exists_in_module(self) -> None: - """Verify that a façade class exists in taichi_printer module.""" - assert hasattr(tp, "TaichiCodegenFacade"), ( - "TaichiCodegenFacade class not found in mechdsl.codegen.taichi_printer" - ) - assert inspect.isclass(tp.TaichiCodegenFacade) - - @pytest.mark.unit - def test_facade_aggregates_preamble_emission(self) -> None: - """Façade has method/property that calls emit_preamble.""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "preamble"), "façade missing 'preamble' method" - assert callable(facade.preamble) - # Verify it actually delegates: output must match emit_preamble directly - bundle = _make_test_bundle() - ctx_direct = EmissionContext() - emit_preamble(ctx_direct, bundle) - ctx_facade = EmissionContext() - facade.preamble(ctx_facade, bundle) - assert ctx_facade.get_source() == ctx_direct.get_source() - - @pytest.mark.unit - def test_facade_aggregates_constants_emission(self) -> None: - """Façade has method/property that calls emit_constants.""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "constants"), "façade missing 'constants' method" - assert callable(facade.constants) - bundle = _make_test_bundle() - ctx_direct = EmissionContext() - emit_constants(ctx_direct, bundle) - ctx_facade = EmissionContext() - facade.constants(ctx_facade, bundle) - assert ctx_facade.get_source() == ctx_direct.get_source() - - @pytest.mark.unit - def test_facade_aggregates_field_declarations_emission(self) -> None: - """Façade has method/property that calls emit_field_declarations.""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "field_declarations"), "façade missing 'field_declarations' method" - assert callable(facade.field_declarations) - bundle = _make_test_bundle() - ctx_direct = EmissionContext() - emit_field_declarations(ctx_direct, bundle) - ctx_facade = EmissionContext() - facade.field_declarations(ctx_facade, bundle) - assert ctx_facade.get_source() == ctx_direct.get_source() - - @pytest.mark.unit - def test_facade_aggregates_constitutive_update_emission(self) -> None: - """Façade has method/property that calls emit_constitutive_update.""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "constitutive_update"), "façade missing 'constitutive_update' method" - assert callable(facade.constitutive_update) - bundle = _make_test_bundle() - ctx_direct = EmissionContext() - emit_constitutive_update(ctx_direct, bundle) - ctx_facade = EmissionContext() - facade.constitutive_update(ctx_facade, bundle) - assert ctx_facade.get_source() == ctx_direct.get_source() - - @pytest.mark.unit - def test_facade_aggregates_internal_force_kernel_emission(self) -> None: - """Façade delegates internal_force_kernel to emit_internal_force_kernel.""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "internal_force_kernel"), ( - "façade missing 'internal_force_kernel' method" - ) - assert callable(facade.internal_force_kernel) - bundle = _make_test_bundle() - - ctx_direct = EmissionContext() - emit_internal_force_kernel(ctx_direct, bundle) - direct_output = ctx_direct.get_source() - - ctx_facade = EmissionContext() - facade.internal_force_kernel(ctx_facade, bundle) - facade_output = ctx_facade.get_source() - - assert facade_output == direct_output - assert facade_output.strip() # non-empty - - @pytest.mark.unit - def test_facade_aggregates_tangent_matvec_kernel_emission(self) -> None: - """Façade delegates tangent_matvec_kernel to emit_tangent_matvec_kernel.""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "tangent_matvec_kernel"), ( - "façade missing 'tangent_matvec_kernel' method" - ) - assert callable(facade.tangent_matvec_kernel) - bundle = _make_test_bundle() - - ctx_direct = EmissionContext() - emit_tangent_matvec_kernel(ctx_direct, bundle) - direct_output = ctx_direct.get_source() - - ctx_facade = EmissionContext() - facade.tangent_matvec_kernel(ctx_facade, bundle) - facade_output = ctx_facade.get_source() - - assert facade_output == direct_output - assert facade_output.strip() # non-empty - - @pytest.mark.unit - def test_facade_aggregates_newton_driver_emission(self) -> None: - """Façade delegates newton_driver to emit_newton_driver.""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "newton_driver"), "façade missing 'newton_driver' method" - assert callable(facade.newton_driver) - bundle = _make_test_bundle() - - ctx_direct = EmissionContext() - emit_newton_driver(ctx_direct, bundle) - direct_output = ctx_direct.get_source() - - ctx_facade = EmissionContext() - facade.newton_driver(ctx_facade, bundle) - facade_output = ctx_facade.get_source() - - assert facade_output == direct_output - assert facade_output.strip() # non-empty - - @pytest.mark.unit - def test_facade_aggregates_explicit_driver_emission(self) -> None: - """Façade delegates explicit_driver to emit_explicit_driver.""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "explicit_driver"), "façade missing 'explicit_driver' method" - assert callable(facade.explicit_driver) - bundle = _make_test_bundle() - - ctx_direct = EmissionContext() - emit_explicit_driver(ctx_direct, bundle) - direct_output = ctx_direct.get_source() - - ctx_facade = EmissionContext() - facade.explicit_driver(ctx_facade, bundle) - facade_output = ctx_facade.get_source() - - assert facade_output == direct_output - assert facade_output.strip() # non-empty - - @pytest.mark.unit - def test_facade_aggregates_validate_mesh_emission(self) -> None: - """Façade delegates validate_mesh to emit_validate_mesh (ctx-only, no bundle).""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "validate_mesh"), "façade missing 'validate_mesh' method" - assert callable(facade.validate_mesh) - - ctx_direct = EmissionContext() - emit_validate_mesh(ctx_direct) - direct_output = ctx_direct.get_source() - - ctx_facade = EmissionContext() - facade.validate_mesh(ctx_facade) - facade_output = ctx_facade.get_source() - - assert facade_output == direct_output - assert facade_output.strip() # non-empty - - @pytest.mark.unit - def test_facade_aggregates_postprocess_emission(self) -> None: - """Façade delegates postprocess to emit_postprocess.""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "postprocess"), "façade missing 'postprocess' method" - assert callable(facade.postprocess) - bundle = _make_test_bundle() - - ctx_direct = EmissionContext() - emit_postprocess(ctx_direct, bundle) - direct_output = ctx_direct.get_source() - - ctx_facade = EmissionContext() - facade.postprocess(ctx_facade, bundle) - facade_output = ctx_facade.get_source() - - assert facade_output == direct_output - assert facade_output.strip() # non-empty - - @pytest.mark.unit - def test_facade_aggregates_main_emission(self) -> None: - """Façade delegates main to emit_main.""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "main"), "façade missing 'main' method" - assert callable(facade.main) - bundle = _make_test_bundle() - - ctx_direct = EmissionContext() - emit_main(ctx_direct, bundle) - direct_output = ctx_direct.get_source() - - ctx_facade = EmissionContext() - facade.main(ctx_facade, bundle) - facade_output = ctx_facade.get_source() - - assert facade_output == direct_output - assert facade_output.strip() # non-empty - - @pytest.mark.unit - def test_facade_make_context_returns_emission_context(self) -> None: - """Façade.make_context() returns a fresh EmissionContext instance.""" - facade = TaichiCodegenFacade() - assert hasattr(facade, "make_context"), "façade missing 'make_context' method" - assert callable(facade.make_context) - - ctx = facade.make_context() - assert isinstance(ctx, EmissionContext), ( - f"make_context() returned {type(ctx).__name__!r}, expected EmissionContext" - ) - # Each call should return a fresh (independent) context - ctx2 = facade.make_context() - assert ctx is not ctx2, "make_context() returned the same object on repeated calls" - - -class TestP5_3FacadeSnapshot: - """P5-3-c1: Snapshot equality — output unchanged after façade wrapping.""" - - @pytest.mark.unit - def test_facade_emission_matches_direct_emission(self) -> None: - """Output from façade emission equals output from direct emitter calls. - - This verifies that the new façade layer is a pure wrapper with no - changes to the emitted code. The acceptance bar is snapshot equality. - """ - bundle = _make_test_bundle() - direct_source = emit(bundle) - facade = TaichiCodegenFacade() - facade_source = facade.emit_all(bundle) - assert facade_source == direct_source, ( - "Façade emit_all() output differs from direct emit() — " - "façade must be a pure pass-through wrapper" - ) - - @pytest.mark.unit - def test_facade_deterministic_output(self) -> None: - """Façade produces deterministic output — same input → same output.""" - bundle = _make_test_bundle() - facade = TaichiCodegenFacade() - source_a = facade.emit_all(bundle) - source_b = facade.emit_all(bundle) - assert source_a == source_b, "TaichiCodegenFacade.emit_all() is not deterministic" - - @pytest.mark.unit - def test_svk_vs_j2_still_differ_through_facade(self) -> None: - """Different material models still produce different façade output.""" - svk_bundle = _make_test_bundle() - j2_bundle = _make_j2_bundle() - facade = TaichiCodegenFacade() - svk_source = facade.emit_all(svk_bundle) - j2_source = facade.emit_all(j2_bundle) - assert svk_source != j2_source, ( - "SVK and J2 bundles produced identical façade output — " - "material-model distinction was lost" - ) - - -class TestP5_3BackendStability: - """P5-3-c1: Underlying emitters remain unchanged — backward compatibility.""" - - @pytest.mark.unit - def test_emit_preamble_callable_and_signature_unchanged(self) -> None: - """emit_preamble signature remains compatible with prior usage. - - Asserts callable, that 'ctx' and 'bundle' are present, and that the - minimum required arity is 2 — but allows optional kwargs to be added - in future without breaking this test. - """ - sig = inspect.signature(emit_preamble) - params = sig.parameters - assert "ctx" in params, f"emit_preamble missing 'ctx' parameter: {list(params)}" - assert "bundle" in params, f"emit_preamble missing 'bundle' parameter: {list(params)}" - required = [p for p in params.values() if p.default is inspect.Parameter.empty] - assert len(required) >= 2, ( - f"emit_preamble must have at least 2 required params (ctx + bundle), got {len(required)}" - ) - - @pytest.mark.unit - def test_emit_constants_callable_and_signature_unchanged(self) -> None: - """emit_constants signature remains compatible with prior usage. - - Asserts callable, that 'ctx' and 'bundle' are present, and that the - minimum required arity is 2 — but allows optional kwargs to be added - in future without breaking this test. - """ - sig = inspect.signature(emit_constants) - params = sig.parameters - assert "ctx" in params, f"emit_constants missing 'ctx' parameter: {list(params)}" - assert "bundle" in params, f"emit_constants missing 'bundle' parameter: {list(params)}" - required = [p for p in params.values() if p.default is inspect.Parameter.empty] - assert len(required) >= 2, ( - f"emit_constants must have at least 2 required params (ctx + bundle), got {len(required)}" - ) - - @pytest.mark.unit - def test_emit_field_declarations_callable_and_signature_unchanged(self) -> None: - """emit_field_declarations signature remains compatible with prior usage. - - Asserts callable, that 'ctx' and 'bundle' are present, and that the - minimum required arity is 2 — but allows optional kwargs to be added - in future without breaking this test. - """ - sig = inspect.signature(emit_field_declarations) - params = sig.parameters - assert "ctx" in params, f"emit_field_declarations missing 'ctx' parameter: {list(params)}" - assert "bundle" in params, ( - f"emit_field_declarations missing 'bundle' parameter: {list(params)}" - ) - required = [p for p in params.values() if p.default is inspect.Parameter.empty] - assert len(required) >= 2, ( - f"emit_field_declarations must have at least 2 required params (ctx + bundle), " - f"got {len(required)}" - ) - - @pytest.mark.unit - def test_emit_constitutive_update_callable_and_signature_unchanged(self) -> None: - """emit_constitutive_update signature remains compatible with prior usage. - - Asserts callable, that 'ctx' and 'bundle' are present, and that the - minimum required arity is 2 — but allows optional kwargs to be added - in future without breaking this test. - """ - sig = inspect.signature(emit_constitutive_update) - params = sig.parameters - assert "ctx" in params, f"emit_constitutive_update missing 'ctx' parameter: {list(params)}" - assert "bundle" in params, ( - f"emit_constitutive_update missing 'bundle' parameter: {list(params)}" - ) - required = [p for p in params.values() if p.default is inspect.Parameter.empty] - assert len(required) >= 2, ( - f"emit_constitutive_update must have at least 2 required params (ctx + bundle), " - f"got {len(required)}" - ) - - @pytest.mark.unit - def test_emitters_still_callable_via_direct_import(self) -> None: - """Direct imports of emitter functions continue to work.""" - # All the imports at the top of this module already exercise this. - # Additionally verify they are callable and present on the module. - for name in ( - "emit_preamble", - "emit_constants", - "emit_field_declarations", - "emit_constitutive_update", - "emit_internal_force_kernel", - "emit_tangent_matvec_kernel", - "emit_newton_driver", - "emit_explicit_driver", - "emit_validate_mesh", - "emit_postprocess", - "emit_main", - "emit", - ): - assert hasattr(tp, name), f"module-level function '{name}' missing" - assert callable(getattr(tp, name)), f"'{name}' is not callable" - - -# --------------------------------------------------------------------------- -# P5-3-c2: Deliverables present at surfaces -# --------------------------------------------------------------------------- - - -class TestP5_3ExportSurface: - """P5-3-c2: Façade is exported at package level.""" - - @pytest.mark.unit - def test_facade_exported_from_taichi_printer(self) -> None: - """Façade is exported from mechdsl.codegen.taichi_printer module.""" - from mechdsl.codegen import taichi_printer - - assert hasattr(taichi_printer, "TaichiCodegenFacade"), ( - "TaichiCodegenFacade not found on mechdsl.codegen.taichi_printer" - ) - - @pytest.mark.unit - def test_facade_accessible_via_package_import(self) -> None: - """Façade can be imported from mechdsl.codegen (or broader).""" - from mechdsl.codegen import TaichiCodegenFacade as _F - - assert _F is TaichiCodegenFacade - - @pytest.mark.unit - def test_facade_has_design_doc_aligned_api(self) -> None: - """Façade API aligns with design document style (v1.0 codegen spec).""" - facade = TaichiCodegenFacade() - # Verify the expected method names for the design-doc-aligned API - expected_methods = [ - "make_context", - "preamble", - "constants", - "field_declarations", - "constitutive_update", - "internal_force_kernel", - "tangent_matvec_kernel", - "newton_driver", - "explicit_driver", - "validate_mesh", - "postprocess", - "main", - "emit_all", - ] - for method in expected_methods: - assert hasattr(facade, method), ( - f"TaichiCodegenFacade missing design-doc method: '{method}'" - ) - assert callable(getattr(facade, method)), ( - f"TaichiCodegenFacade.{method} is not callable" - ) - - -class TestP5_3FileDeliverables: - """P5-3-c2: All deliverables are in place.""" - - @pytest.mark.unit - def test_taichi_printer_contains_facade_definition(self) -> None: - """taichi_printer.py contains the façade class/object definition.""" - import inspect - - source = inspect.getsource(tp) - assert "class TaichiCodegenFacade" in source, ( - "TaichiCodegenFacade class definition not found in taichi_printer.py" - ) - - @pytest.mark.unit - def test_package_init_exports_facade(self) -> None: - """Package __init__.py exports the façade for public API.""" - import mechdsl.codegen as codegen_pkg - - assert hasattr(codegen_pkg, "TaichiCodegenFacade"), ( - "TaichiCodegenFacade not in mechdsl.codegen namespace" - ) - # Also verify it appears in __all__ if defined - if hasattr(codegen_pkg, "__all__"): - assert "TaichiCodegenFacade" in codegen_pkg.__all__, ( - "TaichiCodegenFacade not in mechdsl.codegen.__all__" - ) - - @pytest.mark.unit - def test_facade_has_docstring_explaining_role(self) -> None: - """Façade has docstring documenting its role as aggregator.""" - doc = TaichiCodegenFacade.__doc__ - assert doc is not None, "TaichiCodegenFacade has no docstring" - assert len(doc.strip()) > 0, "TaichiCodegenFacade docstring is empty" - # Verify the docstring mentions façade/aggregation intent - doc_lower = doc.lower() - assert any( - keyword in doc_lower - for keyword in ("façade", "facade", "aggregat", "design-doc", "wrapper", "delegate") - ), "TaichiCodegenFacade docstring does not describe its façade/aggregation role" diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_4.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_4.py deleted file mode 100644 index 392c5d7..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_4.py +++ /dev/null @@ -1,350 +0,0 @@ -"""Live audit for recovery-plan P5-4: Taichi printer consumes enriched IR. - -Asserts that: - -1. The Taichi printer reads from ``ArtifactBundle.element_ir_dict`` - (set by P4-5 from ``ElementIR.to_dict()``) instead of relying - primarily on the legacy ``element_ir_summary``. -2. Codegen tests show canonical path works with enriched IR fields - (``geometry``, ``material_eval``, ``local_force``, ``local_tangent``). -3. Emitted code makes decisions based on enriched IR rather than - inline sniffing of ``problem_ir.formulation`` (e.g. stress measure - choice drives force layout). -4. Output is functionally equivalent to pre-P5-4 codegen (no regressions - on numerical kernel tests). -5. The printer surfaces enriched-IR metadata in emitted comments (e.g. - docstring, function preamble) for auditability. -""" - -from __future__ import annotations - -from dataclasses import replace as _replace - -import pytest - -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.codegen.taichi_printer import ( - EmissionContext, - _ir_block, - _ir_field, - _n_quadrature_points, - emit, - emit_constants, - emit_preamble, -) -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import localise_and_optimize - -# --------------------------------------------------------------------------- -# Fixtures (mirror the P4-5 pattern) -# --------------------------------------------------------------------------- - - -@pytest.fixture(scope="module") -def enriched_bundle() -> ArtifactBundle: - """Build a bundle with element_ir_dict populated (post-P4-5 path).""" - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - ) - loc, plans = localise_and_optimize(problem_ir) - return ArtifactBundle.from_pipeline(loc.problem_ir, loc, plans) - - -@pytest.fixture(scope="module") -def legacy_bundle() -> ArtifactBundle: - """Build a bundle with empty element_ir_dict (legacy fallback path).""" - return ArtifactBundle( - problem_ir_dict={ - "dim": 3, - "formulation": "total_lagrangian", - "material": {"model": "svk", "params": {"E": 200e3, "nu": 0.3}}, - }, - element_ir_summary={ - "element_type": "hex8", - "n_nodes": 8, - "dim": 3, - "n_quadrature_points": 8, - "formulation": "total_lagrangian", - }, - # element_ir_dict left at the default (empty dict). - ) - - -class TestP5_4: - """Unit tests for P5-4: Taichi printer consumes enriched IR.""" - - @pytest.mark.unit - def test_printer_prefers_element_ir_dict_over_summary( - self, legacy_bundle: ArtifactBundle - ) -> None: - """Codegen prefers element_ir_dict when available. - - Verifies - -------- - When ArtifactBundle carries populated element_ir_dict, the Taichi - printer's ``_ir_field`` helper resolves to the dict's value rather - than the summary's. We construct a bundle whose two carriers - DISAGREE on a field, then assert the helper picks the dict. - - Criterion - --------- - P5-4-c1: Codegen tests show canonical path works with enriched IR fields. - """ - bundle = _replace( - legacy_bundle, - element_ir_dict={ - "element_type": "hex8", - "n_nodes": 8, - "dim": 3, - "formulation": "total_lagrangian", - }, - element_ir_summary={ - "element_type": "BOGUS_LEGACY_TYPE", - "n_nodes": -1, - "dim": -1, - "n_quadrature_points": 8, - }, - ) - # Helper picks the dict when both are populated. - assert _ir_field(bundle, "element_type", "fallback") == "hex8" - assert _ir_field(bundle, "n_nodes", 0) == 8 - assert _ir_field(bundle, "dim", 0) == 3 - - @pytest.mark.unit - def test_stress_measure_from_enriched_ir(self, enriched_bundle: ArtifactBundle) -> None: - """Material eval stress measure comes from enriched IR. - - Verifies - -------- - ``_ir_block`` returns the ``material_eval`` block from - ``element_ir_dict`` and the auditability emission surfaces the - ``stress_measure`` field in the emitted source's docstring. - - Criterion - --------- - P5-4-c1: Codegen tests show canonical path works with enriched IR fields. - """ - bundle = enriched_bundle - block = _ir_block(bundle, "material_eval") - assert block is not None - assert block["stress_measure"] == "pk2" - - ctx = EmissionContext(verbose=True) - emit_preamble(ctx, bundle) - source = ctx.get_source() - assert "Stress measure" in source - assert "pk2" in source - - @pytest.mark.unit - def test_force_descriptor_from_enriched_ir(self, enriched_bundle: ArtifactBundle) -> None: - """Local force descriptor comes from enriched IR. - - Verifies - -------- - ``_ir_block`` exposes the ``local_force`` block and the verbose - preamble surfaces ``n_dof`` in the audit comment. - - Criterion - --------- - P5-4-c1: Codegen tests show canonical path works with enriched IR fields. - """ - bundle = enriched_bundle - block = _ir_block(bundle, "local_force") - assert block is not None - assert block["n_dof"] == 24 # Hex8: 8 nodes * 3 dim - - ctx = EmissionContext(verbose=True) - emit_preamble(ctx, bundle) - source = ctx.get_source() - assert "Force n_dof" in source - assert "24" in source - - @pytest.mark.unit - def test_tangent_symmetry_from_enriched_ir(self, enriched_bundle: ArtifactBundle) -> None: - """Tangent symmetry flag comes from enriched IR. - - Verifies - -------- - ``_ir_block`` exposes ``local_tangent`` and the verbose preamble - surfaces the ``is_symmetric`` flag. - - Criterion - --------- - P5-4-c1: Codegen tests show canonical path works with enriched IR fields. - """ - bundle = enriched_bundle - block = _ir_block(bundle, "local_tangent") - assert block is not None - assert block["is_symmetric"] is True - - ctx = EmissionContext(verbose=True) - emit_preamble(ctx, bundle) - source = ctx.get_source() - assert "Tangent symmetric" in source - assert "True" in source - - @pytest.mark.unit - def test_geometry_summary_available_in_codegen(self, enriched_bundle: ArtifactBundle) -> None: - """Geometry summary is available for codegen inspection. - - Verifies - -------- - The printer accesses ``element_ir_dict["geometry"]`` for - ``n_quad`` via the dedicated ``_n_quadrature_points`` helper. - emit_constants then uses that count instead of the legacy - ``element_ir_summary["n_quadrature_points"]`` key. - - Criterion - --------- - P5-4-c1: Codegen tests show canonical path works with enriched IR fields. - """ - bundle = enriched_bundle - block = _ir_block(bundle, "geometry") - assert block is not None - assert block["n_quad"] == 8 - assert "reference_volume" in block - assert "natural_coord_dim" in block - # Helper resolves through the geometry block. - assert _n_quadrature_points(bundle) == 8 - - # And the verbose audit surfaces the count. - ctx = EmissionContext(verbose=True) - emit_preamble(ctx, bundle) - source = ctx.get_source() - assert "Quadrature" in source - assert "8-point" in source - - @pytest.mark.unit - def test_emitted_code_roundtrips_through_enriched_bundle( - self, enriched_bundle: ArtifactBundle - ) -> None: - """Emitted code is deterministic from enriched bundle. - - Verifies - -------- - Given an ArtifactBundle with populated element_ir_dict, calling - the Taichi printer twice produces identical output, and the - standard preamble is intact (auditability comments are additive - only when ``verbose=True``). - - Criterion - --------- - Acceptance: No regressions on the existing test suite. - """ - bundle = enriched_bundle - s1 = emit(bundle) - s2 = emit(bundle) - assert s1 == s2, "emit() must be deterministic for the same bundle" - - # Standard preamble headers always present. - assert '"""Auto-generated Taichi FEM solver. DO NOT EDIT.' in s1 - assert "Formulation : total_lagrangian" in s1 - assert "Material : svk" in s1 - assert "Element : hex8" in s1 - assert "Dimension : 3" in s1 - assert "import taichi as ti" in s1 - # Default emission is non-verbose: audit block must NOT appear. - assert "Enriched-IR contract surface" not in s1 - - # Verbose emission via the step-wise API: audit lines DO appear. - ctx = EmissionContext(verbose=True) - emit_preamble(ctx, bundle) - verbose_source = ctx.get_source() - assert "Enriched-IR contract surface" in verbose_source - assert "Stress measure" in verbose_source - - @pytest.mark.unit - def test_backward_compat_empty_element_ir_dict(self, legacy_bundle: ArtifactBundle) -> None: - """Printer handles empty element_ir_dict gracefully. - - Verifies - -------- - When element_ir_dict is empty (pre-P4-5 bundles), the printer - falls back cleanly to element_ir_summary without error. - - Criterion - --------- - Acceptance: No regressions on the existing test suite. - """ - bundle = legacy_bundle - assert bundle.element_ir_dict == {} - - # Helper falls back to summary. - assert _ir_field(bundle, "element_type", "fallback") == "hex8" - assert _ir_field(bundle, "n_nodes", 0) == 8 - assert _ir_field(bundle, "dim", 0) == 3 - # n_quadrature_points falls back via the dedicated helper. - assert _n_quadrature_points(bundle) == 8 - - # Preamble emits without raising; audit block stays empty since - # neither carrier holds the four enrichment blocks. - ctx = EmissionContext() - emit_preamble(ctx, bundle) - # emit_constants must also work on the legacy bundle. - emit_constants(ctx, bundle) - source = ctx.get_source() - assert "Element : hex8" in source - assert "N_NODES = 8" in source - assert "N_QP = 8" in source - assert "DIM = 3" in source - - # And even with verbose=True, the legacy bundle has no enrichment - # to surface, so the audit block is suppressed (no header line). - ctx_v = EmissionContext(verbose=True) - emit_preamble(ctx_v, bundle) - legacy_verbose = ctx_v.get_source() - assert "Enriched-IR contract surface" not in legacy_verbose - - @pytest.mark.unit - def test_deliverable_surfaces_documented(self) -> None: - """All P5-4 deliverables are present at specified surfaces. - - Verifies - -------- - Taichi printer exports the new helpers, the EmissionContext carries - the ``verbose`` flag, and the module/preamble docstrings document - the enriched-IR consumption. - - Criterion - --------- - P5-4-c2: deliverables present at the listed surfaces - (taichi_printer.py:333-352 + material emission paths). - """ - from mechdsl.codegen import taichi_printer as tp - - # 1. Helpers exist at module scope (private but importable). - assert callable(tp._ir_field) - assert callable(tp._ir_block) - assert callable(tp._n_quadrature_points) - - # 2. EmissionContext carries the verbose flag with the documented default. - ctx = tp.EmissionContext() - assert hasattr(ctx, "verbose") - assert ctx.verbose is False - - # 3. Module docstring mentions the recovery P5-4 enriched-IR consumption. - assert tp.__doc__ is not None - assert "P5-4" in tp.__doc__ - assert "element_ir_dict" in tp.__doc__ - - # 4. emit_preamble's docstring documents the P5-4 sourcing change. - assert tp.emit_preamble.__doc__ is not None - assert "P5-4" in tp.emit_preamble.__doc__ - - # 5. emit_constants's docstring documents the P5-4 sourcing change. - assert tp.emit_constants.__doc__ is not None - assert "P5-4" in tp.emit_constants.__doc__ diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_5.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_5.py deleted file mode 100644 index 7ef9491..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p5_5.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Task P5-5: Split codegen verification into stable vs experimental suites. - -Phase 5 (R4) — regression-tier acceptance: verify that generated code verification -tests can be split into a "stable" suite (Taichi only, must-pass on every push) and -an "experimental" suite (MFEM/MOOSE, allowed to skip or xfail without blocking -the stable contract). - -Acceptance criteria: - 1. Stable suite passes independently of experimental backend status. - 2. All deliverables for P5-5 are in place at the surfaces listed. - 3. No regressions on the existing test suite. - -Implementation mechanism: pytest markers (stable_backend / experimental_backend) -and/or directory split. Stable tests can run independently; experimental tests -can be deselected via marker. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -# Root of the mechdsl-core tests directory. -_TESTS_ROOT = Path(__file__).parent.parent.parent # packages/mechdsl-core/tests/ -_PROJECT_ROOT = _TESTS_ROOT.parent.parent.parent # repo root - - -class TestP5_5: - """Tests for Task P5-5: Split codegen verification into stable vs experimental.""" - - @pytest.mark.integration - @pytest.mark.regression - def test_stable_suite_taichi_only_passes_independently(self) -> None: - """P5-5-c1: Stable suite passes independently of experimental backend status. - - Verifies by reflection: every file that constitutes the stable suite - (test_codegen, test_taichi_printer, test_taichi_printer_ul, - test_emission_phase5) carries the ``stable_backend`` marker at module - level, while none of the experimental files (test_mfem_printer, - test_moose_printer, test_cross_backend) carry ``stable_backend``. - - This guarantees that ``pytest -m "stable_backend"`` selects only - Taichi-only tests and deselects every MFEM/MOOSE test — so the stable - suite is independent of experimental backend availability. - """ - import ast - - stable_files = [ - "test_codegen", - "test_taichi_printer", - "test_taichi_printer_ul", - "test_emission_phase5", - "test_emission_verification", - "test_emit_lame_conversion", - ] - experimental_files = [ - "test_mfem_printer", - "test_moose_printer", - "test_cross_backend", - ] - - def _get_pytestmark_names(module_name: str) -> list[str]: - """Parse a test module with AST and return pytestmark marker names. - - Uses AST parsing rather than import so that modules with relative - imports or heavy optional dependencies (taichi, mfem, moose) can - be inspected without executing their import-time side effects. - - Looks for a top-level assignment of the form: - pytestmark = pytest.mark. - or a list variant: - pytestmark = [pytest.mark., ...] - """ - test_file = _TESTS_ROOT / f"{module_name}.py" - assert test_file.exists(), f"Test file not found: {test_file}" - tree = ast.parse(test_file.read_text(), filename=str(test_file)) - - marker_names: list[str] = [] - for node in ast.iter_child_nodes(tree): - if not isinstance(node, ast.Assign): - continue - # Only top-level: targets must include 'pytestmark'. - if not any(isinstance(t, ast.Name) and t.id == "pytestmark" for t in node.targets): - continue - # Walk the value to collect pytest.mark. attribute accesses. - for sub in ast.walk(node.value): - if ( - isinstance(sub, ast.Attribute) - and isinstance(sub.value, ast.Attribute) - and isinstance(sub.value.value, ast.Name) - and sub.value.value.id == "pytest" - and sub.value.attr == "mark" - ): - marker_names.append(sub.attr) - return marker_names - - # Verify stable files carry stable_backend. - for name in stable_files: - marks = _get_pytestmark_names(name) - assert "stable_backend" in marks, ( - f"{name}.py must carry pytestmark = pytest.mark.stable_backend " - f"(found markers: {marks})" - ) - assert "experimental_backend" not in marks, ( - f"{name}.py must NOT carry experimental_backend marker " - f"(stable files must be disjoint from experimental set)" - ) - - # Verify experimental files carry experimental_backend and NOT stable_backend. - for name in experimental_files: - marks = _get_pytestmark_names(name) - assert "experimental_backend" in marks, ( - f"{name}.py must carry pytestmark = pytest.mark.experimental_backend " - f"(found markers: {marks})" - ) - assert "stable_backend" not in marks, ( - f"{name}.py must NOT carry stable_backend marker " - f"(experimental files must be disjoint from stable set)" - ) - - @pytest.mark.integration - @pytest.mark.regression - def test_deliverables_present_at_surfaces(self) -> None: - """P5-5-c2: All deliverables for P5-5 are in place at the listed surfaces. - - Verifies: Changes to test_codegen*.py reflect the stable/experimental split. - Surfaces: - - packages/mechdsl-core/tests/test_codegen.py - - packages/mechdsl-core/tests/test_cross_backend.py - - packages/mechdsl-core/tests/test_mfem_printer.py - - packages/mechdsl-core/tests/test_moose_printer.py - """ - # 1. pyproject.toml registers both new markers. - pyproject = _PROJECT_ROOT / "pyproject.toml" - assert pyproject.exists(), f"pyproject.toml not found at {pyproject}" - pyproject_text = pyproject.read_text() - assert "stable_backend" in pyproject_text, ( - "pyproject.toml must register the 'stable_backend' marker " - "(required by --strict-markers)" - ) - assert "experimental_backend" in pyproject_text, ( - "pyproject.toml must register the 'experimental_backend' marker " - "(required by --strict-markers)" - ) - - # 2. test_codegen.py contains pytestmark with stable_backend. - test_codegen = _TESTS_ROOT / "test_codegen.py" - assert test_codegen.exists() - codegen_src = test_codegen.read_text() - assert "stable_backend" in codegen_src, ( - "test_codegen.py must contain pytestmark = pytest.mark.stable_backend" - ) - - # 3. test_mfem_printer.py contains pytestmark with experimental_backend. - test_mfem = _TESTS_ROOT / "test_mfem_printer.py" - assert test_mfem.exists() - mfem_src = test_mfem.read_text() - assert "experimental_backend" in mfem_src, ( - "test_mfem_printer.py must contain pytestmark = pytest.mark.experimental_backend" - ) - - # 4. test_moose_printer.py contains pytestmark with experimental_backend. - test_moose = _TESTS_ROOT / "test_moose_printer.py" - assert test_moose.exists() - moose_src = test_moose.read_text() - assert "experimental_backend" in moose_src, ( - "test_moose_printer.py must contain pytestmark = pytest.mark.experimental_backend" - ) - - # 5. test_cross_backend.py contains pytestmark with experimental_backend. - test_cross = _TESTS_ROOT / "test_cross_backend.py" - assert test_cross.exists() - cross_src = test_cross.read_text() - assert "experimental_backend" in cross_src, ( - "test_cross_backend.py must contain pytestmark = pytest.mark.experimental_backend" - ) - - @pytest.mark.integration - @pytest.mark.regression - def test_no_regressions_on_existing_suite(self) -> None: - """P5-5-c3: No regressions on the existing test suite. - - Verifies: All originally passing codegen tests still pass after the - split (stable tests still pass, experimental tests are correctly marked - and can be selectively deselected). - - This is a regression sentinel: the test files themselves are the - regression guard. Any breakage in the tagged files will surface when - pytest runs those files directly. Here we verify structural integrity — - that the marker additions did not corrupt the module-level syntax of - the tagged files. - """ - import ast - - files_to_check = [ - # stable - _TESTS_ROOT / "test_codegen.py", - _TESTS_ROOT / "test_taichi_printer.py", - _TESTS_ROOT / "test_taichi_printer_ul.py", - _TESTS_ROOT / "test_emission_phase5.py", - _TESTS_ROOT / "test_emission_verification.py", - _TESTS_ROOT / "test_emit_lame_conversion.py", - # experimental - _TESTS_ROOT / "test_mfem_printer.py", - _TESTS_ROOT / "test_moose_printer.py", - _TESTS_ROOT / "test_cross_backend.py", - ] - - for path in files_to_check: - assert path.exists(), f"Tagged test file missing: {path}" - source = path.read_text() - try: - ast.parse(source) - except SyntaxError as exc: - pytest.fail(f"Syntax error in {path.name} after P5-5 marker additions: {exc}") - - # Verify pytestmark lines are syntactically correct assignments - # (not comments, not inside functions) by checking the raw source. - stable_files = [ - _TESTS_ROOT / "test_codegen.py", - _TESTS_ROOT / "test_taichi_printer.py", - _TESTS_ROOT / "test_taichi_printer_ul.py", - _TESTS_ROOT / "test_emission_phase5.py", - _TESTS_ROOT / "test_emission_verification.py", - _TESTS_ROOT / "test_emit_lame_conversion.py", - ] - for path in stable_files: - src = path.read_text() - assert "pytestmark = pytest.mark.stable_backend" in src, ( - f"{path.name}: pytestmark assignment not found or malformed" - ) - - experimental_files = [ - _TESTS_ROOT / "test_mfem_printer.py", - _TESTS_ROOT / "test_moose_printer.py", - _TESTS_ROOT / "test_cross_backend.py", - ] - for path in experimental_files: - src = path.read_text() - assert "pytestmark = pytest.mark.experimental_backend" in src, ( - f"{path.name}: pytestmark assignment not found or malformed" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_1.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_1.py deleted file mode 100644 index 7bb1757..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_1.py +++ /dev/null @@ -1,353 +0,0 @@ -"""Task P6-1: Add an optional ``algo2code``-generated PCG path behind ``LinearSolverInterface``. - -Phase 6 (R5.1) — integration-tier acceptance: prove that a Preconditioned -Conjugate Gradient solver whose body is generated by the sibling ``algo2code`` -package can be plugged into the Newton driver via the existing -``LinearSolverInterface`` Protocol, without ``algo2code`` taking a runtime -dependency on ``mechdsl``. - -Acceptance criteria: - 1. Integration test proves a generated PCG path can satisfy the solver interface. - 2. All deliverables for P6-1 are in place at the surfaces listed - (``solver/import_adapter.py``, solver integration layer, ``algo2code`` - interface hook). - 3. No regressions on the existing test suite. - -Plan reference: ``dev/plans/recovery_plan_latex_contract.md`` (Phase 6, R5.1, line 317). - -Surfaces under test: - * ``packages/mechdsl-core/src/mechdsl/solver/import_adapter.py`` — new - ``Algo2CodePCGSolver`` (or equivalent) adapter satisfying - ``LinearSolverInterface``. - * Solver integration layer — wiring the generated PCG into the Newton driver. - * ``packages/algo2code/`` interface hook — the symbol/entry-point that emits - the PCG body without importing ``mechdsl``. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import numpy as np -import pytest - -from mechdsl.solver import Algo2CodePCGSolver as _Algo2CodePCGSolverFromPkg -from mechdsl.solver.import_adapter import ( - Algo2CodePCGSolver, - CGSolver, - LinearSolverInterface, - PCGSolver, -) - - -def _spd_tridiagonal(n: int) -> np.ndarray: - """Build the standard 1D Laplacian-style SPD tridiagonal `[2, -1; -1, 2; ...]`.""" - A = 2.0 * np.eye(n, dtype=np.float64) - A -= np.eye(n, k=1, dtype=np.float64) - A -= np.eye(n, k=-1, dtype=np.float64) - return A - - -def _matvec(A: np.ndarray): - def mv(v: np.ndarray) -> np.ndarray: - return A @ v - - return mv - - -class TestP6_1: - """Tests for Task P6-1: optional algo2code-generated PCG behind LinearSolverInterface.""" - - @pytest.mark.integration - def test_generated_pcg_satisfies_solver_interface(self) -> None: - """P6-1-c1: Integration test proves a generated PCG path can satisfy the solver interface. - - Verifies that the algo2code-emitted PCG implementation, once wrapped - in its ``solver/import_adapter.py`` adapter, conforms structurally and - behaviourally to ``LinearSolverInterface``: - - * Duck-typed Protocol conformance via ``hasattr`` and assignability. - * ``adapter.solve(matvec_fn, rhs, x0, tol, max_iter)`` returns - ``(solution, iter_count, final_residual_norm)``. - * On a small SPD test system the returned solution satisfies the - spec tolerance and converges within reasonable iteration count. - * With a Jacobi preconditioner, iteration count is no worse than - unpreconditioned CG (PCG converges no slower than CG for the - identity-vs-Jacobi case on this well-conditioned tridiagonal). - """ - # ----- Construct the adapter (no preconditioner). ----- - adapter = Algo2CodePCGSolver() - - # ----- Duck-typed Protocol conformance. ----- - assert hasattr(adapter, "solve"), "adapter must expose a `solve` method" - # `LinearSolverInterface` is a structural Protocol; assign-then-call - # is the canonical conformance check (see test_solver.py). - as_iface: LinearSolverInterface = adapter - assert callable(as_iface.solve) - - # ----- Build a small SPD test system: 5x5 tridiagonal Laplacian. ----- - n = 5 - A = _spd_tridiagonal(n) - b = np.arange(1.0, n + 1, dtype=np.float64) - x_ref = np.linalg.solve(A, b) - x0 = np.zeros(n, dtype=np.float64) - tol = 1e-10 - max_iter = 100 - - # ----- Solve and unpack the 3-tuple. ----- - result = adapter.solve(_matvec(A), b, x0, tol=tol, max_iter=max_iter) - assert isinstance(result, tuple) and len(result) == 3, ( - "solve() must return a 3-tuple (x, k, r_norm)" - ) - x, k, r_norm = result - assert isinstance(x, np.ndarray) - assert isinstance(k, int) - assert isinstance(r_norm, float) - - # ----- Solution accuracy + residual + iteration count. ----- - np.testing.assert_allclose(x, x_ref, atol=1e-8) - assert float(np.linalg.norm(A @ x - b)) < 1e-8 - assert k > 0, "non-zero RHS should require at least one iteration" - assert r_norm < 1e-8 - - # ----- Class identity: solver/__init__ must re-export the same symbol. ----- - assert _Algo2CodePCGSolverFromPkg is Algo2CodePCGSolver - - # ----- Jacobi-preconditioned PCG must converge no slower than CG. ----- - # Use a 10x10 random SPD system (same recipe as test_solver.py). - rng = np.random.default_rng(99) - n2 = 10 - B = rng.standard_normal((n2, n2)) - A2 = B.T @ B + n2 * np.eye(n2) - b2 = rng.standard_normal(n2).astype(np.float64) - x_ref2 = np.linalg.solve(A2, b2) - diag_inv = 1.0 / np.diag(A2) - - def jacobi(v: np.ndarray) -> np.ndarray: - return diag_inv * v - - adapter_pcg = Algo2CodePCGSolver(precond_fn=jacobi) - x_pcg, k_pcg, r_pcg = adapter_pcg.solve( - _matvec(A2), b2, np.zeros(n2), tol=1e-10, max_iter=200 - ) - np.testing.assert_allclose(x_pcg, x_ref2, atol=1e-8) - assert r_pcg < 1e-10 * float(np.linalg.norm(b2)) - - cg = CGSolver() - _x_cg, k_cg, _r_cg = cg.solve(_matvec(A2), b2, np.zeros(n2), 1e-10, 200) - assert k_pcg <= k_cg, ( - f"Jacobi-PCG ({k_pcg} iters) must converge no slower than CG ({k_cg} iters)" - ) - - # ----- Behavioural equivalence with the existing PCGSolver. ----- - # Same RHS and SPD system → same converged solution to numerical - # precision (both algorithms are mathematically identical PCG). - ref_pcg = PCGSolver(precond_fn=jacobi) - x_ref_pcg, _, _ = ref_pcg.solve(_matvec(A2), b2, np.zeros(n2), 1e-10, 200) - np.testing.assert_allclose(x_pcg, x_ref_pcg, atol=1e-8) - - @pytest.mark.integration - def test_p6_1_deliverables_present_at_listed_surfaces(self) -> None: - """P6-1-c2: deliverables present at the listed surfaces. - - Verifies by reflection that: - - * ``mechdsl.solver.import_adapter`` exposes the generated-PCG adapter - symbol ``Algo2CodePCGSolver`` at module level. - * The Newton-driver / solver integration layer can resolve the - generated PCG as a ``LinearSolverInterface`` implementation - (re-exported through ``mechdsl.solver``). - * The ``algo2code`` package exposes the PCG-emission entry point used - by the adapter, and ``algo2code`` does **not** import ``mechdsl`` - (runtime-free sibling package invariant from ``.claude/CLAUDE.md``). - """ - # ── (1) Adapter symbol present in solver/import_adapter.py. ────── - from mechdsl.solver import import_adapter as _ia - - assert hasattr(_ia, "Algo2CodePCGSolver"), ( - "Algo2CodePCGSolver must be exposed in mechdsl.solver.import_adapter" - ) - assert _ia.Algo2CodePCGSolver is Algo2CodePCGSolver - - # ── (2) Solver integration layer re-exports the symbol. ────────── - from mechdsl import solver as _solver_pkg - - assert hasattr(_solver_pkg, "Algo2CodePCGSolver") - assert "Algo2CodePCGSolver" in _solver_pkg.__all__ - assert _solver_pkg.Algo2CodePCGSolver is Algo2CodePCGSolver - - # The adapter must satisfy the Newton-driver `linear_solver=` slot: - # the slot expects a `LinearSolverInterface`, and the adapter is - # constructible with no arguments. - from mechdsl.solver.newton import newton_solve # noqa: F401 (presence) - - adapter = Algo2CodePCGSolver() - as_iface: LinearSolverInterface = adapter - assert callable(as_iface.solve) - - # ── (3) algo2code interface hook is importable. ────────────────── - import algo2code - from algo2code.library.pcg import ( - PCG_ALGORITHM_LATEX, - get_pcg_algorithm_latex, - ) - - assert hasattr(algo2code, "get_pcg_algorithm_latex") - assert algo2code.get_pcg_algorithm_latex is get_pcg_algorithm_latex - assert isinstance(PCG_ALGORITHM_LATEX, str) - - # The hook must return the canonical algorithm verbatim, including - # the leading directive comments and the algorithmic environment. - latex = get_pcg_algorithm_latex() - assert latex.startswith("% algorithm pcg"), ( - "canonical LaTeX must start with the % algorithm directive" - ) - assert "% backend taichi" in latex - assert r"\begin{algorithmic}" in latex - assert r"\end{algorithmic}" in latex - assert r"apply\_M\_inv" in latex, ( - "canonical LaTeX must use the `apply_M_inv` callable name (P6-1 fix)" - ) - - # The adapter must store the canonical source verbatim. - assert adapter.algorithm_source == latex - - # ── (4) algo2code stays runtime-free of mechdsl. ───────────────── - algo2code_src = Path(__file__).resolve().parents[4] / "algo2code" / "src" / "algo2code" - assert algo2code_src.is_dir(), f"expected algo2code source dir at {algo2code_src}" - offenders: list[tuple[Path, int, str]] = [] - pattern = re.compile(r"^\s*(?:import\s+mechdsl|from\s+mechdsl)") - for py_file in algo2code_src.rglob("*.py"): - text = py_file.read_text(encoding="utf-8") - for lineno, line in enumerate(text.splitlines(), start=1): - if pattern.match(line): - offenders.append((py_file, lineno, line.rstrip())) - assert not offenders, ( - "algo2code must not import mechdsl (runtime-free sibling " - f"invariant). Offending lines: {offenders}" - ) - - -class TestAlgo2CodePCGSolverFailurePaths: - """Failure-route coverage for ``Algo2CodePCGSolver`` per the canonical LaTeX. - - These tests pin down the four non-happy-path branches that the canonical - PCG algpseudocode specifies: - - * Zero-RHS short-circuit (``\\If{$r_0 = 0$} \\Return $x, 0, 0$ \\EndIf``). - * Identity-preconditioner fallback when ``precond_fn=None`` (the - ``apply_M_inv = lambda v: v.copy()`` adapter-init binding). - * Breakdown guard (``\\If{$|pq| < 10^{-300}$} \\State \\textbf{break}``) - → returns the trailing ``(x, max_iter, ||r||)`` tuple after a - ``RuntimeWarning``. - * Max-iter exhaustion → falls through the for-loop and returns the - trailing tuple unchanged. - """ - - def test_zero_rhs_short_circuit(self) -> None: - """Per LaTeX: ``\\If{$r_0 = 0$} \\Return $x, 0, 0$``.""" - n = 4 - A = np.eye(n, dtype=np.float64) - b = np.zeros(n, dtype=np.float64) - x0 = np.zeros(n, dtype=np.float64) - - solver = Algo2CodePCGSolver() - x, k, r = solver.solve(_matvec(A), b, x0, tol=1e-12, max_iter=100) - - np.testing.assert_allclose(x, np.zeros(n), atol=1e-15) - assert k == 0 - assert r == 0.0 - - def test_precond_none_uses_identity(self) -> None: - """``precond_fn=None`` ⇒ ``apply_M_inv = lambda v: v.copy()`` at init. - - With identity preconditioning, results match the unpreconditioned - ``CGSolver`` on the same SPD system. - """ - n = 5 - A = _spd_tridiagonal(n) - b = np.arange(1.0, n + 1, dtype=np.float64) - x0 = np.zeros(n, dtype=np.float64) - - solver = Algo2CodePCGSolver(precond_fn=None) - x, _, _ = solver.solve(_matvec(A), b, x0, 1e-10, 100) - - cg = CGSolver() - x_cg, _, _ = cg.solve(_matvec(A), b, x0, 1e-10, 100) - - np.testing.assert_allclose(x, x_cg, atol=1e-10) - - def test_breakdown_guard_emits_runtime_warning(self) -> None: - """Non-SPD matvec triggers the ``|pq| < 1e-300`` guard. - - We construct a degenerate matvec where ``A @ p == 0`` for any - non-trivial ``p`` (the zero matrix), which forces ``pq = 0`` on the - first iteration. The adapter must emit a ``RuntimeWarning`` and - return the trailing 3-tuple from the LaTeX's final ``\\Return``. - """ - n = 3 - - def zero_matvec(v: np.ndarray) -> np.ndarray: - return np.zeros_like(v) - - b = np.array([1.0, 2.0, 3.0], dtype=np.float64) - x0 = np.zeros(n, dtype=np.float64) - - solver = Algo2CodePCGSolver() - with pytest.warns(RuntimeWarning, match="breakdown"): - x, k, r = solver.solve(zero_matvec, b, x0, tol=1e-10, max_iter=50) - - # After breakdown the loop returns the trailing maxiter return per - # the canonical LaTeX. `x` is unchanged (we never updated it). - assert k == 50 - np.testing.assert_allclose(x, x0) - # `r` reflects ||rhs - A x|| = ||b|| since A x = 0 and x = x0 = 0. - assert r == pytest.approx(float(np.linalg.norm(b))) - - def test_max_iter_exhausted_returns_trailing_tuple(self) -> None: - """If convergence is not reached, the for-loop completes and the - adapter returns ``(x, maxiter, ||r||_2)`` per the trailing - ``\\Return`` line. - """ - n = 5 - A = _spd_tridiagonal(n) - b = np.arange(1.0, n + 1, dtype=np.float64) - x0 = np.zeros(n, dtype=np.float64) - - solver = Algo2CodePCGSolver() - # Tight tolerance + 1 iteration → cannot converge. - x, k, r = solver.solve(_matvec(A), b, x0, tol=1e-16, max_iter=1) - assert k == 1 - # Residual is positive and finite — the trailing return reports it. - assert r > 0.0 - assert np.isfinite(r) - # The reported residual matches the actual ||A x - b||. - actual = float(np.linalg.norm(A @ x - b)) - np.testing.assert_allclose(r, actual, rtol=1e-10) - - def test_callable_preconditioner_is_invoked_each_iteration(self) -> None: - """Verify the ``apply_M_inv(r)`` callable is actually called. - - We wrap the Jacobi preconditioner in a counter and assert it fires - at least once per iteration of a non-trivial solve (twice on the - first iteration: once for ``z = apply_M_inv(r)`` before the loop, - once for ``z = apply_M_inv(r)`` inside the loop after ``r`` is - updated). - """ - n = 5 - A = _spd_tridiagonal(n) - b = np.arange(1.0, n + 1, dtype=np.float64) - x0 = np.zeros(n, dtype=np.float64) - diag_inv = 1.0 / np.diag(A) - calls = {"n": 0} - - def jacobi(v: np.ndarray) -> np.ndarray: - calls["n"] += 1 - return diag_inv * v - - solver = Algo2CodePCGSolver(precond_fn=jacobi) - _x, k, _r = solver.solve(_matvec(A), b, x0, tol=1e-10, max_iter=100) - # Pre-loop application + at least k-1 in-loop applications. - # (Convergence return on iteration k skips the final precond call.) - assert calls["n"] >= max(1, k) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_2.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_2.py deleted file mode 100644 index 3fe893c..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_2.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Task P6-2: Keep the current imported solver path as the default fallback -until generated PCG is stable. - -Phase 6 (R5.2) — unit-tier acceptance: verify that the imported (pre-existing) -linear solver remains the default fallback used by the Newton driver until the -algo2code-generated PCG kernel has been validated. Selecting the generated PCG -must be an explicit opt-in until P6 is closed. - -Plan reference: dev/plans/recovery_plan_latex_contract.md (Phase 6, R5.2, -line 318). - -Acceptance criteria: - 1. Solver regression tests pass with both fallback (imported) and generated - (algo2code PCG) modes. - 2. All deliverables for P6-2 are in place at the listed surfaces: - - packages/mechdsl-core/src/mechdsl/solver/import_adapter.py - - solver integration layer (Newton driver wiring) - - algo2code interface hook (generated-PCG entry point) - 3. No regressions on the existing test suite. - -LinearSolverInterface contract: - packages/mechdsl-core/src/mechdsl/solver/import_adapter.py:26-57 -""" - -from __future__ import annotations - -import ast -import importlib -import inspect -import re -from pathlib import Path - -import numpy as np -import pytest - -import mechdsl.solver as solver_pkg -from mechdsl.solver import integration as solver_integration -from mechdsl.solver.import_adapter import ( - Algo2CodePCGSolver, - LinearSolverInterface, - ScipyCGSolver, - build_solver, - get_default_solver, -) -from mechdsl.solver.integration import select_linear_solver - -# Root paths for surface-presence checks. -_TESTS_ROOT = Path(__file__).parent.parent.parent # packages/mechdsl-core/tests/ -_PACKAGE_ROOT = _TESTS_ROOT.parent # packages/mechdsl-core/ -_SRC_ROOT = _PACKAGE_ROOT / "src" / "mechdsl" -_REPO_ROOT = _PACKAGE_ROOT.parent.parent # repo root -_ALGO2CODE_SRC = _REPO_ROOT / "packages" / "algo2code" / "src" - - -def _laplacian_1d(n: int) -> np.ndarray: - """Return the SPD (n x n) tridiagonal Laplacian — standard CG test bed.""" - A = np.zeros((n, n), dtype=np.float64) - for i in range(n): - A[i, i] = 2.0 - if i > 0: - A[i, i - 1] = -1.0 - A[i - 1, i] = -1.0 - return A - - -class TestP6_2: - """Tests for Task P6-2: Imported solver as default fallback until generated PCG is stable.""" - - def test_solver_regression_passes_with_both_modes(self) -> None: - """P6-2-c1: Solver regression tests pass with both fallback and generated modes. - - Criterion: ``Solver regression tests pass with both fallback and - generated modes.`` - - Verifies: - - The solver factory exposes two selectable modes: ``"fallback"`` - (imported solver, the default) and ``"generated"`` (algo2code PCG). - - With no explicit selection, ``get_default_solver()`` returns the - imported/fallback solver — confirming the fallback remains the - default until generated PCG is stabilised. - - Running the Newton-driver solver regression suite against the - fallback mode passes (residual norm drops below the configured - tolerance for the canonical small linear system). - - Running the same regression suite against the generated mode also - passes (same residual tolerance), demonstrating both code paths - satisfy the LinearSolverInterface contract. - - Passing condition: both modes solve the regression linear system to the - spec tolerance, and the default mode is the fallback (imported) solver. - """ - # --- 1. Default factory is the fallback (imported) solver. -------- - default = get_default_solver() - assert isinstance(default, ScipyCGSolver), ( - "get_default_solver() must return ScipyCGSolver — the imported " - "fallback path is the default until P6-3 stabilises generated PCG." - ) - - # --- 2. build_solver dispatch table. ------------------------------ - assert isinstance(build_solver(), ScipyCGSolver), ( - "build_solver() with no args must default to mode='fallback'." - ) - assert isinstance(build_solver("fallback"), ScipyCGSolver) - assert isinstance(build_solver("generated"), Algo2CodePCGSolver) - - with pytest.raises(ValueError, match="nonsense"): - build_solver("nonsense") # type: ignore[arg-type] - - # --- 3. select_linear_solver mirrors build_solver. ---------------- - assert isinstance(select_linear_solver(), ScipyCGSolver) - assert isinstance(select_linear_solver("fallback"), ScipyCGSolver) - assert isinstance(select_linear_solver("generated"), Algo2CodePCGSolver) - - with pytest.raises(ValueError, match="bogus"): - select_linear_solver("bogus") # type: ignore[arg-type] - - # --- 4. Both modes solve a small SPD system to spec tolerance. ---- - n = 5 - A = _laplacian_1d(n) - b = np.array([1.0, 2.0, 3.0, 4.0, 5.0], dtype=np.float64) - - def matvec(v: np.ndarray) -> np.ndarray: - return A @ v - - x0 = np.zeros(n, dtype=np.float64) - tol = 1e-12 - max_iter = 100 - - results: dict[str, tuple[np.ndarray, int, float]] = {} - for mode, solver in ( - ("fallback", build_solver("fallback")), - ("generated", build_solver("generated")), - ): - x, k, r = solver.solve(matvec, b, x0, tol, max_iter) - results[mode] = (x, k, r) - assert k > 0, f"{mode}: iteration count must be positive (got {k})" - residual_inf = float(np.linalg.norm(A @ x - b)) - assert residual_inf < 1e-8, f"{mode}: ||A x - b|| = {residual_inf:.3e} exceeds 1e-8" - - # --- 5. Both code paths agree on the solution. -------------------- - x_fb, _, _ = results["fallback"] - x_gen, _, _ = results["generated"] - max_abs_diff = float(np.max(np.abs(x_fb - x_gen))) - assert max_abs_diff < 1e-8, ( - f"Fallback / generated solutions diverge by {max_abs_diff:.3e} (must be < 1e-8)" - ) - - # --- 6. Newton's `linear_solver=None` default still resolves to - # ScipyCGSolver. We inspect the function source via AST so we - # do not need to assemble a real Newton problem. - from mechdsl.solver import newton as newton_module - - newton_src = inspect.getsource(newton_module.newton_solve) - newton_tree = ast.parse(newton_src) - # Locate the `if linear_solver is None:` branch. - found_branch = False - for node in ast.walk(newton_tree): - if not isinstance(node, ast.If): - continue - test = node.test - if ( - isinstance(test, ast.Compare) - and isinstance(test.left, ast.Name) - and test.left.id == "linear_solver" - and len(test.ops) == 1 - and isinstance(test.ops[0], ast.Is) - and len(test.comparators) == 1 - and isinstance(test.comparators[0], ast.Constant) - and test.comparators[0].value is None - ): - # Body must assign `linear_solver = ScipyCGSolver()`. - names_called = [ - n.func.id - for n in ast.walk(node) - if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) - ] - assert "ScipyCGSolver" in names_called, ( - "Newton's linear_solver=None branch must construct " - "ScipyCGSolver(); found calls: " + repr(names_called) - ) - found_branch = True - break - assert found_branch, ( - "Newton's `if linear_solver is None:` default branch is missing — " - "P6-2 forbids changing the default fallback resolution." - ) - - def test_deliverables_present_at_surfaces(self) -> None: - """P6-2-c2: deliverables present at the listed surfaces. - - Criterion: ``All deliverables for P6-2 are in place at the surfaces - listed (same as P6-1: solver/import_adapter.py, solver integration - layer, algo2code interface hook).`` - - Verifies the three surfaces named in the plan exist and expose the - expected hooks: - 1. ``packages/mechdsl-core/src/mechdsl/solver/import_adapter.py`` - defines ``LinearSolverInterface`` and exposes a fallback factory - that returns the imported solver as the default. - 2. The solver integration layer (Newton driver wiring) calls into a - single mode-selector entry point so callers can request - ``"fallback"`` or ``"generated"`` without reaching past the - interface. - 3. The algo2code interface hook (generated-PCG entry point) is - present and discoverable from the integration layer; until the - generated path is marked stable, the integration layer must not - dispatch to it by default. - - Passing condition: each surface file exists, the - ``LinearSolverInterface`` contract is unchanged, the default selector - returns the imported/fallback solver, and the generated-PCG entry - point is reachable only via explicit opt-in. - """ - # --- 1. import_adapter.py exposes the expected names. ------------- - import_adapter = importlib.import_module("mechdsl.solver.import_adapter") - for name in ( - "LinearSolverInterface", - "Algo2CodePCGSolver", - "get_default_solver", - "build_solver", - ): - assert hasattr(import_adapter, name), f"import_adapter must expose {name!r}" - assert callable(import_adapter.get_default_solver) - assert callable(import_adapter.build_solver) - - # --- 2. integration.py exposes select_linear_solver. -------------- - assert hasattr(solver_integration, "select_linear_solver") - assert callable(solver_integration.select_linear_solver) - - # --- 3. mechdsl.solver.__all__ contains every required surface. --- - required = { - "LinearSolverInterface", - "CGSolver", - "PCGSolver", - "ScipyCGSolver", - "Algo2CodePCGSolver", - "get_default_solver", - "build_solver", - "select_linear_solver", - } - missing = required - set(solver_pkg.__all__) - assert not missing, f"mechdsl.solver.__all__ is missing required surfaces: {missing}" - - # --- 4. algo2code interface hook is still importable and unchanged. - algo_pcg = importlib.import_module("algo2code.library.pcg") - assert hasattr(algo_pcg, "PCG_ALGORITHM_LATEX") - assert hasattr(algo_pcg, "get_pcg_algorithm_latex") - assert callable(algo_pcg.get_pcg_algorithm_latex) - # Re-running through the function returns the same canonical text. - assert algo_pcg.get_pcg_algorithm_latex() == algo_pcg.PCG_ALGORITHM_LATEX - - # --- 5. algo2code stays runtime-free of mechdsl. ------------------ - pattern = re.compile(r"^\s*(?:import\s+mechdsl|from\s+mechdsl)", re.MULTILINE) - offenders: list[Path] = [] - for py_file in _ALGO2CODE_SRC.rglob("*.py"): - text = py_file.read_text(encoding="utf-8") - if pattern.search(text): - offenders.append(py_file) - assert not offenders, ( - "algo2code package must not import mechdsl at runtime; " - f"offenders: {[str(p) for p in offenders]}" - ) - - # --- 6. Default selector returns the imported (fallback) solver, - # NOT the generated one. - default = select_linear_solver() - assert isinstance(default, ScipyCGSolver) - assert not isinstance(default, Algo2CodePCGSolver) - - # And the LinearSolverInterface protocol still acts on the result. - # (Structural Protocol — `solve` callable with the right signature - # is sufficient.) - assert callable(getattr(default, "solve", None)), ( - "Default solver must satisfy LinearSolverInterface (have .solve)." - ) - # Reference the protocol so the import is exercised. - _ = LinearSolverInterface diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_3.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_3.py deleted file mode 100644 index ea37080..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_3.py +++ /dev/null @@ -1,362 +0,0 @@ -"""Task P6-3: Add a single stable integration test for `algo2code` -> PCG -> Newton plumbing. - -Phase 6 (R5.3) — integration-tier acceptance: prove that a PCG path generated by -the `algo2code` pipeline can be plugged into `mechdsl.solver.newton.newton_solve` -through `LinearSolverInterface` (added in P6-1) while the imported scipy CG path -remains the default fallback (preserved in P6-2). This task establishes the -single, stable integration point between the two monorepo packages — it does -not attempt broader algo2code substitution (radial-return etc., deferred per -P6-4). - -Tier: integration - -Acceptance criteria: - 1. P6-3-c1: Test passes without requiring broader algo2code substitution. - The generated PCG is the *only* algo2code-derived component exercised; - residual / tangent assembly use the existing handwritten reference path. - 2. P6-3-c2: All deliverables for P6-3 are in place at the listed surfaces - (targeted integration tests under - `packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/`). - 3. No regressions on the existing test suite (the full plumbing is exercised - end-to-end via `newton_solve` with the generated PCG; the imported scipy - CG default still works in the rest of the suite). - -Verification chain (executable spec for the eventual implementation): - ProblemIR (small linear-elastic Hex8 patch) - -> ElementIR (lowering) - -> handwritten reference residual + tangent matvec - -> `algo2code` parses the PCG algpseudocode source - -> `algo2code` taichi_codegen emits a `solve(matvec, b, x0, tol, maxit)` - function satisfying `mechdsl.solver.import_adapter.LinearSolverInterface` - -> `newton_solve(..., linear_solver=)` - -> converged Newton solution agreeing with the scipy-CG fallback - within the project tolerance (07-CONVENTIONS §6: max diff < 1e-10). - -Implementation note (P6-1 outcome): the canonical PCG LaTeX in -:mod:`algo2code.library.pcg` cannot currently be consumed by -``algo2code.algo_parser`` / ``taichi_codegen.generate`` (the multi-letter -scratch identifier ``pq`` parses as ``p * q``). P6-1 therefore ships -:class:`mechdsl.solver.import_adapter.Algo2CodePCGSolver` — a verbatim, -line-by-line Python translation of ``PCG_ALGORITHM_LATEX`` — as the -algo2code-derived component. P6-3 exercises that adapter directly; calling -the generic algo2code parser/codegen on this LaTeX would fail. The two are -kept in sync by ``test_p6_1.py``. -""" - -from __future__ import annotations - -import ast -import inspect -from pathlib import Path - -import numpy as np -import pytest - -from algo2code.library.pcg import PCG_ALGORITHM_LATEX, get_pcg_algorithm_latex -from mechdsl.solver.import_adapter import ( - Algo2CodePCGSolver, - LinearSolverInterface, - ScipyCGSolver, -) -from mechdsl.solver.integration import select_linear_solver -from mechdsl.solver.newton import NewtonConfig, NewtonResult, newton_solve - -# Reference assembly callbacks — the only non-algo2code-derived component -# exercised below. Per P6-3 c1: only the PCG seam is the algo2code component. -from tests.ref.ref_hex8_elastic import ( - assemble_internal_force as ref_assemble_f_int, -) -from tests.ref.ref_hex8_elastic import ( - element_tangent_matvec as ref_elem_tangent_matvec, -) -from tests.ref.ref_hex8_elastic import ( - generate_hex8_mesh, -) - -# --------------------------------------------------------------------------- -# Fixture helpers -# --------------------------------------------------------------------------- - - -def _raw_global_matvec( - u: np.ndarray, - v: np.ndarray, - coords: np.ndarray, - conn: np.ndarray, - lam: float, - mu: float, -) -> np.ndarray: - """Global tangent matvec WITHOUT BC enforcement (newton_solve wraps BCs).""" - n_nodes = coords.shape[0] - Kv = np.zeros((n_nodes, 3), dtype=np.float64) - for e in range(conn.shape[0]): - nodes = conn[e] - Kv_e = ref_elem_tangent_matvec(u[nodes], coords[nodes], v[nodes], lam, mu) - for a in range(8): - Kv[nodes[a]] += Kv_e[a] - return Kv - - -def _build_small_elastic_patch() -> dict: - """Small linear-elastic Hex8 patch — single 1x1x1 element under tiny extension. - - Why this fixture? - * Single Hex8 element keeps the test under 1s (no @slow marker needed). - * SVK at small strain is effectively linear elasticity, so Newton - converges in 1-2 iterations — matching the stub's "<=2 iterations" - wording most precisely. - * Reference assembly uses the handwritten ``ref_hex8_elastic`` kernels - (the ``algo2code`` PCG is the *only* generated component). - - BCs: - * Left face (x=0) fully clamped (3 DOFs per node x 4 nodes = 12 fixed). - - Loading: - * Tiny tensile force on each right-face node (x=Lx). The total load is - small enough that the geometric / nonlinear contribution to SVK is - ~1e-9 of the linear part, so Newton lands in 1 iteration. - """ - # Single 1x1x1 Hex8 element - coords, conn = generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - n_nodes = coords.shape[0] - - # Material: E=1000, nu=0.3 -> Lame parameters - E_mod, nu = 1000.0, 0.3 - lam = E_mod * nu / ((1 + nu) * (1 - 2 * nu)) - mu = E_mod / (2 * (1 + nu)) - - # BC: clamp left face (x=0) - bc_mask = np.zeros((n_nodes, 3), dtype=bool) - left_nodes = np.where(np.abs(coords[:, 0]) < 1e-12)[0] - bc_mask[left_nodes, :] = True - - # Tensile load on right face: tiny so SVK ~= linear elasticity. - f_ext = np.zeros((n_nodes, 3), dtype=np.float64) - right_nodes = np.where(np.abs(coords[:, 0] - 1.0) < 1e-12)[0] - for nd in right_nodes: - f_ext[nd, 0] = 1e-3 # tiny tensile force per node - - return { - "coords": coords, - "conn": conn, - "lam": lam, - "mu": mu, - "bc_mask": bc_mask, - "f_ext": f_ext, - "n_nodes": n_nodes, - } - - -def _newton_run_with( - s: dict, - linear_solver: LinearSolverInterface, -) -> tuple[NewtonResult, np.ndarray]: - """Run ``newton_solve`` on the elastic patch with the supplied linear solver. - - Returns (result, final_displacement). The displacement array is built - fresh per call so the two sweeps cannot cross-contaminate. - """ - u = np.zeros((s["n_nodes"], 3), dtype=np.float64) - - def assemble_residual(u_: np.ndarray) -> np.ndarray: - f_int = ref_assemble_f_int(u_, s["coords"], s["conn"], s["lam"], s["mu"]) - return s["f_ext"] - f_int - - def tangent_mv(u_: np.ndarray, v: np.ndarray) -> np.ndarray: - return _raw_global_matvec(u_, v, s["coords"], s["conn"], s["lam"], s["mu"]) - - result = newton_solve( - assemble_residual=assemble_residual, - tangent_matvec=tangent_mv, - u=u, - bc_mask=s["bc_mask"], - linear_solver=linear_solver, - config=NewtonConfig(), - ) - return result, u - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestP6_3: - """Tests for Task P6-3: stable algo2code -> PCG -> Newton integration test.""" - - @pytest.mark.integration - def test_algo2code_generated_pcg_drives_newton_to_convergence(self) -> None: - """P6-3-c1: Test passes without requiring broader algo2code substitution. - - Plumbing exercised: - ProblemIR -> ElementIR -> reference residual + tangent matvec - -> algo2code-generated PCG (the *only* algo2code-derived component) - -> `newton_solve(..., linear_solver=)` - -> converged displacement field. - - Verification: - - The generated PCG implements `LinearSolverInterface.solve(matvec, b, - x0, tol, maxit) -> (x, n_iters, residual)` exactly as required by - `mechdsl.solver.newton.newton_solve`. - - On a small linear-elastic Hex8 patch, Newton converges in <= 2 - iterations (linear problem) to the same displacement field as the - scipy-CG fallback, within max diff 1e-10 (07-CONVENTIONS §6). - - No algo2code-generated residual / tangent / radial-return code is - invoked — only the PCG seam is traversed (P6-4 explicitly defers - broader substitution). - - Acceptance criterion text (from plan, Phase 6, R5.3, line 319): - "Test passes without requiring broader algo2code substitution." - """ - s = _build_small_elastic_patch() - - # --- 1. Type-level check: Algo2CodePCGSolver satisfies the protocol. - # Structural Protocol — assignment-based duck check, same idiom used - # by P6-1 / P6-2 tests. - gen_solver: LinearSolverInterface = Algo2CodePCGSolver() - assert callable(getattr(gen_solver, "solve", None)) - - # --- 2. Run Newton with the generated PCG path. ------------------- - result_gen, u_gen = _newton_run_with(s, Algo2CodePCGSolver()) - assert result_gen.converged, ( - f"Newton did not converge with generated PCG: " - f"iters={result_gen.n_iterations}, " - f"final ||R||={result_gen.residual_history[-1]:.3e}" - ) - # Linear problem: should converge within a couple of iterations. - assert result_gen.n_iterations <= 5, ( - f"Newton with generated PCG took {result_gen.n_iterations} iters " - "on a near-linear problem (expected <= 5)." - ) - - # --- 3. Run Newton with the imported scipy fallback. -------------- - result_ref, u_ref = _newton_run_with(s, ScipyCGSolver()) - assert result_ref.converged, ( - f"Newton did not converge with ScipyCGSolver fallback: " - f"iters={result_ref.n_iterations}, " - f"final ||R||={result_ref.residual_history[-1]:.3e}" - ) - - # --- 4. Generated and fallback paths agree on the displacement. --- - # 07-CONVENTIONS §6 documents 1e-10 as the generated-vs-reference - # tolerance. Both paths solve the same linear system with relative - # CG tolerance 1e-10 (NewtonConfig default), so this should hold. - max_abs_diff = float(np.max(np.abs(u_gen - u_ref))) - assert max_abs_diff < 1e-10, ( - f"Generated PCG and ScipyCG Newton solutions diverge: " - f"max |u_gen - u_ref| = {max_abs_diff:.3e} (must be < 1e-10)." - ) - - # --- 5. Final residual must be below the relative Newton tolerance. - cfg = NewtonConfig() - # Both runs share the same first-iteration residual norm because - # they start from u=0 with identical assembly callbacks. - assert result_gen.residual_history[-1] <= cfg.tol * result_gen.residual_history[0], ( - "Generated PCG run did not satisfy ||R||_final <= tol * ||R_0||." - ) - - # --- 6. The integration-layer hook resolves to the generated solver - # when mode='generated' is selected explicitly. Re-run Newton - # through that path to prove `select_linear_solver` is a real - # seam, not a stub re-export. - gen_via_integration = select_linear_solver("generated") - assert isinstance(gen_via_integration, Algo2CodePCGSolver) - result_via_integration, u_via_integration = _newton_run_with(s, gen_via_integration) - assert result_via_integration.converged - assert float(np.max(np.abs(u_via_integration - u_gen))) < 1e-12, ( - "select_linear_solver('generated') Newton run disagrees with direct adapter." - ) - - # --- 7. Sanity: the canonical algpseudocode source is reachable - # through the algo2code interface hook (binds `mechdsl-core` - # to the algo2code package the way P6-1 specified). - assert get_pcg_algorithm_latex() == PCG_ALGORITHM_LATEX - assert "algorithm pcg" in PCG_ALGORITHM_LATEX - # The adapter holds the same canonical text it consumed at construction. - assert Algo2CodePCGSolver().algorithm_source == PCG_ALGORITHM_LATEX - - @pytest.mark.integration - def test_deliverables_present_at_surfaces(self) -> None: - """P6-3-c2: All deliverables for P6-3 are in place at the listed surfaces. - - Plumbing exercised: filesystem-level structural check that the - targeted integration test lives at the surface named in the plan - (`packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/`), - and that it imports the seam introduced in P6-1 - (`mechdsl.solver.import_adapter.LinearSolverInterface`) plus the - algo2code generation entry point. - - Verification chain: - - This file exists at the canonical plan-test surface. - - The real implementation imports `LinearSolverInterface` from - `mechdsl.solver.import_adapter` and the algo2code interface - hook (`PCG_ALGORITHM_LATEX`). - - `newton_solve` is invoked with the generated PCG adapter (proving - the seam is a real integration point, not a stub re-export). - - Acceptance criterion text (from plan, Phase 6, R5.3, line 319, - deliverables column "targeted integration tests"): - "All deliverables for P6-3 are in place at the surfaces listed." - """ - # --- 1. This file lives at the canonical plan-test surface. ------ - this_file = Path(__file__).resolve() - rel = this_file.relative_to(this_file.parents[5]) - # Repo-relative POSIX form so the assertion is portable across OS. - rel_posix = rel.as_posix() - assert rel_posix == ( - "packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_3.py" - ), f"Test file is at the wrong surface: {rel_posix}" - - # --- 2. Required surfaces are reachable + callable. -------------- - assert callable(Algo2CodePCGSolver), "Algo2CodePCGSolver must be importable + callable" - assert callable(select_linear_solver), "select_linear_solver must be importable + callable" - assert callable(newton_solve), "newton_solve must be importable + callable" - assert isinstance(PCG_ALGORITHM_LATEX, str) and PCG_ALGORITHM_LATEX, ( - "PCG_ALGORITHM_LATEX must be a non-empty string (canonical algpseudocode)." - ) - - # --- 3. Mode-selector wiring is the P6-2 invariant. -------------- - # 'generated' -> Algo2CodePCGSolver - gen = select_linear_solver("generated") - assert isinstance(gen, Algo2CodePCGSolver) - # 'fallback' -> ScipyCGSolver (the imported, stable path; default). - fb = select_linear_solver("fallback") - assert isinstance(fb, ScipyCGSolver) - # Default (no arg) is still the fallback — P6-2 invariant. - assert isinstance(select_linear_solver(), ScipyCGSolver) - - # --- 4. AST scan: this file actually invokes ``newton_solve`` with - # ``linear_solver=`` pointing at an Algo2CodePCGSolver. This - # guards against future refactors that drop the call site - # and silently turn the integration test into a no-op. - helper_src = inspect.getsource(_newton_run_with) - helper_tree = ast.parse(helper_src) - found_kwarg = False - for node in ast.walk(helper_tree): - if not isinstance(node, ast.Call): - continue - func = node.func - # Match `newton_solve(...)` calls (bare name). - if not (isinstance(func, ast.Name) and func.id == "newton_solve"): - continue - for kw in node.keywords: - if kw.arg == "linear_solver": - found_kwarg = True - break - if found_kwarg: - break - assert found_kwarg, ( - "P6-3 integration test must invoke newton_solve(..., " - "linear_solver=); _newton_run_with helper has lost " - "the kwarg, breaking the seam under test." - ) - - # The c1 test routes the generated solver through the helper, so - # confirm the helper's signature accepts a LinearSolverInterface. - sig = inspect.signature(_newton_run_with) - assert "linear_solver" in sig.parameters - - # --- 5. Type-level check: the generated solver satisfies the - # protocol introduced in P6-1. - gen_typed: LinearSolverInterface = Algo2CodePCGSolver() - assert callable(getattr(gen_typed, "solve", None)) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_4.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_4.py deleted file mode 100644 index fd1afa4..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_4.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Live audit for recovery-plan P6-4: Defer radial-return replacement. - -Task: P6-4 — Defer radial-return replacement until frontend + IR alignment -is settled. -Phase: 6 (Integrate ``algo2code`` at the least risky seam — R5) -Tier: docs (planning docs only) - -Acceptance criteria: - -1. Recovery docs explicitly label radial-return substitution as later-stage - work. -2. All deliverables for P6-4 are in place at the surfaces listed - (planning docs only). -3. No regressions on the existing test suite. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -# P6-4 is a docs-tier task — no mechdsl runtime symbol is checked here. -# The deferral surface is the recovery plan; tests audit its prose. - -_ROOT = Path(__file__).resolve().parents[5] -_RECOVERY_PLAN = _ROOT / "dev" / "plans" / "recovery_plan_latex_contract.md" - -_LATER_STAGE_MARKERS = ("later-stage", "deferred", "post-MVP") -_PREREQUISITE_MARKERS = ("frontend", "IR alignment", "R2", "R3") - - -def _normalize(text: str) -> str: - return text.lower() - - -def _contains_radial_return(text: str) -> bool: - """Match either ``radial-return`` or ``radial return`` (case-insensitive).""" - lowered = _normalize(text) - return "radial-return" in lowered or "radial return" in lowered - - -def _has_later_stage_marker(text: str) -> bool: - lowered = _normalize(text) - return any(marker.lower() in lowered for marker in _LATER_STAGE_MARKERS) - - -def _has_prerequisite_marker(text: str) -> bool: - """Frontend / IR / R2 / R3 / alignment cue (case-insensitive substring, - except R2 / R3 which we match as whole tokens to avoid false positives).""" - lowered = _normalize(text) - if "frontend" in lowered or "ir alignment" in lowered or "alignment" in lowered: - return True - # R2 / R3 as standalone tokens (allow surrounding punctuation / parens). - return bool(re.search(r"\br[23]\b", text, flags=re.IGNORECASE)) - - -def _split_paragraphs(text: str) -> list[str]: - """Split a markdown body into paragraph blocks separated by blank lines.""" - return [block for block in re.split(r"\n\s*\n", text) if block.strip()] - - -class TestP6_4: - """ - Tests for Task P6-4: Defer radial-return replacement until - frontend + IR alignment is settled. - Tier: docs (recovery-plan documentation audit) - """ - - @pytest.mark.regression - def test_recovery_plan_labels_radial_return_as_later_stage(self) -> None: - """ - Verifies: dev/plans/recovery_plan_latex_contract.md explicitly labels - radial-return substitution as later-stage work. - Acceptance criterion: P6-4-c1 (recovery docs explicitly label - radial-return substitution as later-stage work). - Passes when: the recovery plan contains both 'radial-return' and a - later-stage label (e.g. 'later-stage', 'deferred', - 'post-MVP') in the same context. - Expected: a sentence like "radial-return substitution is later-stage - work, deferred until frontend + IR alignment is settled." - """ - assert _RECOVERY_PLAN.exists(), f"Missing recovery plan at {_RECOVERY_PLAN}" - text = _RECOVERY_PLAN.read_text(encoding="utf-8") - - assert _contains_radial_return(text), ( - "Recovery plan must mention 'radial-return' (or 'radial return') " - "to anchor the P6-4 deferral note." - ) - assert _has_later_stage_marker(text), ( - f"Recovery plan must label the radial-return work with one of {_LATER_STAGE_MARKERS}." - ) - - # 'In the same context' check: at least one paragraph block carries - # both the radial-return token and a later-stage marker. - paragraphs = _split_paragraphs(text) - co_located = [ - block - for block in paragraphs - if _contains_radial_return(block) and _has_later_stage_marker(block) - ] - assert co_located, ( - "Recovery plan must place 'radial-return' and a later-stage " - "marker in the same paragraph / callout block." - ) - - @pytest.mark.regression - def test_p6_4_deliverables_present_in_planning_docs(self) -> None: - """ - Verifies: the P6-4 deliverable surface (planning docs only) carries - the deferral note where the recovery plan promises it. - Acceptance criterion: P6-4-c2 (deliverables present at the listed - surfaces — 'planning docs only'). - Passes when: dev/plans/recovery_plan_latex_contract.md contains the - P6-4 row AND a clarifying paragraph or note that - classifies radial-return replacement as later-stage / - deferred work, not just a one-line table entry. - Expected: a section, callout, or paragraph in the recovery plan that - expands on the P6-4 row and pins the radial-return work - to a later phase. - """ - assert _RECOVERY_PLAN.exists(), f"Missing recovery plan at {_RECOVERY_PLAN}" - text = _RECOVERY_PLAN.read_text(encoding="utf-8") - - # The original P6-4 table-row anchor must remain intact for traceability - # with dev/tasks/recovery_plan_latex_contract/json/P6-4.json. - assert "P6-4 | R5.4" in text, ( - "Recovery plan must keep the 'P6-4 | R5.4' table row as the anchor " - "for the deferral note." - ) - - # Locate a paragraph / callout block (multi-line, NOT the table row) - # that mentions radial-return AND a frontend / IR / R2 / R3 cue. - paragraphs = _split_paragraphs(text) - candidates = [ - block - for block in paragraphs - if _contains_radial_return(block) - and _has_prerequisite_marker(block) - and block.count("\n") >= 1 # multi-line: paragraph or blockquote - and not block.lstrip().startswith("| ") # exclude the table row - ] - assert candidates, ( - "Recovery plan must contain a multi-line paragraph or blockquote " - "that expands the P6-4 row and pins radial-return work behind a " - "frontend / IR alignment (R2 / R3) prerequisite." - ) - - # Sanity: at least one such block also flags the work as later-stage, - # so c1's 'in same context' guarantee co-locates with c2's expansion. - expanded = [block for block in candidates if _has_later_stage_marker(block)] - assert expanded, ( - "The expanded P6-4 paragraph must itself carry a later-stage / " - "deferred / post-MVP marker, not just the bare table row." - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_5.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_5.py deleted file mode 100644 index fa4df88..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p6_5.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Task P6-5: Document ``algo2code``'s role in the recovered architecture. - -Phase 6 (R5.5) — docs-tier acceptance: ensure the public architecture -description names both packages of the monorepo and explains the -relationship between them, so the recovered seam between -``mechdsl-core`` and ``algo2code`` cannot silently regress to the -"design-doc only" state called out in the recovery plan. - -Acceptance criteria: - 1. Public architecture description includes both packages and their - relationship. - 2. All deliverables for P6-5 are in place at the surfaces listed - (``README.md``, architecture docs, examples). - 3. No regressions on the existing test suite. - -Plan reference: ``dev/plans/recovery_plan_latex_contract.md`` (Phase 6, -R5.5, line 321). - -Surfaces under test: - * Top-level ``README.md`` — must name both ``mechdsl-core`` and - ``algo2code`` in the public architecture section and describe the - consumer/producer relationship (e.g. ``mechdsl-core`` consumes - ``algo2code``-generated artifacts behind ``LinearSolverInterface``). - * ``dev/design_docs/`` — architecture documentation must reflect the - recovered relationship (not just sibling existence). - * ``dev/examples/`` — at least one example, or the examples README, - points at the ``algo2code`` seam so the relationship is - discoverable from runnable code. -""" - -from __future__ import annotations - -from pathlib import Path - -_ROOT = Path(__file__).resolve().parents[5] - - -def _split_paragraphs(text: str) -> list[str]: - """Split markdown text into paragraph blocks (separated by blank lines).""" - return [block.strip() for block in text.split("\n\n") if block.strip()] - - -def _extract_architecture_section(readme_text: str) -> str: - """Return the body of the top-level ``## Architecture`` section. - - Includes any nested subsections (``###``) until the next top-level - ``## `` heading is encountered. - """ - lines = readme_text.splitlines() - out: list[str] = [] - inside = False - for line in lines: - if line.startswith("## Architecture"): - inside = True - out.append(line) - continue - if inside and line.startswith("## ") and not line.startswith("## Architecture"): - break - if inside: - out.append(line) - return "\n".join(out) - - -class TestP6_5: - """Tests for Task P6-5: document algo2code's role in the recovered architecture. - - Tier: docs (documentation/example audit). - """ - - def test_readme_architecture_section_names_both_packages(self) -> None: - """P6-5-c1: README architecture description names both packages and - their relationship. - - Verifies: top-level ``README.md`` contains a public architecture - section that names both ``mechdsl-core`` and ``algo2code`` and - documents that ``mechdsl-core`` consumes ``algo2code``-generated - artifacts (e.g. the PCG path) behind ``LinearSolverInterface``. - - File under test: ``README.md`` (repo root). - Expected wording (substrings): - * ``mechdsl-core`` — the FEM compiler package is named. - * ``algo2code`` — the sibling package is named. - * ``LinearSolverInterface`` — the seam is identified, not - just the package names. - * a paragraph with both ``consumes`` and ``algo2code`` — - making the consumer/producer relationship explicit. - * ``PCG`` appears somewhere in the architecture section. - - Acceptance criterion: P6-5-c1. - """ - readme = (_ROOT / "README.md").read_text(encoding="utf-8") - - assert "mechdsl-core" in readme, "README must name the mechdsl-core package" - assert "algo2code" in readme, "README must name the algo2code package" - assert "LinearSolverInterface" in readme, ( - "README must name the LinearSolverInterface seam (CamelCase)" - ) - - # Consumer/producer relationship must live in a single paragraph. - paragraphs = _split_paragraphs(readme) - assert any("consumes" in p and "algo2code" in p for p in paragraphs), ( - "README must contain a paragraph that uses both 'consumes' and " - "'algo2code' to describe the consumer/producer relationship" - ) - - arch_section = _extract_architecture_section(readme) - assert arch_section, "README must contain a '## Architecture' section" - assert "PCG" in arch_section, ( - "Architecture section must mention the PCG path landed in Phase 6" - ) - - def test_p6_5_deliverables_present_at_listed_surfaces(self) -> None: - """P6-5-c2: deliverables present at the surfaces listed in the plan. - - Verifies: each surface listed for P6-5 in - ``dev/plans/recovery_plan_latex_contract.md`` has the - documentation deliverable in place. - - Files under test: - * ``README.md`` — public architecture section mentions - ``algo2code`` alongside ``mechdsl-core`` and the - ``LinearSolverInterface`` seam. - * ``dev/design_docs/11-ALGO2CODE.md`` — names the recovered - seam (``PCG``, ``LinearSolverInterface``, ``algo2code``, - ``Algo2CodePCGSolver``) and the canonical-source pointer - ``PCG_ALGORITHM_LATEX``. - * ``dev/examples/`` — a README or example docstring mentions - ``algo2code`` so the seam is reachable from runnable code. - - Acceptance criterion: P6-5-c2. - """ - # Surface 1 — README architecture section names algo2code. - readme = (_ROOT / "README.md").read_text(encoding="utf-8") - arch_section = _extract_architecture_section(readme) - assert arch_section, "README must contain a '## Architecture' section" - assert "algo2code" in arch_section, "README architecture section must name algo2code" - - # Surface 2 — design doc names the full recovered seam. - design_doc = (_ROOT / "dev" / "design_docs" / "11-ALGO2CODE.md").read_text(encoding="utf-8") - for needle in ( - "PCG", - "LinearSolverInterface", - "algo2code", - "Algo2CodePCGSolver", - "PCG_ALGORITHM_LATEX", - ): - assert needle in design_doc, ( - f"dev/design_docs/11-ALGO2CODE.md must mention {needle!r} " - "to document the recovered seam" - ) - - # Surface 3 — examples directory advertises the algo2code seam. - examples_dir = _ROOT / "dev" / "examples" - examples_readme = examples_dir / "README.md" - readme_mentions = examples_readme.is_file() and "algo2code" in examples_readme.read_text( - encoding="utf-8" - ) - example_docstring_mentions = any( - "algo2code" in path.read_text(encoding="utf-8") for path in examples_dir.glob("*.py") - ) - assert readme_mentions or example_docstring_mentions, ( - "dev/examples/ must advertise the algo2code seam either via a " - "README.md or via an example docstring" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_1.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_1.py deleted file mode 100644 index 73bb5ef..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_1.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Task P7-1: Split end-to-end tests into ``from_latex`` and ``from_problem_ir`` families. - -Phase 7 (R6.1) — integration-tier acceptance: the recovery plan requires that -e2e coverage make the LaTeX-input vs. programmatic-IR-input boundary explicit. -Today every test under ``packages/mechdsl-core/tests/`` that exercises the full -pipeline constructs ``ProblemIR`` directly (see ``test_e2e.py:_make_elastic_problem_ir``); -no test starts from a LaTeX string. Until P7-1 lands, CI / pytest selection -cannot meaningfully distinguish "tests that exercise the LaTeX contract" from -"tests that bypass the frontend". - -Tier: integration - -Acceptance criteria: - 1. P7-1-c1: CI/test selection makes the boundary explicit (e.g. via - ``-m from_latex`` / ``-m from_problem_ir``, separate folders, or naming - convention queryable by ``pytest -k``). - 2. P7-1-c2: Deliverables present at the listed surfaces - (``packages/mechdsl-core/tests/**``). - 3. No regressions on the existing test suite. - -Blocked by: P5-1 (Taichi as the only stable backend — the family split is -defined relative to the canonical Taichi compile path). - -Implementation note: - P7-1 introduces two pytest markers — ``from_latex`` and - ``from_problem_ir`` — registered in the root ``pyproject.toml``'s - ``[tool.pytest.ini_options]`` ``markers`` list. The existing top-level - e2e modules (``test_e2e.py``, ``test_e2e_taichi.py``, - ``test_e2e_plastic.py``, ``test_full_pipeline.py``, - ``test_compile_pipeline.py``) all construct ``ProblemIR`` programmatically - and therefore receive ``pytestmark = pytest.mark.from_problem_ir``. - The ``from_latex`` family is intentionally empty at the close of P7-1 — - the first member lands in P7-2 (canonical LaTeX-to-solution acceptance - test). Per recovery-plan scope, only top-level e2e modules carry these - markers; per-task ``plan_tests/`` files are not relabelled. -""" - -from __future__ import annotations - -import re -import subprocess -import sys -from pathlib import Path - -import pytest - -# Repository root: -# packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_1.py -# parents[0]=recovery_plan_latex_contract, [1]=plan_tests, [2]=tests, -# [3]=mechdsl-core, [4]=packages, [5]=repo root. -_REPO_ROOT = Path(__file__).resolve().parents[5] -_PYPROJECT = _REPO_ROOT / "pyproject.toml" -_E2E_TEST_DIR = _REPO_ROOT / "packages" / "mechdsl-core" / "tests" - - -def _read_registered_markers() -> list[str]: - """Parse the ``markers`` list under ``[tool.pytest.ini_options]``. - - The pyproject style is a TOML array of ``": "`` - strings (one per line, comma-separated). We use the stdlib TOML parser - so escaped inner quotes (``'-m \\"not slow\\"'``) round-trip cleanly. - """ - import tomllib - - with _PYPROJECT.open("rb") as fh: - data = tomllib.load(fh) - markers = data.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("markers") - assert isinstance(markers, list), ( - "pyproject.toml [tool.pytest.ini_options].markers must be a list" - ) - return [str(entry) for entry in markers] - - -def _collect_node_ids(marker_expr: str) -> list[str]: - """Run ``pytest --collect-only -q -m `` and return node IDs.""" - proc = subprocess.run( - [ - sys.executable, - "-m", - "pytest", - "--collect-only", - "-q", - "-p", - "no:cacheprovider", - "-m", - marker_expr, - str(_E2E_TEST_DIR), - ], - capture_output=True, - text=True, - cwd=str(_REPO_ROOT), - check=False, - ) - # pytest exits 5 when no tests are collected — that's a valid outcome - # for the ``from_latex`` family today and must not crash this harness. - lines = (proc.stdout + proc.stderr).splitlines() - node_ids: list[str] = [] - for line in lines: - stripped = line.strip() - if not stripped: - continue - # Collected node IDs look like "path/to/test.py::TestClass::test_x". - if "::" in stripped and not stripped.startswith(("=", "-", "<", "!")): - node_ids.append(stripped) - return node_ids - - -class TestP7_1: - """Tests for Task P7-1: e2e family split (``from_latex`` / ``from_problem_ir``).""" - - @pytest.mark.integration - def test_ci_test_selection_exposes_from_latex_family(self) -> None: - """P7-1-c1: CI/test selection makes the boundary explicit. - - Verifies: a discoverable ``from_latex`` family exists — both - ``from_latex`` and ``from_problem_ir`` are registered as pytest - markers in ``pyproject.toml``, and the ``from_problem_ir`` family - is non-empty (at least one e2e module carries the marker today). - The ``from_latex`` family may be empty until P7-2 lands its - canonical LaTeX-to-solution test; the *selector* must still be - registered and queryable. - """ - markers = _read_registered_markers() - names = {entry.split(":", 1)[0].strip() for entry in markers} - - assert "from_latex" in names, ( - "pyproject.toml [tool.pytest.ini_options].markers must register " - "`from_latex` so `pytest -m from_latex` is a queryable selector. " - f"Registered markers: {sorted(names)}" - ) - assert "from_problem_ir" in names, ( - "pyproject.toml [tool.pytest.ini_options].markers must register " - "`from_problem_ir` so `pytest -m from_problem_ir` is a queryable " - f"selector. Registered markers: {sorted(names)}" - ) - - # Every registered marker entry has the form ": ". - for entry in markers: - if entry.split(":", 1)[0].strip() in {"from_latex", "from_problem_ir"}: - assert ":" in entry, f"Marker registration must include a description: {entry!r}" - _, description = entry.split(":", 1) - assert description.strip(), f"Marker `{entry}` must have a non-empty description" - - # The ``from_problem_ir`` family must be non-empty today. - problem_ir_nodes = _collect_node_ids("from_problem_ir") - assert problem_ir_nodes, ( - "`pytest -m from_problem_ir` collected zero tests under " - f"{_E2E_TEST_DIR} — at least one top-level e2e module must " - "carry `pytestmark = pytest.mark.from_problem_ir`." - ) - - @pytest.mark.integration - def test_deliverables_present_at_surfaces(self) -> None: - """P7-1-c2: Deliverables present at the listed surfaces. - - Verifies: at least one e2e file under - ``packages/mechdsl-core/tests/test_e2e*.py`` (or other top-level - ``test_*.py``) carries ``pytest.mark.from_problem_ir`` at module - level, and the marker registration in ``pyproject.toml`` follows - the existing ``": "`` convention. - """ - # (a) At least one top-level e2e file carries - # ``pytestmark = ... from_problem_ir ...`` at module level. - candidates = sorted(_E2E_TEST_DIR.glob("test_e2e*.py")) - assert candidates, ( - f"No `test_e2e*.py` files found under {_E2E_TEST_DIR}; " - "the from_problem_ir family has nowhere to live." - ) - - marker_pattern = re.compile( - r"^pytestmark\s*=.*from_problem_ir", - flags=re.MULTILINE, - ) - carriers = [ - path for path in candidates if marker_pattern.search(path.read_text(encoding="utf-8")) - ] - assert carriers, ( - "At least one `test_e2e*.py` module must declare " - "`pytestmark = pytest.mark.from_problem_ir` (or include it in a " - "list-form pytestmark) at module level. Inspected: " - f"{[p.name for p in candidates]}" - ) - - # (b) Marker registration follows the existing convention. - markers = _read_registered_markers() - for entry in markers: - name, _, description = entry.partition(":") - assert name.strip(), f"Empty marker name in entry {entry!r}" - assert description.strip(), ( - f"Marker entry {entry!r} must have a non-empty description " - "(convention established by `slow`, `e2e`, `stable_backend`)." - ) - - registered_names = {entry.split(":", 1)[0].strip() for entry in markers} - assert {"from_latex", "from_problem_ir"} <= registered_names, ( - "Both family markers must be registered for the split to be " - f"structural. Registered: {sorted(registered_names)}" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_2.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_2.py deleted file mode 100644 index 6315929..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_2.py +++ /dev/null @@ -1,252 +0,0 @@ -"""Task P7-2: Canonical LaTeX-to-solution acceptance test on the MVP-stable path. - -Phase 7 (R6.2) -- integration-tier acceptance: the recovery plan is not -considered restored until at least one acceptance test runs the full -canonical contract -- LaTeX source -> ``compile_latex`` facade -> Taichi -codegen (P5-1) -> mesh + Newton solve -> verified solution. Today no test -starts from a LaTeX string; P2-1's ``compile_latex`` is exercised only at -the API-shape level by ``test_p2_1.py`` and the symbolic-pipeline checks. - -Tier: integration - -Acceptance criteria: - 1. P7-2-c1: Acceptance test passes starting from LaTeX input -- i.e. a - literal LaTeX string is the test's only ProblemIR source, with no - ``build_context`` / ``_make_elastic_problem_ir`` shortcuts. - 2. P7-2-c2: Deliverables present at the listed surfaces (e2e tests, - examples). - 3. No regressions on the existing test suite. - -Blocked by: P2-1 (LaTeX facade), P4-1 (enriched ElementIR), P5-1 (Taichi -as canonical stable backend) -- the four pillars whose acceptance test -this task verifies. -""" - -from __future__ import annotations - -from pathlib import Path - -import numpy as np -import pytest - -from mechdsl import compile_latex -from tests.ref.ref_hex8_elastic import generate_hex8_mesh, solve_elastic - -# Module-level marker: this entire file is the canonical "from_latex" family -# anchor created by P7-1. Every test below must traverse the LaTeX -> facade -# path; do NOT mark with ``from_problem_ir``. -pytestmark = pytest.mark.from_latex - - -# --------------------------------------------------------------------------- -# Canonical LaTeX source -- mirrors dev/examples/run_compile_latex.py so the -# acceptance test and the user-facing example exercise the same contract. -# Material parameters (E, nu) match tests/ref/ref_hex8_elastic.py defaults. -# --------------------------------------------------------------------------- - -# post_recovery_plan P1-6: the Neumann directive now carries a numeric -# 3-vector traction and an explicit ``--surface`` tag. ``compile_latex`` -# parses these (P1-2) and emits an ``init_f_ext_from_neumann_load`` -# kernel (P1-5) that the test below invokes — no manual numeric f_ext -# injection. Traction magnitude (1.0 in +x on x1) was chosen to match -# the previous hand-written ``f_ext[right, 0] = 1.0`` after the kernel's -# uniform-distribution weighting (per-node = traction * face_area / -# n_face_nodes; face_area = 1.0, n_face_nodes = 4 → per-node = 0.25). -CANONICAL_LATEX_SOURCE = r""" -% MechDSL canonical first-run example -- elastic cantilever (SVK Hex8). -% mechanics dim 3 -% mechanics cell hex8 -% mechanics formulation total_lagrangian -% mechanics material svk --E 200e3 --nu 0.3 -% mechanics boundary fix --type dirichlet --value 0 --components 0 1 2 -% mechanics boundary load --type neumann --traction "1 0 0" --surface x1 -""" - -E_YOUNG = 200.0e3 -NU = 0.3 -LAM = E_YOUNG * NU / ((1 + NU) * (1 - 2 * NU)) -MU = E_YOUNG / (2 * (1 + NU)) - - -# --------------------------------------------------------------------------- -# Helpers (kept local to this file -- the family split rule forbids reusing -# ``_make_elastic_problem_ir`` from test_e2e_taichi.py because that helper -# constructs a ProblemIR programmatically). -# --------------------------------------------------------------------------- - - -# post_recovery_plan Phase 6 (P6-1, P6-2): _import_generated_module -# now lives in the shared tests/_e2e_helpers module. -# post_recovery_plan Phase 7 (P7-3): module name is now derived from -# `uuid.uuid4()` per test invocation — the previously-hardcoded -# `"gen_p7_2"` literal made every invocation share a single importlib -# cache slot, which can mask ordering dependencies when the suite -# runs alongside other emitter tests. - -import uuid as _uuid_p7_3 # noqa: E402 - -from tests._e2e_helpers import _import_generated_module # noqa: E402 - -# =========================================================================== -# P7-2-c1: Canonical LaTeX -> compile_latex -> Taichi -> Newton -> reference -# =========================================================================== - - -class TestP7_2: - """Tests for Task P7-2: canonical LaTeX-to-solution acceptance test.""" - - @pytest.mark.integration - @pytest.mark.e2e - @pytest.mark.slow - def test_acceptance_passes_starting_from_latex_input(self, tmp_path: Path) -> None: - """P7-2-c1: Acceptance test passes starting from LaTeX input. - - Verifies the full canonical contract end-to-end: - literal LaTeX string - -> mechdsl.compile_latex(source, profile="mvp") - -> ArtifactBundle (Taichi backend, P5-1 stable surface) - -> generated module imported & executed under Taichi JIT - -> max |u_generated - u_reference| < 1e-10 - (07-CONVENTIONS.md Sec 6 tolerance authority). - - Critically, the LaTeX string is the *sole* ProblemIR source -- no - ``_make_elastic_problem_ir`` / ``build_context`` shortcut is used. - That is the contract Phase 7 R6.2 closes. - """ - # 1. Canonical entry point: LaTeX source -> ArtifactBundle. No - # programmatic ProblemIR construction is allowed in this test. - bundle = compile_latex(CANONICAL_LATEX_SOURCE, profile="mvp") - assert bundle.emitted_source, "compile_latex returned an empty source" - assert bundle.element_ir_summary["element_type"] == "hex8" - assert bundle.element_ir_summary["formulation"] == "total_lagrangian" - # Taichi-stable backend signature -- prove we are NOT on an - # experimental printer path (MFEM/MOOSE produce different markers). - assert "import taichi as ti" in bundle.emitted_source - assert "@ti.kernel" in bundle.emitted_source - # post_recovery_plan P1-5/P1-6: the Neumann directive surfaces as - # an emitted f_ext init kernel; the directive-driven path replaces - # the previous manual numeric injection. - assert bundle.f_ext_kernel is not None, ( - "compile_latex must emit an f_ext kernel for the Neumann directive " - "(post_recovery_plan P1-5)" - ) - assert "init_f_ext_from_neumann_load" in bundle.f_ext_kernel - - # 2. Import the emitted module under Taichi JIT. Splice the - # Neumann f_ext kernel onto the main solver source so the same - # imported module exposes both ``newton_solve`` and the - # ``init_f_ext_from_neumann_load`` kernel the test calls below. - merged_source = bundle.emitted_source + "\n\n" + bundle.f_ext_kernel - # post_recovery_plan Phase 7 (P7-3): unique-per-invocation module - # name avoids importlib cache collisions when the suite runs - # alongside other emitter tests. - gen_module_name = f"gen_p7_2_{_uuid_p7_3.uuid4().hex}" - mod = _import_generated_module(merged_source, tmp_path, name=gen_module_name) - assert hasattr(mod, "compute_internal_force") - assert hasattr(mod, "tangent_matvec") - assert hasattr(mod, "newton_solve") - assert hasattr(mod, "allocate_fields") - - # 3. Set up a 1-element Hex8 unit cube mesh -- minimal canonical - # fixture, converges in a handful of Newton iterations. - coords, conn = generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - n_nodes = coords.shape[0] - n_elem = conn.shape[0] - - mod.allocate_fields(n_nodes, n_elem) - mod.x_ref.from_numpy(coords) - for e in range(n_elem): - for a in range(8): - mod.elem_nodes[e, a] = int(conn[e, a]) - - # 4. BCs/loads driven entirely by the LaTeX directive. The - # Dirichlet directive supplies the ``fix`` BC at x=0; the - # Neumann directive's ``--traction "1 0 0" --surface x1`` - # drives the emitted ``init_f_ext_from_neumann_load`` kernel - # that initialises ``f_ext``. The previous manual numeric - # injection (closes follow-up item 9) is gone. - bc_mask = np.zeros((n_nodes, 3), dtype=bool) - bc_values = np.zeros((n_nodes, 3), dtype=np.float64) - left = np.where(np.abs(coords[:, 0]) < 1e-12)[0] - bc_mask[left, :] = True - bc_dofs = np.where(bc_mask.ravel())[0].astype(np.int64) - - # Surface nodes for the Neumann tag ``x1`` and runtime - # ``f_factor = face_area / n_face_nodes`` per the P1-5 emitter. - right = np.where(np.abs(coords[:, 0] - 1.0) < 1e-12)[0].astype(np.int32) - face_area = 1.0 # unit-cube x1 face is 1.0 x 1.0 - f_factor = face_area / float(len(right)) - # Numeric reference uses the same per-node force the kernel - # produces (traction[0] = 1.0, multiplied by f_factor on x1). - f_ext = np.zeros((n_nodes, 3), dtype=np.float64) - f_ext[right, 0] = 1.0 * f_factor - - # 5. Drive the emitted Newton solver via the public emitted entry - # point newton_solve(lam, mu, bc_dofs=...). The Neumann load - # is initialised by the emitted directive-driven kernel — no - # manual numeric injection on the Taichi field. - mod.init_f_ext_from_neumann_load(right, f_factor) - n_iters = mod.newton_solve(LAM, MU, bc_dofs=bc_dofs) - u_gen = mod.u.to_numpy() - - assert n_iters >= 1, "Newton must take at least one iteration with nonzero load" - assert float(np.max(np.abs(u_gen))) > 1e-10, ( - "Generated solution is trivially zero -- BCs not enforced" - ) - - # 6. Solve the same problem with the handwritten reference. - u_ref, _ = solve_elastic(coords, conn, LAM, MU, bc_mask, bc_values, f_ext) - - # 7. 07-CONVENTIONS Sec 6: generated vs reference displacement < 1e-10. - max_diff = float(np.max(np.abs(u_gen - u_ref))) - assert max_diff < 1e-10, ( - "LaTeX-to-solution path does not match reference within " - f"07-CONVENTIONS Sec 6 tolerance: max |u_gen - u_ref| = " - f"{max_diff:.3e} (>= 1e-10)" - ) - - @pytest.mark.integration - def test_deliverables_present_at_surfaces(self) -> None: - """P7-2-c2: Deliverables present at the listed surfaces. - - Verifies the two surfaces the recovery plan calls out: - * ``packages/mechdsl-core/tests/`` carries at least one test - in the ``from_latex`` family (this file). - * ``dev/examples/`` carries at least one runnable example that - consumes ``compile_latex`` end-to-end (P7-3 ``run_compile_latex.py``). - - Both surfaces consume the public ``compile_latex`` facade. - """ - # ---- Surface 1: tests/ has a from_latex acceptance test ---- - repo_root = Path(__file__).resolve().parents[5] - this_file = Path(__file__).resolve() - # Self-witness: this file imports compile_latex and is marked - # from_latex at module scope. - text = this_file.read_text(encoding="utf-8") - assert "pytestmark = pytest.mark.from_latex" in text, ( - "P7-2 test file must anchor the from_latex marker family at module scope" - ) - assert "from mechdsl import compile_latex" in text, ( - "P7-2 test file must consume the public compile_latex facade" - ) - - # ---- Surface 2: dev/examples/ has a LaTeX-first runnable example ---- - examples_dir = repo_root / "dev" / "examples" - assert examples_dir.is_dir(), f"missing dev/examples/ at {examples_dir}" - - latex_first_example = examples_dir / "run_compile_latex.py" - assert latex_first_example.is_file(), ( - "P7-3 example dev/examples/run_compile_latex.py must be present " - "as the canonical LaTeX-first runnable example" - ) - example_text = latex_first_example.read_text(encoding="utf-8") - assert "from mechdsl import compile_latex" in example_text, ( - "run_compile_latex.py must import the public compile_latex facade" - ) - assert "compile_latex(" in example_text, ( - "run_compile_latex.py must actually call compile_latex" - ) - assert "% mechanics" in example_text, ( - "run_compile_latex.py must embed a literal '% mechanics' LaTeX " - "directive set so the user sees the canonical input shape" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_3.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_3.py deleted file mode 100644 index 46d7dd5..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_3.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Task P7-3: Update examples so the stable story begins from LaTeX input. - -Phase 7 (R6.3) — docs-tier acceptance: the canonical first-run example in -``README.md`` and ``dev/examples/`` must use ``compile_latex(...)`` as the -primary entry point. Programmatic ``build_context()`` / direct ProblemIR -construction is preserved (P2-2 mandate) but demoted to advanced/testing -aid status. - -Tier: docs - -Acceptance criteria: - 1. P7-3-c1: First-run example in docs uses the canonical path - (``compile_latex`` reading a LaTeX source). - 2. P7-3-c2: Deliverables present at the listed surfaces (``examples/``, - ``README.md``). - 3. No regressions on the existing test suite. - -Blocked by: P2-1 (the canonical ``compile_latex`` façade must exist -before examples can call it). -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -# Repo-root anchor: this test lives at -# packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_3.py -REPO_ROOT = Path(__file__).resolve().parents[5] -README_PATH = REPO_ROOT / "README.md" -EXAMPLES_DIR = REPO_ROOT / "dev" / "examples" - - -class TestP7_3: - """Tests for Task P7-3: examples present LaTeX-first canonical story.""" - - @pytest.mark.docs - def test_first_run_example_in_readme_uses_canonical_path(self) -> None: - """P7-3-c1: First-run example in docs uses the canonical path. - - Verifies: the first end-to-end example in ``README.md`` (the one - users hit when scanning top-down) imports ``compile_latex`` / - starts from a LaTeX string, and any programmatic ProblemIR example - appears later under an "advanced" / "testing" heading. - - Passes when: README parsing locates a ``compile_latex`` snippet - before any ``build_context`` / ``ProblemIR(...)`` snippet. - """ - assert README_PATH.is_file(), f"missing README at {README_PATH}" - text = README_PATH.read_text(encoding="utf-8") - - # post_recovery_plan Phase 7 (P7-2): scope ordering check to - # *runnable code blocks* (markdown ```python ... ``` fences), - # not raw text. Bare prose mentions of `compile_latex(` / - # `build_context(` near the top of the README no longer flip - # the assertion. Robust against doc copy edits. - import re as _re_runnable - - _CODE_BLOCK_RE = _re_runnable.compile( - r"```(?:python|py|bash|shell)?\s*\n(.*?)```", _re_runnable.DOTALL - ) - - def _first_in_runnable_blocks(needle: str) -> int: - for match in _CODE_BLOCK_RE.finditer(text): - if needle in match.group(1): - return match.start() - return -1 - - first_compile_latex = _first_in_runnable_blocks("compile_latex(") - first_build_context = _first_in_runnable_blocks("build_context(") - first_problem_ir = _first_in_runnable_blocks("ProblemIR(") - - assert first_compile_latex != -1, ( - "README.md must contain a `compile_latex(` snippet so the " - "canonical LaTeX-first path is documented (P7-3-c1)." - ) - - # The canonical LaTeX example must precede any programmatic - # ``build_context(...)`` / ``ProblemIR(...)`` example. Either the - # programmatic substring is absent, or it appears strictly after - # the canonical one. - if first_build_context != -1: - assert first_compile_latex < first_build_context, ( - "README.md must show `compile_latex(` before " - "`build_context(`; the LaTeX-first path is the stable " - "story (P7-3-c1)." - ) - if first_problem_ir != -1: - assert first_compile_latex < first_problem_ir, ( - "README.md must show `compile_latex(` before " - "`ProblemIR(`; the programmatic API is an advanced / " - "testing aid only (P7-3-c1)." - ) - - @pytest.mark.docs - def test_deliverables_present_at_surfaces(self) -> None: - """P7-3-c2: Deliverables present at the listed surfaces. - - Verifies: at least one runnable script under ``dev/examples/`` (or - ``examples/``) is a LaTeX-input example; README references it; the - Programmatic / build_context path remains documented but secondary. - - Passes when: a LaTeX example file exists, README links to it, and - the README ordering keeps the LaTeX example first. - """ - assert EXAMPLES_DIR.is_dir(), f"missing examples dir at {EXAMPLES_DIR}" - - # Leg (a): at least one .py file under dev/examples/ imports - # ``compile_latex`` AND opens or contains a literal LaTeX source - # (either reads a .tex file or embeds a raw string with the - # canonical ``% mechanics`` directive prefix). - latex_first_scripts: list[Path] = [] - for script in sorted(EXAMPLES_DIR.glob("*.py")): - content = script.read_text(encoding="utf-8") - imports_compile_latex = "from mechdsl import compile_latex" in content or ( - "import mechdsl" in content and "compile_latex" in content - ) - has_literal_latex = "% mechanics" in content or ".tex" in content - if imports_compile_latex and has_literal_latex: - latex_first_scripts.append(script) - - assert latex_first_scripts, ( - "Expected at least one LaTeX-first script under " - f"{EXAMPLES_DIR}: a .py file that imports `compile_latex` " - "and either embeds a `% mechanics` literal or reads a .tex " - "file (P7-3-c2)." - ) - - # Leg (b): README references at least one of those scripts by - # relative path, so users can copy/paste the runnable command. - # - # post_recovery_plan Phase 7 (P7-2): accept three path-prefix - # variants — `dev/examples/`, `./dev/examples/`, - # and any absolute prefix ending in `/dev/examples/`. - # Robust against doc-style differences across READMEs. - readme_text = README_PATH.read_text(encoding="utf-8") - relative_names = {script.name for script in latex_first_scripts} - accepted_prefixes = ("dev/examples/", "./dev/examples/", "/dev/examples/") - # The third entry above also covers any absolute prefix (e.g. - # `/Users/.../dev/examples/...`) because `is_absolute()` paths - # always contain the substring `/dev/examples/`. - found = any( - f"{prefix}{name}" in readme_text - for prefix in accepted_prefixes - for name in relative_names - ) - assert found, ( - "README.md must reference at least one LaTeX-first example " - f"script by one of {accepted_prefixes!r} (basenames: " - f"{sorted(relative_names)}) so the canonical first-run path " - "is reachable (P7-3-c2)." - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_4.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_4.py deleted file mode 100644 index 9b071fe..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_4.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Task P7-4: Architecture decision / recovery-status note cross-linking the plan and the drift report. - -Phase 7 (R6.4) — docs-tier acceptance: a short ADR-style or recovery-status -note must let future readers trace why the recovery work exists and what -it is correcting, by linking the recovery plan -(``dev/plans/recovery_plan_latex_contract.md``) and the drift report -(``dev/reviews/drift_20_04.md``). - -Tier: docs - -Acceptance criteria: - 1. P7-4-c1: Readers can trace why recovery work exists and what it is - correcting (cross-links present and bidirectional, or at least - discoverable from one entry point). - 2. P7-4-c2: Deliverables present at the listed surfaces (``dev/reviews/``, - ``dev/plans/``, optional ADR). - 3. No regressions on the existing test suite. - -No upstream blockers — this is governance documentation that summarises -phases that have already landed. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[5] -REVIEWS_DIR = REPO_ROOT / "dev" / "reviews" -ADR_DIR = REPO_ROOT / "dev" / "adr" -PLANS_DIR = REPO_ROOT / "dev" / "plans" -RECOVERY_PLAN = PLANS_DIR / "recovery_plan_latex_contract.md" -DRIFT_REPORT = REVIEWS_DIR / "drift_20_04.md" - - -def _candidate_note_paths() -> list[Path]: - """Return any markdown file under ``dev/reviews/`` or ``dev/adr/`` - that mentions both the recovery plan and the drift report by filename. - - The search is intentionally permissive about location so the test - works whether P7-4 chose ``dev/reviews/recovery_status_*.md``, - ``dev/adr/0001-*.md``, or some other in-surface name. - """ - candidates: list[Path] = [] - for surface in (REVIEWS_DIR, ADR_DIR): - if not surface.is_dir(): - continue - for path in surface.glob("*.md"): - # Don't let the drift report or recovery plan itself satisfy the - # cross-link role — we want a *new* note that points at both. - if path.resolve() == DRIFT_REPORT.resolve(): - continue - try: - text = path.read_text(encoding="utf-8") - except OSError: - continue - if "recovery_plan_latex_contract.md" in text and "drift_20_04.md" in text: - candidates.append(path) - return candidates - - -class TestP7_4: - """Tests for Task P7-4: ADR / recovery-status cross-link note.""" - - @pytest.mark.docs - def test_cross_link_note_exists_and_references_plan_and_drift(self) -> None: - """P7-4-c1: Readers can trace why recovery work exists. - - Verifies: a recovery-status / ADR note exists under ``dev/reviews/`` - or ``dev/adr/`` (or similar) that names BOTH - ``recovery_plan_latex_contract.md`` and ``drift_20_04.md``, plus a - one-line summary of the contract being restored. - - Passes when: at least one file in the recovery-doc surfaces contains - both filenames as relative links or markdown references. - """ - # Preconditions — the diagnosis and prescription must both still exist. - assert DRIFT_REPORT.is_file(), f"missing drift audit (diagnosis): {DRIFT_REPORT}" - assert RECOVERY_PLAN.is_file(), f"missing recovery plan (prescription): {RECOVERY_PLAN}" - - notes = _candidate_note_paths() - assert notes, ( - "P7-4 cross-link note not found: expected a markdown file under " - "dev/reviews/ or dev/adr/ that references BOTH " - "recovery_plan_latex_contract.md AND drift_20_04.md." - ) - - # The note must read as a cross-link, not just incidentally mention - # both filenames — require a one-line summary marker and a - # 'how to read' / pointer style sentence. - # - # post_recovery_plan Phase 6 (P6-3): selecting the candidate by - # filtering on the plan-referenced filename rather than indexing - # the list positionally. Order of `notes` must not change which - # note we assert on. - target_notes = [ - n for n in notes if "recovery_plan_latex_contract.md" in n.read_text(encoding="utf-8") - ] - assert target_notes, "no cross-link note references recovery_plan_latex_contract.md" - target = target_notes[0] - text = target.read_text(encoding="utf-8") - assert "LaTeX" in text, ( - f"cross-link note {target} should mention the LaTeX contract " - "being restored in its summary." - ) - # Sanity: must be short. P7-4 spec asks for under ~80 lines. - line_count = len(text.splitlines()) - assert line_count <= 120, ( - f"cross-link note {target} is {line_count} lines; P7-4 spec " - "asks for a short note (target <= 80, ceiling 120)." - ) - - @pytest.mark.docs - def test_deliverables_present_at_surfaces(self) -> None: - """P7-4-c2: Deliverables present at the listed surfaces. - - Verifies: the recovery plan now contains a back-reference to the - cross-link note (so the note is discoverable from the plan), and - the note itself lives under ``dev/reviews/`` or ``dev/adr/``. - - Passes when: the recovery plan references the new note by relative - path AND the note file lives in one of the listed surfaces. - """ - notes = _candidate_note_paths() - assert notes, "P7-4 cross-link note not found under dev/reviews/ or dev/adr/." - - # All candidate notes must live under one of the listed surfaces. - for note in notes: - in_listed_surface = any( - note.resolve().is_relative_to(surface.resolve()) - for surface in (REVIEWS_DIR, ADR_DIR) - if surface.is_dir() - ) - assert in_listed_surface, ( - f"cross-link note {note} must live under dev/reviews/ or " - "dev/adr/ (the surfaces listed in P7-4)." - ) - - # The recovery plan must back-reference at least one of the candidate - # cross-link notes by filename, so the note is discoverable from the - # plan itself. (frontend_drift_history.md is a P1-5 historical record - # that also cross-links both documents but is not itself the P7-4 - # cross-link note; we accept any candidate the plan points back at.) - plan_text = RECOVERY_PLAN.read_text(encoding="utf-8") - referenced = [n for n in notes if n.name in plan_text] - assert referenced, ( - f"recovery plan {RECOVERY_PLAN} must reference at least one " - f"cross-link note from {[n.name for n in notes]} so readers can " - "discover it without prior knowledge of the filename." - ) 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 deleted file mode 100644 index d45c048..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_5.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Task P7-5: Archive or annotate superseded sprint/task documents. - -Phase 7 (R6.5) — docs-tier acceptance: superseded sprint/plan/tracker -artifacts must be obviously historical so that no future contributor -mistakes them for the active execution source. Builds on P1-6 (which -already added superseded banners to ``MVP_plan.md`` and -``MVP_sprint{1,2,3}.md``) and extends the same treatment to any other -plans, task folders, and trackers that are no longer authoritative. - -Tier: docs - -Acceptance criteria: - 1. P7-5-c1: No historical plan appears to be the active execution - source by accident — every superseded artifact carries a banner or - directory marker pointing to the recovery plan as the active source. - 2. P7-5-c2: Deliverables present at the listed surfaces (``dev/plans/``, - ``dev/tasks/``, ``dev/tracking/``). - 3. No regressions on the existing test suite. - -Blocked by: P5-1 (the stable Taichi-only contract must already be the -active story before older sprint plans can be safely archived). -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -# Repository root: this file lives at -# packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_5.py -# so .parents[5] is the workspace root. -REPO_ROOT = Path(__file__).resolve().parents[5] -PLANS_DIR = REPO_ROOT / "dev" / "plans" -TASKS_DIR = REPO_ROOT / "dev" / "tasks" -TRACKING_DIR = REPO_ROOT / "dev" / "tracking" - -# Active execution sources: completed recovery foundations plus the current -# successor plans that explicitly continue after them. ``constitutive_latex`` -# is the current active plan (the LaTeX-derived constitutive pipeline — Phase 3 -# is merged on main, Phase 4 (Mooney-Rivlin + Ogden) is pending), so it is -# authoritative, not superseded. ``akms_executable_bridge`` is the active -# coupling plan (MechDSL as the AKMS-Learn executable_bridge — Phase 1 / Tier-1 -# integration surface is implemented; Phases 2-3 execute in the AKMS and -# Logic-Loom repos), so it is authoritative, not superseded. -# ``issue307`` is the active algo2code parser/codegen fix plan (issue #307); -# workstreams W1–W6 are implemented in this PR (fail-loud foundation, SSA vector -# lowering, transpose alias, PCG parity gate, deferral tests, and inclusive -# for-loop lowering), so the plan remains authoritative pending merge/close, not -# superseded. -# ``PlanJune14`` is the active SVK/J2 all-Taichi-seam plan; ``pj14_fix`` (its -# Codex-remediation child) and ``pj316_resolution`` (the active plan resolving -# the PR #316 review findings) continue it, so all three are authoritative, not -# superseded — allowlisted rather than bannered (the "still active" model). -ACTIVE_PLAN_STEMS = { - "recovery_plan_latex_contract", - "fgram", - "constitutive_latex", - "akms_executable_bridge", - "issue307", - "PlanJune14", - "pj14_fix", - "pj316_resolution", - # ``PlanJune14_closure`` is the active closure record for PlanJune14 (PJ-7 - # governance) — an authoritative governance artifact, not a superseded plan. - "PlanJune14_closure", - # ``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", - "post_recovery_plan", - "fgram", - "constitutive_latex", - "akms_executable_bridge", - # PlanJune14 is active (see ACTIVE_PLAN_STEMS) -> its task folder is too. - "PlanJune14", -} -ACTIVE_TRACKER_STEMS = { - "tasks-tracker_recovery_plan_latex_contract", - "tasks-tracker_post_recovery_plan", - "tasks-tracker_fgram", - "tasks-tracker_constitutive_latex", - "tasks-tracker_akms_executable_bridge", - # PlanJune14 is active (see ACTIVE_PLAN_STEMS) -> its tracker is too. - "tasks-tracker_PlanJune14", -} - -# Trackers that are conventions / vocabulary, not plan execution sources. -TRACKER_NON_PLAN_STEMS = {"STATUS_LEGEND", "verification_matrix"} - -# A "superseded" marker is any case-insensitive occurrence of the literal -# substring ``superseded``. P1-6 established this convention via the -# ``> ⚠️ **Superseded ...`` banner on ``MVP_plan.md`` and -# ``MVP_sprint{1,2,3}.md``; P7-5 extends it across the rest of dev/. -SUPERSEDED_MARKER = "superseded" - - -def _has_superseded_marker(text: str) -> bool: - return SUPERSEDED_MARKER in text.lower() - - -class TestP7_5: - """Tests for Task P7-5: superseded artifacts marked historical.""" - - @pytest.mark.docs - def test_no_historical_plan_appears_active_by_accident(self) -> None: - """P7-5-c1: No historical plan appears to be the active source. - - Every top-level ``.md`` file under ``dev/plans/`` other than the - canonical-active set must carry a ``superseded`` marker pointing - at the recovery plan as the active execution source. - """ - assert PLANS_DIR.is_dir(), f"missing {PLANS_DIR}" - - plan_files = sorted(p for p in PLANS_DIR.iterdir() if p.is_file() and p.suffix == ".md") - assert plan_files, f"no plan files found under {PLANS_DIR}" - - offenders: list[str] = [] - for plan in plan_files: - if plan.stem in ACTIVE_PLAN_STEMS: - continue - text = plan.read_text(encoding="utf-8") - if not _has_superseded_marker(text): - offenders.append(str(plan.relative_to(REPO_ROOT))) - - assert not offenders, ( - "Plan files lack a 'superseded' banner pointing at the recovery plan " - "(the active execution source); either banner them or add to the " - f"ACTIVE_PLAN_STEMS allowlist with justification: {offenders}" - ) - - @pytest.mark.docs - def test_deliverables_present_at_surfaces(self) -> None: - """P7-5-c2: Deliverables present at the listed surfaces. - - Every non-recovery task folder under ``dev/tasks/`` must contain a - ``_SUPERSEDED.md`` marker, and every non-recovery tracker file - under ``dev/tracking/`` must carry a ``superseded`` banner. The - recovery plan's task folder and tracker remain banner-free. - """ - assert TASKS_DIR.is_dir(), f"missing {TASKS_DIR}" - assert TRACKING_DIR.is_dir(), f"missing {TRACKING_DIR}" - - # --- dev/tasks/ --- - task_dirs = sorted(d for d in TASKS_DIR.iterdir() if d.is_dir()) - assert task_dirs, f"no task folders found under {TASKS_DIR}" - - task_offenders: list[str] = [] - for tdir in task_dirs: - if tdir.name in ACTIVE_TASK_DIRS: - continue - marker = tdir / "_SUPERSEDED.md" - if not marker.is_file(): - task_offenders.append(str(tdir.relative_to(REPO_ROOT))) - continue - if not _has_superseded_marker(marker.read_text(encoding="utf-8")): - task_offenders.append(str(marker.relative_to(REPO_ROOT))) - - assert not task_offenders, ( - "Task folders lack a `_SUPERSEDED.md` marker pointing at the " - f"recovery plan: {task_offenders}" - ) - - # --- dev/tracking/ --- - tracker_files = sorted( - p - for p in TRACKING_DIR.iterdir() - if p.is_file() and p.suffix == ".md" and p.stem.startswith("tasks-tracker_") - ) - assert tracker_files, f"no tracker files found under {TRACKING_DIR}" - - tracker_offenders: list[str] = [] - for tracker in tracker_files: - if tracker.stem in ACTIVE_TRACKER_STEMS: - continue - if tracker.stem in TRACKER_NON_PLAN_STEMS: - continue - text = tracker.read_text(encoding="utf-8") - if not _has_superseded_marker(text): - tracker_offenders.append(str(tracker.relative_to(REPO_ROOT))) - - assert not tracker_offenders, ( - "Tracker files lack a 'superseded' banner pointing at the " - f"recovery plan: {tracker_offenders}" - ) - - # --- positive checks: the active set must not carry the marker - # at the top of file (the recovery plan / its tracker / its tasks - # folder must read as authoritative). --- - active_plan = PLANS_DIR / "recovery_plan_latex_contract.md" - active_tracker = TRACKING_DIR / "tasks-tracker_recovery_plan_latex_contract.md" - active_task_dir = TASKS_DIR / "recovery_plan_latex_contract" - - assert active_plan.is_file(), f"active plan missing: {active_plan}" - assert active_tracker.is_file(), f"active tracker missing: {active_tracker}" - assert active_task_dir.is_dir(), f"active task folder missing: {active_task_dir}" - - # The active task folder must NOT have a _SUPERSEDED.md. - assert not (active_task_dir / "_SUPERSEDED.md").exists(), ( - f"active task folder must not be marked superseded: {active_task_dir}" - ) - - # The active plan's first line / first 500 chars must not look superseded. - active_plan_head = active_plan.read_text(encoding="utf-8")[:500] - assert not _has_superseded_marker(active_plan_head), ( - "Active recovery plan unexpectedly carries a 'superseded' marker in its header." - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_6.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_6.py deleted file mode 100644 index e16dffe..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_6.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Task P7-6: Closing drift/alignment review after Phases R1–R4 land. - -Phase 7 (R6.6) docs-tier acceptance: once the four pillars (R1 frontend, -R2 ProblemIR, R3 ElementIR, R4 Taichi codegen) are landed, the recovery -loop is closed by a follow-up to ``dev/reviews/drift_20_04.md`` that -records explicit per-pillar verdicts. - -Tier: docs. Blocked by: P2-1, P3-1, P4-1, P5-1 (the four pillars). - -Acceptance: (c1) follow-up review answers each pillar with one of -RESTORED / PARTIAL / STILL DRIFTING; (c2) review file lives under -``dev/reviews/`` and reconciles ≥5 of the recovery plan's success -criteria. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -# Repo root: this file is at -# packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_p7_6.py -# so .parents[5] is the repo root. -_REPO_ROOT = Path(__file__).resolve().parents[5] -_REVIEWS_DIR = _REPO_ROOT / "dev" / "reviews" -_RECOVERY_PLAN = _REPO_ROOT / "dev" / "plans" / "recovery_plan_latex_contract.md" - -# Verdict words the follow-up review may assign to a pillar. -_VERDICT_PATTERN = re.compile( - r"\b(RESTORED|STILL\s+DRIFTING|PARTIAL)\b", - re.IGNORECASE, -) - - -def _find_followup_review() -> Path: - """Locate ``drift_post_recovery*.md`` under ``dev/reviews/`` (parallels - the ``drift_20_04.md`` naming; glob tolerates a date suffix).""" - candidates = sorted(_REVIEWS_DIR.glob("drift_post_recovery*.md")) - assert candidates, f"P7-6 expected drift_post_recovery*.md under {_REVIEWS_DIR}; found none." - return candidates[0] - - -def _section_for_pillar(text: str, pillar: str) -> str: - """Slice of ``text`` belonging to one R-pillar section, delimited by - markdown headings (``## R1 ...`` or ``### R1 — ...``); ends at the - next heading of equal-or-higher level.""" - head_re = re.compile(rf"^(#+)\s+{pillar}\b", re.MULTILINE | re.IGNORECASE) - m = head_re.search(text) - assert m, f"Follow-up review has no heading for pillar {pillar!r}." - depth = len(m.group(1)) - next_head_re = re.compile(rf"^#{{1,{depth}}}\s+\S", re.MULTILINE) - nxt = next_head_re.search(text, pos=m.end()) - return text[m.start() : (nxt.start() if nxt else len(text))] - - -class TestP7_6: - """Tests for Task P7-6: post-R1–R4 closing drift/alignment review.""" - - @pytest.mark.docs - def test_follow_up_review_confirms_contract_status(self) -> None: - """P7-6-c1: review references the original drift report and - records an explicit verdict (RESTORED / PARTIAL / STILL DRIFTING) - per pillar R1–R4.""" - review = _find_followup_review() - text = review.read_text(encoding="utf-8") - assert "drift_20_04.md" in text, ( - f"{review.name} must reference drift_20_04.md so readers can " - "trace the recovery-loop closure." - ) - for pillar in ("R1", "R2", "R3", "R4"): - section = _section_for_pillar(text, pillar) - assert _VERDICT_PATTERN.search(section), ( - f"Pillar {pillar} in {review.name} has no verdict " - "(RESTORED / PARTIAL / STILL DRIFTING)." - ) - - @pytest.mark.docs - def test_deliverables_present_at_surfaces(self) -> None: - """P7-6-c2: review lives under ``dev/reviews/`` and reconciles - ≥5 of the recovery plan's 9 success-criteria checklist bullets - (matched by lowercased 5-word fingerprint, with a tail-fingerprint - fallback for bullets starting with stop-words).""" - review = _find_followup_review() - assert review.parent == _REVIEWS_DIR, f"{review} is not under dev/reviews/." - - raw_review = review.read_text(encoding="utf-8").lower() - review_text = re.sub(r"\s+", " ", raw_review) - - assert _RECOVERY_PLAN.is_file(), f"Recovery plan missing at {_RECOVERY_PLAN}." - plan_lines = _RECOVERY_PLAN.read_text(encoding="utf-8").splitlines() - # Success-criteria block lives ~lines 60–69; widen window for drift. - block_window = "\n".join(plan_lines[55:75]) - bullet_re = re.compile(r"^- \[[ x]\] (?P.+)$", re.MULTILINE) - bullets = [m.group("body").strip() for m in bullet_re.finditer(block_window)] - assert len(bullets) >= 9, ( - f"Plan success-criteria block changed shape (found {len(bullets)}; " - "expected ≥9). Update P7-6 alongside any plan rewrite." - ) - - def fingerprint(bullet: str, lo: int = 0, hi: int = 5) -> str: - words = re.findall(r"[A-Za-z_]+", bullet) - return " ".join(words[lo:hi]).lower() - - matched = 0 - for bullet in bullets: - fp = fingerprint(bullet) - if not fp: - continue - if fp in review_text or ( - len(re.findall(r"[A-Za-z_]+", bullet)) >= 8 - and fingerprint(bullet, 3, 8) in review_text - ): - matched += 1 - - assert matched >= 5, ( - f"Follow-up review {review.name} mirrors only {matched} of " - f"{len(bullets)} plan success criteria; P7-6 requires ≥5." - ) diff --git a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_pcg_transpiler_parity.py b/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_pcg_transpiler_parity.py deleted file mode 100644 index 3265b26..0000000 --- a/packages/mechdsl-core/tests/plan_tests/recovery_plan_latex_contract/test_pcg_transpiler_parity.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Issue #307 W4 — decision gate: hand-written PCG vs transpiler-generated PCG. - -The hand-written ``Algo2CodePCGSolver`` is a line-by-line translation of the -canonical PCG LaTeX. Now that ``algo2code.transpile`` produces a runnable Taichi -PCG (issue #307 F1/F2), this test compares the two numerically to decide whether -the hand-translation can be retired. - -Outcome (pinned here): - * **Converged path** — identical to machine precision (same iterations, same x). - * **Max-iter-exhaust path** — also identical. The generated - ``\\For{$k = 1, ..., maxiter$}`` now lowers to the inclusive - ``range(1, maxiter + 1)`` (issue #307 W6 for-loop fix), matching the - hand-written ``range(1, max_iter + 1)``. With both paths bit-identical, the - hand-translation can be retired. -""" - -from __future__ import annotations - -import importlib.util -import sys -import warnings - -import numpy as np -import pytest - -pytestmark = [pytest.mark.slow, pytest.mark.e2e] - - -def _matvec(a_mat: np.ndarray): - return lambda v: a_mat @ v - - -def _spd_system(seed: int = 99, n: int = 10): - rng = np.random.default_rng(seed) - b_mat = rng.standard_normal((n, n)) - a_mat = b_mat.T @ b_mat + n * np.eye(n) - rhs = rng.standard_normal(n) - return a_mat, rhs, 1.0 / np.diag(a_mat) - - -def _load_generated_pcg(ti, tmp_path): - from algo2code import transpile - from algo2code.library.pcg import PCG_ALGORITHM_LATEX - - code = transpile(PCG_ALGORITHM_LATEX).replace("arch=ti.gpu", "arch=ti.cpu") - code = "\n".join(line for line in code.splitlines() if not line.startswith("ti.init")) - # pytest's tmp_path is managed (auto-cleaned) yet persists for the whole test, - # so the generated module's source file is still on disk when Taichi runs - # inspect.getsource during JIT. - path = tmp_path / "gen_pcg.py" - path.write_text(code) - spec = importlib.util.spec_from_file_location("gen_pcg_parity", path) - mod = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = mod - spec.loader.exec_module(mod) - return mod - - -def _run_generated(ti, gen, a_mat, rhs, x0, dinv, tol, maxiter): - n = len(rhs) - a_field = ti.field(ti.f64, shape=(n, n)) - a_field.from_numpy(np.ascontiguousarray(a_mat)) - b_field = ti.field(ti.f64, shape=n) - b_field.from_numpy(np.ascontiguousarray(rhs)) - x_field = ti.field(ti.f64, shape=n) - x_field.from_numpy(np.ascontiguousarray(x0)) - - # The generated driver calls apply_M_inv(r, z) as a plain in-place callback, - # so a pure-Python function over the fields suffices (no @ti.kernel — which - # would fail source introspection when nested under pytest). - def apply_m_inv(r_in, z_out): - z_out.from_numpy(np.ascontiguousarray(dinv * r_in.to_numpy())) - - x, k, r_norm = gen.pcg(a_field, b_field, x_field, apply_m_inv, tol, maxiter) - return x.to_numpy(), int(k), float(r_norm) - - -def test_converged_path_is_identical_to_machine_precision(tmp_path): - """On a system that converges before maxiter, the two PCGs agree exactly.""" - ti = pytest.importorskip("taichi") - ti.init(arch=ti.cpu, default_fp=ti.f64) - from mechdsl.solver.import_adapter import Algo2CodePCGSolver - - a_mat, rhs, dinv = _spd_system() - x0 = np.zeros(len(rhs)) - - def jac(v): - return dinv * v - - gen = _load_generated_pcg(ti, tmp_path) - x_hand, k_hand, _ = Algo2CodePCGSolver(precond_fn=jac).solve( - _matvec(a_mat), rhs, x0.copy(), 1e-10, 200 - ) - x_gen, k_gen, _ = _run_generated(ti, gen, a_mat, rhs, x0, dinv, 1e-10, 200) - - assert k_hand == k_gen - np.testing.assert_allclose(x_gen, x_hand, atol=1e-10, rtol=0) - - -def test_maxiter_exhaust_path_matches(tmp_path): - """When maxiter is exhausted the two PCGs match. - - Previously diverged because the generated ``\\For{$k = 1, ..., maxiter$}`` - lowered to exclusive ``range(1, maxiter)`` (one fewer iteration). The - for-loop range lowering now emits the inclusive ``range(1, maxiter + 1)``, - so the two are bit-identical on this path too — clearing the way to retire - the hand-translation. - """ - ti = pytest.importorskip("taichi") - ti.init(arch=ti.cpu, default_fp=ti.f64) - from mechdsl.solver.import_adapter import Algo2CodePCGSolver - - a_mat, rhs, dinv = _spd_system() - x0 = np.zeros(len(rhs)) - - def jac(v): - return dinv * v - - gen = _load_generated_pcg(ti, tmp_path) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - x_hand, _, _ = Algo2CodePCGSolver(precond_fn=jac).solve( - _matvec(a_mat), rhs, x0.copy(), 1e-10, 3 - ) - x_gen, _, _ = _run_generated(ti, gen, a_mat, rhs, x0, dinv, 1e-10, 3) - - np.testing.assert_allclose(x_gen, x_hand, atol=1e-10, rtol=0) diff --git a/packages/mechdsl-core/tests/plan_tests/test_p1_1.py b/packages/mechdsl-core/tests/plan_tests/test_p1_1.py deleted file mode 100644 index b38a14f..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p1_1.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -PLAN = Path(__file__).resolve().parents[4] / "dev" / "plans" / "recovery_plan_latex_contract.md" - - -def _plan_text() -> str: - return PLAN.read_text(encoding="utf-8") - - -class TestTaskP1_1: - """ - Tests for Task P1-1: Insert Phase ID mapping (R-label ↔ Aut_Faciam integer) table - Acceptance criteria covered: 1, 2, 3 - """ - - @pytest.mark.audit - def test_phase_id_mapping_section_present(self) -> None: - text = _plan_text() - hits = re.findall(r"^## Phase ID mapping", text, flags=re.MULTILINE) - assert len(hits) == 1, ( - f"expected exactly one '## Phase ID mapping' heading, found {len(hits)}" - ) - - @pytest.mark.audit - def test_mapping_appears_before_first_phase_heading(self) -> None: - text = _plan_text() - mapping = text.find("## Phase ID mapping") - first_phase = re.search(r"^## Phase (?:R\d|\d) ", text, flags=re.MULTILINE) - assert mapping >= 0, "mapping section missing" - assert first_phase is not None, "no phase heading found" - assert mapping < first_phase.start(), ( - f"mapping at offset {mapping} should precede first phase heading at {first_phase.start()}" - ) - - @pytest.mark.audit - def test_all_seven_phases_listed_with_r_label(self) -> None: - text = _plan_text() - section_match = re.search( - r"## Phase ID mapping.*?(?=^---|^## )", text, flags=re.MULTILINE | re.DOTALL - ) - assert section_match is not None - section = section_match.group(0) - for phase_int in range(1, 8): - assert re.search(rf"^\|\s*{phase_int}\s*\|", section, flags=re.MULTILINE), ( - f"missing row for Aut_Faciam phase {phase_int}" - ) - for r_label in ("R0", "R1", "R2", "R3", "R4", "R5", "R6"): - assert re.search(rf"\|\s*{r_label}\s*\|", section), f"missing R-label {r_label}" diff --git a/packages/mechdsl-core/tests/plan_tests/test_p1_2.py b/packages/mechdsl-core/tests/plan_tests/test_p1_2.py deleted file mode 100644 index 1877795..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p1_2.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -PLAN = Path(__file__).resolve().parents[4] / "dev" / "plans" / "recovery_plan_latex_contract.md" - - -def _plan_text() -> str: - return PLAN.read_text(encoding="utf-8") - - -def _phase_headings() -> list[str]: - text = _plan_text() - # match `## Phase — ... (R)` only (the canonical form after P1-2) - return re.findall(r"^## Phase \d+ — .*?\(R\d\)\s*$", text, flags=re.MULTILINE) - - -class TestTaskP1_2: - """ - Tests for Task P1-2: Renumber phase headings to integer + (RX) form - Acceptance criteria covered: 1, 2, 3 - """ - - @pytest.mark.audit - def test_seven_integer_phase_headings(self) -> None: - text = _plan_text() - # `^## Phase —` count must equal 7 - hits = re.findall(r"^## Phase \d+ — ", text, flags=re.MULTILINE) - assert len(hits) == 7, f"expected 7 integer-form phase headings, found {len(hits)}" - # no legacy `## Phase R —` headings should remain - legacy = re.findall(r"^## Phase R\d — ", text, flags=re.MULTILINE) - assert not legacy, f"legacy R-form headings still present: {legacy}" - - @pytest.mark.audit - def test_each_heading_carries_legacy_r_label(self) -> None: - headings = _phase_headings() - assert len(headings) == 7, f"expected 7 canonical phase headings, found {len(headings)}" - for h in headings: - assert re.search(r"\(R\d\)\s*$", h), f"heading missing legacy (RX) suffix: {h!r}" - - @pytest.mark.audit - def test_phase_numbers_strictly_increasing(self) -> None: - headings = _phase_headings() - nums = [int(re.match(r"## Phase (\d+) ", h).group(1)) for h in headings] - assert nums == sorted(nums), f"phase numbers not in increasing order: {nums}" - assert nums == list(range(1, 8)), f"expected exactly 1..7, got {nums}" diff --git a/packages/mechdsl-core/tests/plan_tests/test_p1_3.py b/packages/mechdsl-core/tests/plan_tests/test_p1_3.py deleted file mode 100644 index 1a7ddbd..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p1_3.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -PLAN = Path(__file__).resolve().parents[4] / "dev" / "plans" / "recovery_plan_latex_contract.md" -TIER_VOCABULARY = {"unit", "integration", "regression", "docs", "manual"} -EXPECTED_HEADER = ( - "| Task ID | Legacy ID | Action item | Files / surfaces | Blocked by | Tier | Verification |" -) - - -def _plan_text() -> str: - return PLAN.read_text(encoding="utf-8") - - -def _action_item_tables() -> list[list[str]]: - """Return each action-item table's body rows (one list of row-strings per phase).""" - text = _plan_text() - tables: list[list[str]] = [] - # Each phase's action-item table is preceded by `### Action items` and starts at - # the `| Task ID | …` header line. - for action_match in re.finditer(r"^### Action items\b", text, flags=re.MULTILINE): - tail = text[action_match.end() :] - header_match = re.search(r"^\| Task ID .+\|$", tail, flags=re.MULTILINE) - assert header_match, "action-item table missing Task ID header" - body_start = tail.find("\n", header_match.end()) + 1 - # body rows continue until a blank line or the next `###` heading - body_section = tail[body_start:] - end_match = re.search(r"^\s*$|^###? ", body_section, flags=re.MULTILINE) - body = body_section[: end_match.start()] if end_match else body_section - rows = [ - line - for line in body.splitlines() - if line.startswith("|") and not line.startswith("|---") and "Task ID" not in line - ] - tables.append(rows) - return tables - - -class TestTaskP1_3: - """ - Tests for Task P1-3: Rewrite action-item tables with new columns - Acceptance criteria covered: 1, 2, 3, 4 - """ - - @pytest.mark.audit - def test_seven_column_header_on_every_action_table(self) -> None: - text = _plan_text() - headers = re.findall(r"^\| Task ID \| Legacy ID \| .+\|$", text, flags=re.MULTILINE) - assert len(headers) == 7, f"expected 7 canonical headers, found {len(headers)}" - for h in headers: - assert h == EXPECTED_HEADER, f"header mismatch: {h!r}" - - @pytest.mark.audit - def test_task_ids_match_canonical_regex(self) -> None: - tables = _action_item_tables() - assert len(tables) == 7, f"expected 7 action-item tables, found {len(tables)}" - for phase_idx, rows in enumerate(tables, start=1): - assert rows, f"phase {phase_idx} has no action-item rows" - for row in rows: - cells = [c.strip() for c in row.strip("|").split("|")] - task_id = cells[0] - assert re.fullmatch(r"P[1-7]-[0-9]+", task_id), ( - f"phase {phase_idx} row has non-canonical Task ID: {task_id!r}" - ) - assert task_id.startswith(f"P{phase_idx}-"), ( - f"phase {phase_idx} contains foreign Task ID {task_id}" - ) - - @pytest.mark.audit - def test_tier_values_drawn_from_vocabulary(self) -> None: - tables = _action_item_tables() - for phase_idx, rows in enumerate(tables, start=1): - for row in rows: - cells = [c.strip() for c in row.strip("|").split("|")] - tier = cells[5] - assert tier in TIER_VOCABULARY, ( - f"phase {phase_idx} task {cells[0]} has invalid tier {tier!r}; " - f"must be one of {sorted(TIER_VOCABULARY)}" - ) - - @pytest.mark.audit - def test_cross_phase_blockers_use_canonical_ids(self) -> None: - tables = _action_item_tables() - for phase_idx, rows in enumerate(tables, start=1): - for row in rows: - cells = [c.strip() for c in row.strip("|").split("|")] - blocked_by = cells[4] - if blocked_by in ("", "—"): - continue - tokens = [t.strip() for t in blocked_by.split(",")] - for tok in tokens: - assert re.fullmatch(r"P[1-7]-[0-9]+", tok), ( - f"phase {phase_idx} task {cells[0]} has non-canonical " - f"blocked_by token {tok!r}" - ) - # legacy-style R-IDs must not appear - assert not re.match(r"R\d\.\d", tok), ( - f"legacy R-style ID {tok} leaked into Blocked by" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/test_p1_4.py b/packages/mechdsl-core/tests/plan_tests/test_p1_4.py deleted file mode 100644 index 8a0edc0..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p1_4.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[4] -PLAN = REPO_ROOT / "dev" / "plans" / "recovery_plan_latex_contract.md" -ANCHOR_HEADING = "### Code reality anchor (2026-04-26)" - - -def _plan_text() -> str: - return PLAN.read_text(encoding="utf-8") - - -class TestTaskP1_4: - """ - Tests for Task P1-4: Insert Code reality anchor blocks per phase - Acceptance criteria covered: 1, 2, 3 - """ - - @pytest.mark.audit - def test_seven_code_reality_anchor_subsections(self) -> None: - text = _plan_text() - count = text.count(ANCHOR_HEADING) - assert count == 7, f"expected 7 Code reality anchor subsections, found {count}" - - @pytest.mark.audit - def test_each_anchor_has_at_least_three_bullets(self) -> None: - text = _plan_text() - positions = [m.start() for m in re.finditer(re.escape(ANCHOR_HEADING), text)] - assert len(positions) == 7, f"expected 7 anchors, found {len(positions)}" - for start in positions: - tail = text[start + len(ANCHOR_HEADING) :] - next_heading = re.search(r"\n###? ", tail) - block = tail[: next_heading.start()] if next_heading else tail - bullets = re.findall(r"^- ", block, flags=re.MULTILINE) - assert len(bullets) >= 3, ( - f"anchor near offset {start} has only {len(bullets)} bullets; expected >= 3" - ) - - @pytest.mark.audit - def test_citations_point_to_existing_files(self) -> None: - text = _plan_text() - positions = [m.start() for m in re.finditer(re.escape(ANCHOR_HEADING), text)] - cited_paths: set[str] = set() - for start in positions: - tail = text[start + len(ANCHOR_HEADING) :] - next_heading = re.search(r"\n###? ", tail) - block = tail[: next_heading.start()] if next_heading else tail - for match in re.finditer( - r"`([A-Za-z0-9_./-]+\.(?:py|md|toml))(?::\d+(?:-\d+)?)?`", block - ): - cited_paths.add(match.group(1)) - assert cited_paths, "no file citations found inside anchors" - # Citations are short-form relative paths (e.g. `mechanics_ir.py`, - # `frontend/__init__.py`) that resolve somewhere under packages/. - search_roots = ( - REPO_ROOT, - REPO_ROOT / "packages" / "mechdsl-core" / "src", - REPO_ROOT / "packages" / "algo2code" / "src", - ) - - def _resolves(short_path: str) -> bool: - for root in search_roots: - if (root / short_path).exists(): - return True - tail = short_path.rsplit("/", 1)[-1] - return any(any(root.rglob(tail)) for root in search_roots) - - resolved_missing = [p for p in cited_paths if not _resolves(p)] - assert not resolved_missing, f"cited paths do not resolve: {resolved_missing}" diff --git a/packages/mechdsl-core/tests/plan_tests/test_p1_5.py b/packages/mechdsl-core/tests/plan_tests/test_p1_5.py deleted file mode 100644 index c598d2c..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p1_5.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -PLAN = Path(__file__).resolve().parents[4] / "dev" / "plans" / "recovery_plan_latex_contract.md" - - -def _plan_text() -> str: - return PLAN.read_text(encoding="utf-8") - - -def _cross_phase_blocks() -> list[str]: - """Return the body text of each `### Cross-phase dependencies` block (one per phase).""" - text = _plan_text() - blocks: list[str] = [] - for match in re.finditer(r"^### Cross-phase dependencies\b", text, flags=re.MULTILINE): - tail = text[match.end() :] - end_match = re.search(r"^###? ", tail, flags=re.MULTILINE) - body = tail[: end_match.start()] if end_match else tail - blocks.append(body) - return blocks - - -def _action_table_blocked_by(phase_int: int) -> dict[str, list[str]]: - """Map Task ID → Blocked-by token list for a given phase's action-item table.""" - text = _plan_text() - phase_match = re.search( - rf"^## Phase {phase_int} —.*?(?=^## Phase \d+ —|\Z)", - text, - flags=re.MULTILINE | re.DOTALL, - ) - assert phase_match, f"phase {phase_int} section not found" - section = phase_match.group(0) - edges: dict[str, list[str]] = {} - for line in section.splitlines(): - if not line.startswith("| P") or line.startswith("|---"): - continue - cells = [c.strip() for c in line.strip("|").split("|")] - task_id, _legacy, _action, _files, blocked_by, *_ = cells - if not re.fullmatch(r"P[1-7]-[0-9]+", task_id): - continue - if blocked_by in ("", "—"): - edges[task_id] = [] - else: - edges[task_id] = [t.strip() for t in blocked_by.split(",") if t.strip()] - return edges - - -class TestTaskP1_5: - """ - Tests for Task P1-5: Add Cross-phase dependencies blocks per phase - Acceptance criteria covered: 1, 2, 3 - """ - - @pytest.mark.audit - def test_seven_cross_phase_dependencies_blocks(self) -> None: - text = _plan_text() - count = len(re.findall(r"^### Cross-phase dependencies\b", text, flags=re.MULTILINE)) - assert count == 7, f"expected 7 Cross-phase dependencies subsections, found {count}" - - @pytest.mark.audit - def test_dependency_ids_are_canonical(self) -> None: - for body in _cross_phase_blocks(): - assert not re.search(r"\bR[0-6]\.\d", body), ( - f"legacy R-style ID found inside Cross-phase dependencies block:\n{body}" - ) - ids = re.findall(r"P[1-7]-[0-9]+", body) - for tok in ids: - assert re.fullmatch(r"P[1-7]-[0-9]+", tok), ( - f"non-canonical ID {tok!r} in deps block" - ) - - @pytest.mark.audit - def test_edges_consistent_with_action_table_blocked_by(self) -> None: - all_edges: dict[str, list[str]] = {} - for phase_int in range(1, 8): - all_edges.update(_action_table_blocked_by(phase_int)) - # any task referenced as a blocker must itself exist in the canonical task set - all_task_ids = set(all_edges.keys()) - for task_id, blockers in all_edges.items(): - for b in blockers: - assert b in all_task_ids, ( - f"{task_id} is blocked by {b}, but {b} is not defined in any action-item table" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/test_p1_6.py b/packages/mechdsl-core/tests/plan_tests/test_p1_6.py deleted file mode 100644 index 37eabdf..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p1_6.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -PLAN = Path(__file__).resolve().parents[4] / "dev" / "plans" / "recovery_plan_latex_contract.md" - - -def _plan_text() -> str: - return PLAN.read_text(encoding="utf-8") - - -def _risks_table() -> tuple[str, list[list[str]]]: - """Return (header_row, body_rows) of the Risks and mitigations table.""" - text = _plan_text() - section_match = re.search( - r"^## Risks and mitigations\b.*?(?=^## )", text, flags=re.MULTILINE | re.DOTALL - ) - assert section_match, "Risks and mitigations section missing" - section = section_match.group(0) - rows = [line for line in section.splitlines() if line.startswith("|")] - assert rows, "no table rows in Risks and mitigations" - header = rows[0] - body = [r for r in rows[1:] if not r.startswith("|---")] - parsed = [[c.strip() for c in r.strip("|").split("|")] for r in body] - return header, parsed - - -def _all_canonical_task_ids() -> set[str]: - text = _plan_text() - return set(re.findall(r"\bP[1-7]-[0-9]+\b", text)) - - -class TestTaskP1_6: - """ - Tests for Task P1-6: Add 'Affects task(s)' column to risks table - Acceptance criteria covered: 1, 2, 3 - """ - - @pytest.mark.audit - def test_risks_table_has_affects_column(self) -> None: - header, _ = _risks_table() - assert "Affects task(s)" in header, f"header missing 'Affects task(s)' column: {header!r}" - - @pytest.mark.audit - def test_no_blank_affects_cells(self) -> None: - _, body = _risks_table() - assert body, "no risk rows found" - for row in body: - assert len(row) >= 4, f"row too short: {row!r}" - affects = row[3] - assert affects, f"blank Affects task(s) cell in row: {row!r}" - - @pytest.mark.audit - def test_affected_task_ids_exist_in_action_tables(self) -> None: - _, body = _risks_table() - canonical = _all_canonical_task_ids() - assert canonical, "no canonical task IDs found anywhere in plan — P1-3 must run first" - for row in body: - affects = row[3] - if affects in ("", "—"): - continue - tokens = [t.strip() for t in affects.split(",") if t.strip()] - for tok in tokens: - assert re.fullmatch(r"P[1-7]-[0-9]+", tok), ( - f"non-canonical token {tok!r} in Affects task(s) cell of row: {row!r}" - ) - assert tok in canonical, ( - f"affected task {tok!r} is not defined in any action-item table" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/test_p1_7.py b/packages/mechdsl-core/tests/plan_tests/test_p1_7.py deleted file mode 100644 index ad46261..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p1_7.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -PLAN = Path(__file__).resolve().parents[4] / "dev" / "plans" / "recovery_plan_latex_contract.md" -EXPECTED_MVP_FILES = ( - "dev/plans/MVP_plan.md", - "dev/plans/MVP_sprint1.md", - "dev/plans/MVP_sprint2.md", - "dev/plans/MVP_sprint3.md", -) - - -def _phase1_action_rows() -> list[list[str]]: - text = PLAN.read_text(encoding="utf-8") - phase1_match = re.search( - r"^## Phase 1 —.*?(?=^## Phase 2 —)", text, flags=re.MULTILINE | re.DOTALL - ) - assert phase1_match, "Phase 1 section not found" - section = phase1_match.group(0) - rows: list[list[str]] = [] - for line in section.splitlines(): - if not line.startswith("| P1-") or line.startswith("|---"): - continue - cells = [c.strip() for c in line.strip("|").split("|")] - rows.append(cells) - return rows - - -class TestTaskP1_7: - """ - Tests for Task P1-7: Add P1-6 supersession task in recovery plan - Acceptance criteria covered: 1, 2, 3 - """ - - @pytest.mark.audit - def test_phase1_action_table_has_six_rows(self) -> None: - rows = _phase1_action_rows() - assert len(rows) == 6, ( - f"expected 6 P1-* rows in Phase 1, found {len(rows)}: {[r[0] for r in rows]}" - ) - ids = [r[0] for r in rows] - assert ids == [f"P1-{n}" for n in range(1, 7)], f"unexpected Task IDs in Phase 1: {ids}" - - @pytest.mark.audit - def test_p1_6_row_has_correct_metadata(self) -> None: - rows = _phase1_action_rows() - p1_6 = next((r for r in rows if r[0] == "P1-6"), None) - assert p1_6 is not None, "P1-6 row missing from Phase 1 action-item table" - # cells: Task ID | Legacy ID | Action item | Files / surfaces | Blocked by | Tier | Verification - tier = p1_6[5] - blocked_by = p1_6[4] - assert tier == "docs", f"P1-6 Tier should be 'docs', got {tier!r}" - assert blocked_by == "P1-4", f"P1-6 Blocked by should be 'P1-4', got {blocked_by!r}" - - @pytest.mark.audit - def test_all_four_mvp_plan_files_listed(self) -> None: - rows = _phase1_action_rows() - p1_6 = next((r for r in rows if r[0] == "P1-6"), None) - assert p1_6 is not None - files_cell = p1_6[3] - for expected in EXPECTED_MVP_FILES: - assert expected in files_cell, f"P1-6 row missing reference to {expected}" diff --git a/packages/mechdsl-core/tests/plan_tests/test_p1_8.py b/packages/mechdsl-core/tests/plan_tests/test_p1_8.py deleted file mode 100644 index a51cc4b..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p1_8.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -PLAN = Path(__file__).resolve().parents[4] / "dev" / "plans" / "recovery_plan_latex_contract.md" -ONE_SENTENCE_NOTE = ( - "PR boundaries are tracked per task in " - "`dev/tasks/recovery_plan_latex_contract/json/`; one task = one PR is the default." -) - - -def _plan_text() -> str: - return PLAN.read_text(encoding="utf-8") - - -class TestTaskP1_8: - """ - Tests for Task P1-8: Drop or fold the 'Suggested PR slices' section - Acceptance criteria covered: 1, 2 - """ - - @pytest.mark.audit - def test_no_legacy_pr_slice_patterns(self) -> None: - text = _plan_text() - # legacy slicing patterns interleaved tasks across phases (e.g. "PR-2 ... R1.1") - assert not re.search(r"PR-\d.*R\d\.\d", text), "legacy interleaved PR slicing still present" - for legacy_header in ("### PR-1 ", "### PR-2 ", "### PR-3 ", "### PR-4 "): - assert legacy_header not in text, ( - f"legacy PR-slice header still present: {legacy_header!r}" - ) - - @pytest.mark.audit - def test_section_either_deleted_or_one_sentence(self) -> None: - text = _plan_text() - match = re.search(r"^## Suggested PR slices\b", text, flags=re.MULTILINE) - if match is None: - return # section deleted entirely is acceptable - # section retained — body must contain the canonical one-sentence note - # and be brief (no nested ### sub-sections). - tail = text[match.end() :] - next_top = re.search(r"^## ", tail, flags=re.MULTILINE) - body = tail[: next_top.start()] if next_top else tail - assert ONE_SENTENCE_NOTE in body, "one-sentence note missing from retained section" - assert "### " not in body, "retained section should have no sub-headings" diff --git a/packages/mechdsl-core/tests/plan_tests/test_p1_9.py b/packages/mechdsl-core/tests/plan_tests/test_p1_9.py deleted file mode 100644 index c4b8ccd..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p1_9.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -PLAN = Path(__file__).resolve().parents[4] / "dev" / "plans" / "recovery_plan_latex_contract.md" -APPENDED_ROW = ( - "- [ ] All canonical task IDs in `dev/tasks/recovery_plan_latex_contract/json/` " - "reach `done` status, with their corresponding GitHub issues closed." -) - - -def _success_block() -> str: - text = PLAN.read_text(encoding="utf-8") - match = re.search(r"^## Success criteria\b.*?(?=^## )", text, flags=re.MULTILINE | re.DOTALL) - assert match, "Success criteria section missing" - return match.group(0) - - -class TestTaskP1_9: - """ - Tests for Task P1-9: Update success criteria checklist with canonical-IDs row - Acceptance criteria covered: 1, 2, 3 - """ - - @pytest.mark.audit - def test_new_checkbox_appended_at_end(self) -> None: - block = _success_block() - rows = re.findall(r"^- \[ \] .+$", block, flags=re.MULTILINE) - assert rows, "no checklist rows found in Success criteria" - assert rows[-1] == APPENDED_ROW, f"appended row is not last; last row is: {rows[-1]!r}" - - @pytest.mark.audit - def test_existing_checkbox_rows_unchanged(self) -> None: - block = _success_block() - # the original 9 rows + 1 appended = 10 total - rows = re.findall(r"^- \[ \] .+$", block, flags=re.MULTILINE) - assert len(rows) == 10, f"expected 10 checklist rows after append, found {len(rows)}" - # spot-check a representative original row remains - expected_original = ( - "- [ ] `mechdsl-core` exposes a canonical `compile_latex(...)` or equivalent façade." - ) - assert expected_original in block, "original first checkbox row was modified or removed" - - @pytest.mark.audit - def test_new_row_references_recovery_plan_json_dir(self) -> None: - block = _success_block() - assert "dev/tasks/recovery_plan_latex_contract/json/" in block, ( - "appended row missing the verbatim path reference" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/test_p2_1.py b/packages/mechdsl-core/tests/plan_tests/test_p2_1.py deleted file mode 100644 index 9e26817..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p2_1.py +++ /dev/null @@ -1,51 +0,0 @@ -"""P2-1 verification — skim of amended recovery plan. - -Phase-2 verification of the back2latex plan reuses the live Phase-1 audit -suite as its evidence base: the seven structural checks listed in -back2latex.md verification step 1 are exactly what test_p1_{1..9} already -assert. This file simply imports those modules to confirm the suite is -importable and well-formed; the suite itself is exercised by the audit -selector in CI. -""" - -from __future__ import annotations - -import pytest - -P1_MODULES = [f"packages.mechdsl_core.tests.plan_tests.test_p1_{n}" for n in range(1, 10)] - - -class TestTaskP2_1: - """ - Tests for Task P2-1: Skim verification of amended recovery plan - Acceptance criteria covered: structural checks per back2latex.md step 1 - """ - - @pytest.mark.audit - def test_phase1_audit_suite_importable(self) -> None: - # mirror the on-disk layout (`tests/plan_tests/test_p1_*.py`) so importing - # by file works from the test directory. - from pathlib import Path - - plan_tests_dir = Path(__file__).parent - for n in range(1, 10): - f = plan_tests_dir / f"test_p1_{n}.py" - assert f.is_file(), f"missing Phase-1 audit module {f}" - - @pytest.mark.audit - def test_recovery_plan_present_for_skim(self) -> None: - from pathlib import Path - - plan = ( - Path(__file__).resolve().parents[4] - / "dev" - / "plans" - / "recovery_plan_latex_contract.md" - ) - assert plan.is_file(), f"recovery plan missing: {plan}" - # quick sanity: the seven phase headings are still in place - text = plan.read_text(encoding="utf-8") - import re - - hits = re.findall(r"^## Phase \d+ — ", text, flags=re.MULTILINE) - assert len(hits) == 7, f"expected 7 integer phase headings, found {len(hits)}" diff --git a/packages/mechdsl-core/tests/plan_tests/test_p2_2.py b/packages/mechdsl-core/tests/plan_tests/test_p2_2.py deleted file mode 100644 index 7f6d9ba..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p2_2.py +++ /dev/null @@ -1,88 +0,0 @@ -"""P2-2 verification — recursive `/Aut_Faciam tasks` on the recovery plan. - -P2-2 is integration-tier; its evidence is the existence and shape of the -artifacts that Plan-2-Tasks produces under -`dev/tasks/recovery_plan_latex_contract/`. The tests below skip until that -directory exists, then assert on the expected layout. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[4] -RP_TASKS = REPO_ROOT / "dev" / "tasks" / "recovery_plan_latex_contract" -RP_TRACKER = REPO_ROOT / "dev" / "tracking" / "tasks-tracker_recovery_plan_latex_contract.md" - - -def _require_artifacts() -> None: - if not RP_TASKS.is_dir(): - pytest.skip( - "recovery_plan_latex_contract task tree not yet generated — run " - "/Aut_Faciam tasks dev/plans/recovery_plan_latex_contract.md" - ) - - -class TestTaskP2_2: - """ - Tests for Task P2-2: Run /Aut_Faciam tasks on amended recovery plan - Acceptance criteria covered: 1, 2, 3, 4, 5 - """ - - @pytest.mark.integration - def test_all_tasks_md_row_count(self) -> None: - _require_artifacts() - all_tasks = (RP_TASKS / "all-tasks.md").read_text(encoding="utf-8") - rows = [ - line - for line in all_tasks.splitlines() - if line.startswith("| P") and not line.startswith("|---") - ] - # Recovery plan decomposes to 6+6+5+5+5+5+6 = 38 rows after the P1-6 - # supersession task lands; allow a small window around that target. - assert 30 <= len(rows) <= 42, f"expected 30-42 task rows in all-tasks.md, found {len(rows)}" - - @pytest.mark.integration - def test_one_json_per_task_with_required_fields(self) -> None: - _require_artifacts() - json_dir = RP_TASKS / "json" - assert json_dir.is_dir(), "json/ directory missing" - files = sorted(json_dir.glob("P*.json")) - assert files, "no task JSONs found" - required_keys = ("objective", "scope", "acceptance_criteria") - for f in files: - data = json.loads(f.read_text(encoding="utf-8")) - for key in required_keys: - assert data.get(key), f"{f.name} has empty {key}" - tier = data.get("test_plan", {}).get("tier") - assert tier, f"{f.name} has empty test_plan.tier" - - @pytest.mark.integration - def test_seven_phase_context_summaries(self) -> None: - _require_artifacts() - for n in range(1, 8): - ctx = RP_TASKS / f"Phase_{n}_context_summary.md" - assert ctx.is_file(), f"missing {ctx.name}" - - @pytest.mark.integration - def test_tracker_file_present(self) -> None: - _require_artifacts() - assert RP_TRACKER.is_file(), f"recovery-plan tracker missing: {RP_TRACKER}" - - @pytest.mark.integration - def test_github_issue_map_populated(self) -> None: - _require_artifacts() - gh_map = RP_TASKS / "github_issue_map.json" - if not gh_map.is_file(): - pytest.skip("github_issue_map.json not generated (gh integration deferred)") - data = json.loads(gh_map.read_text(encoding="utf-8")) - assert data.get("plan_overview_issue"), "plan_overview_issue missing" - phases = data.get("phases", {}) - assert all(str(n) in phases for n in range(1, 8)), ( - f"expected phases 1..7 in issue map, got {sorted(phases)}" - ) - for n in range(1, 8): - assert phases[str(n)].get("issue_number"), f"phase {n} issue_number missing" diff --git a/packages/mechdsl-core/tests/plan_tests/test_p2_3.py b/packages/mechdsl-core/tests/plan_tests/test_p2_3.py deleted file mode 100644 index 3c74210..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p2_3.py +++ /dev/null @@ -1,104 +0,0 @@ -"""P2-3 verification — spot-check three recovery-plan task JSONs. - -P2-3 inspects the JSONs Plan-2-Tasks produced from the amended recovery -plan. Spot-check by *content*, not just by ID: in case Plan-2-Tasks -splits or merges Phase 3 differently, the ProblemIR-enrichment task may -not literally be `P3-1`. -""" - -from __future__ import annotations - -import json -import re -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[4] -RP_JSON = REPO_ROOT / "dev" / "tasks" / "recovery_plan_latex_contract" / "json" - -STATUS_VOCAB = {"done", "deferred", "implemented-via-substitute", "not_started"} - - -def _require_jsons() -> list[Path]: - if not RP_JSON.is_dir(): - pytest.skip( - "recovery_plan_latex_contract/json/ not yet generated — run " - "/Aut_Faciam tasks dev/plans/recovery_plan_latex_contract.md" - ) - files = sorted(RP_JSON.glob("P*.json")) - if not files: - pytest.skip("recovery_plan_latex_contract/json/ has no P*.json files yet") - return files - - -def _flatten(data: object) -> str: - """Cheap stringification of a JSON document for substring checks.""" - return json.dumps(data, ensure_ascii=False) - - -class TestTaskP2_3: - """ - Tests for Task P2-3: Spot-check three generated task JSONs - Acceptance criteria covered: 1, 2, 3, 4, 5 - """ - - @pytest.mark.audit - def test_status_vocabulary_task_present(self) -> None: - files = _require_jsons() - # find the Phase-1 task whose acceptance/implementation mentions all four - # status values (the recovery-plan namespace's tracker-vocabulary task). - for f in files: - if not f.name.startswith("P1-"): - continue - blob = _flatten(json.loads(f.read_text(encoding="utf-8"))) - if all(v in blob for v in STATUS_VOCAB): - return - pytest.fail( - f"no Phase-1 recovery-plan task references all four status values " - f"{sorted(STATUS_VOCAB)}" - ) - - @pytest.mark.audit - def test_compile_latex_facade_task_present(self) -> None: - files = _require_jsons() - # find the Phase-2 task whose deliverables list mechdsl/__init__.py and - # frontend/__init__.py and whose acceptance mentions LaTeX-string entry. - for f in files: - if not f.name.startswith("P2-"): - continue - data = json.loads(f.read_text(encoding="utf-8")) - deliverables = " ".join(data.get("deliverables", [])) - accept = " ".join(data.get("acceptance_criteria", [])) - if ( - "mechdsl/__init__.py" in deliverables - and "frontend/__init__.py" in deliverables - and re.search(r"latex", accept, flags=re.IGNORECASE) - ): - return - pytest.fail( - "no Phase-2 recovery-plan task lists both __init__ files in deliverables " - "with a LaTeX-string acceptance criterion" - ) - - @pytest.mark.audit - def test_problem_ir_serialization_task_present(self) -> None: - files = _require_jsons() - # find the Phase-3 task whose deliverables include mechanics_ir.py and - # whose implementation_steps explicitly add to_dict/from_dict to ProblemIR. - for f in files: - if not f.name.startswith("P3-"): - continue - data = json.loads(f.read_text(encoding="utf-8")) - deliverables = " ".join(data.get("deliverables", [])) - steps = " ".join(data.get("implementation_steps", [])) - if ( - "mechanics_ir.py" in deliverables - and "ProblemIR" in steps - and ("to_dict" in steps or "from_dict" in steps) - ): - return - pytest.fail( - "no Phase-3 recovery-plan task lists mechanics_ir.py in deliverables " - "with ProblemIR.to_dict/from_dict in implementation_steps" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/test_p2_4.py b/packages/mechdsl-core/tests/plan_tests/test_p2_4.py deleted file mode 100644 index 1aefca3..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p2_4.py +++ /dev/null @@ -1,83 +0,0 @@ -"""P2-4 verification — recursive `/Aut_Faciam scaffold 1` on the recovery plan. - -Evidence: scaffold artifacts under `dev/tasks/recovery_plan_latex_contract/` -plus the GH-side state recorded in `github_issue_map.json`. Skipped until -those artifacts exist. - -Hard-stop invariant: this test does NOT run /Aut_Faciam exec; the test -only inspects the *outputs* of scaffold to confirm exec was not invoked. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[4] -RP_TASKS = REPO_ROOT / "dev" / "tasks" / "recovery_plan_latex_contract" - - -def _require_scaffold() -> dict: - gates_file = RP_TASKS / "gates" / "phase_1_gates.md" - if not gates_file.is_file(): - pytest.skip( - "recovery-plan Phase 1 not yet scaffolded — run " - "/Aut_Faciam scaffold 1 dev/plans/recovery_plan_latex_contract.md" - ) - gh_map_file = RP_TASKS / "github_issue_map.json" - if not gh_map_file.is_file(): - pytest.skip("recovery-plan github_issue_map.json missing") - return json.loads(gh_map_file.read_text(encoding="utf-8")) - - -class TestTaskP2_4: - """ - Tests for Task P2-4: Run /Aut_Faciam scaffold 1 on recovery plan and stop - Acceptance criteria covered: 1, 2, 3, 4, 5 - """ - - @pytest.mark.integration - def test_phase1_gates_file_exists(self) -> None: - _require_scaffold() - gates_file = RP_TASKS / "gates" / "phase_1_gates.md" - body = gates_file.read_text(encoding="utf-8") - assert body.strip(), "phase_1_gates.md is empty" - - @pytest.mark.integration - def test_six_task_issues_recorded_for_phase1(self) -> None: - gh_map = _require_scaffold() - phase1 = gh_map.get("phases", {}).get("1", {}) - task_issues = phase1.get("task_issues", {}) - assert len(task_issues) == 6, ( - f"expected 6 recovery-plan Phase-1 task issues (P1-1..P1-6), " - f"found {len(task_issues)}: {list(task_issues)}" - ) - - @pytest.mark.integration - def test_completion_data_consistent_with_status(self) -> None: - """Post-exec invariant: a task has a ``completion_date`` iff ``status == 'done'``. - - History: this test originally enforced a *hard stop* that no - ``/Aut_Faciam exec`` had run against the recovery plan during the - back2latex P2-4 engagement. That invariant was honored at back2latex - commit time and is recorded in - ``dev/tasks/back2latex/gates/phase_2_gates.md``. Recovery-plan exec is - now authorized in subsequent engagements; the assertion was relaxed to - a data-integrity check that survives across exec phases. - """ - _require_scaffold() - json_dir = RP_TASKS / "json" - if not json_dir.is_dir(): - pytest.skip("json/ not present yet") - for f in sorted(json_dir.glob("P*.json")): - data = json.loads(f.read_text(encoding="utf-8")) - status = data.get("status") - completion = data.get("completion_date") - if status == "done": - assert completion, f"{f.name} status=done but completion_date empty" - elif status in ("", "pending"): - assert not completion, ( - f"{f.name} status={status!r} but completion_date={completion!r} (should be empty)" - ) diff --git a/packages/mechdsl-core/tests/plan_tests/test_p3_1.py b/packages/mechdsl-core/tests/plan_tests/test_p3_1.py deleted file mode 100644 index ce062f3..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p3_1.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Tests for Task P3-1 (PlanJune14 Phase 3). - -Route the ``local_tangent`` einsum through the Layer-4b optimizer: -``lowering/einsum_extract.py`` -> ``codegen/einsum_optimizer.py`` -> -a :class:`~mechdsl.codegen.artifact.ContractionPlan` for the matrix-free -tangent matvec ``K(u)·v``, passing the JIT-budget counter. The contraction is -optimiser-produced, not hand-rolled. - -Design note (P3-1): the matvec is exposed as a *dedicated builder* -(:func:`build_tangent_matvec_plan`) rather than a fourth key in -``extract_einsum_specs``. The three-key contract of ``extract_einsum_specs`` -is pinned by existing tests/goldens; adding a key there would break them. -The builder derives the matvec einsum ``qaI,qiIjJ,qbJ,bj->qai`` from the same -ElementIR geometry and routes it through ``optimize_contraction``. -""" - -from __future__ import annotations - -import numpy as np -import opt_einsum -import pytest - -from mechdsl.codegen.artifact import ContractionPlan -from mechdsl.codegen.einsum_optimizer import MAX_LINES_TI_FUNC, Tier, estimate_unrolled_lines -from mechdsl.ir.element_ir import create_hex8_element_ir -from mechdsl.lowering.einsum_extract import ( - TANGENT_MATVEC_APPLY_EINSUM, - build_tangent_matvec_plan, - extract_einsum_specs, - tangent_matvec_apply_spec, -) - -# --------------------------------------------------------------------------- -# Helpers — concrete SVK Hex8 arrays for the numeric AC-3 test -# --------------------------------------------------------------------------- - - -def _svk_material_tangent(lam: float, mu: float) -> np.ndarray: - """St. Venant–Kirchhoff reference material tangent A_{iIjJ}. - - For SVK the constant fourth-order elasticity tensor in index form is - ``C_{IJKL} = lam delta_{IJ} delta_{KL} + mu (delta_{IK} delta_{JL} - + delta_{IL} delta_{JK})``. We use it directly as the consistent tangent - block ``A(i=I, I, j=K, J=L)`` so the matvec test exercises a physically - meaningful, symmetric tangent rather than noise. - """ - d = np.eye(3) - # C_{IJKL} - c = ( - lam * np.einsum("ij,kl->ijkl", d, d) - + mu * np.einsum("ik,jl->ijkl", d, d) - + mu * np.einsum("il,jk->ijkl", d, d) - ) - return c - - -def _svk_hex8_arrays(seed: int = 0) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Concrete ``(dN, A, v)`` arrays for an SVK Hex8 element. - - Returns - ------- - dN : (n_qp, n_nodes, dim) - Reference shape-function gradients at each quadrature point. - A : (n_qp, dim, dim, dim, dim) - The (constant, per-qp broadcast) SVK material tangent. - v : (n_nodes, dim) - A random direction field. - """ - element_ir = create_hex8_element_ir() - n_qp = element_ir.quadrature.n_points - n_nodes = element_ir.n_nodes - dim = element_ir.dim - - # Real Hex8 reference-gradient table dN(q,a,I). - dN = np.empty((n_qp, n_nodes, dim), dtype=np.float64) - for q, (xi, eta, zeta) in enumerate(element_ir.quadrature.points): - dN[q] = element_ir.basis.gradient(xi, eta, zeta) - - lam, mu = 1.15, 0.77 # arbitrary Lamé constants (kPa-scale) - a_single = _svk_material_tangent(lam, mu) - A = np.broadcast_to(a_single, (n_qp, dim, dim, dim, dim)).copy() - - rng = np.random.default_rng(seed) - v = rng.standard_normal((n_nodes, dim)) - return dN, A, v - - -class TestTaskP3_1: - """Tests for Task P3-1: tangent contraction through the einsum optimizer. AC 1-3.""" - - @pytest.mark.unit - def test_einsum_extract_returns_local_tangent_string(self): - """AC-1: einsum_extract yields the expected local_tangent subscripts for SVK Hex8.""" - element_ir = create_hex8_element_ir() - - # The full element tangent K(q,a,i,b,j) — the local_tangent contraction. - specs = extract_einsum_specs(element_ir) - assert specs["tangent_matvec"].einsum_string == "qaI,qiIjJ,qbJ->qaibj" - - # The matrix-free matvec view folds v(b,j) into that contraction. - matvec_spec = tangent_matvec_apply_spec(element_ir) - assert matvec_spec.einsum_string == "qaI,qiIjJ,qbJ,bj->qai" - assert matvec_spec.einsum_string == TANGENT_MATVEC_APPLY_EINSUM - # Operand order is (dN, A, dN, v); result is the per-qp Kv(q,a,i). - assert matvec_spec.operand_shapes == ( - (8, 8, 3), - (8, 3, 3, 3, 3), - (8, 8, 3), - (8, 3), - ) - assert matvec_spec.result_shape == (8, 8, 3) - - @pytest.mark.unit - def test_optimizer_yields_contraction_plan_within_budget(self): - """AC-2: the optimizer produces a ContractionPlan that passes the JIT-budget counter.""" - element_ir = create_hex8_element_ir() - plan = build_tangent_matvec_plan(element_ir) - - # It is an actual ContractionPlan carrying the opt_einsum path. - assert isinstance(plan, ContractionPlan) - assert plan.einsum_string == TANGENT_MATVEC_APPLY_EINSUM - assert len(plan.contraction_path) >= 1 # path recorded, not hand-rolled - - # Re-run the budget counter over the recorded path: must be within the - # 512-line @ti.func budget and Tier <= 2 (no Tier-3 restructuring). - matvec_spec = tangent_matvec_apply_spec(element_ir) - lines = estimate_unrolled_lines( - matvec_spec.einsum_string, - list(matvec_spec.operand_shapes), - plan.contraction_path, - ) - assert lines <= MAX_LINES_TI_FUNC - assert plan.tier <= int(Tier.TIER_2) - - @pytest.mark.unit - def test_matvec_contraction_equals_dense_tangent_applied_to_v(self): - """AC-3: the matvec contraction numerically equals the dense local_tangent applied to v.""" - element_ir = create_hex8_element_ir() - dN, A, v = _svk_hex8_arrays(seed=0) - - # Ground truth: form the full element tangent K(q,a,i,b,j) then apply v. - k_full = np.einsum("qaI,qiIjJ,qbJ->qaibj", dN, A, dN) - kv_dense = np.einsum("qaibj,bj->qai", k_full, v) - - # Optimiser path: contract directly via the recorded opt_einsum path. - plan = build_tangent_matvec_plan(element_ir) - kv_opt = opt_einsum.contract( - TANGENT_MATVEC_APPLY_EINSUM, - dN, - A, - dN, - v, - optimize=plan.contraction_path, - ) - - assert kv_opt.shape == (8, 8, 3) - assert np.max(np.abs(kv_opt - kv_dense)) < 1e-12 diff --git a/packages/mechdsl-core/tests/plan_tests/test_p3_2.py b/packages/mechdsl-core/tests/plan_tests/test_p3_2.py deleted file mode 100644 index fe456cb..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p3_2.py +++ /dev/null @@ -1,305 +0,0 @@ -"""Tests for Task P3-2 (PlanJune14 Phase 3). - -The Taichi printer now emits a generated ``@ti.kernel`` matrix-free SVK tangent -operator (``svk_tangent_matvec_apply``) **alongside** the legacy host-NumPy -``tangent_matvec``. The generated kernel applies ``K(u)·v`` fully matrix-free -(D-A: element tangents never stored), forms the consistent two-point tangent -``A(i,I,j,J)`` per quadrature point from the Tier-1 ``ti_runtime`` helpers, and -routes the tangent contraction through the **P3-1 opt_einsum ContractionPlan** -(``qaI,qiIjJ,qbJ,bj->qai``, path ``[(2,3),(1,2),(0,1)]``) — not a hand-rolled -contraction. It targets the ``ti_runtime`` ``apply_A(out, x)`` injection seam. - -Acceptance (P3-2): - -* AC-1: generated tangent matvec matches ``tests/ref/ref_hex8_elastic`` to <1e-10. -* AC-2: the generated tangent passes the JIT-budget counter (≤512 lines/@ti.func). -* AC-3: the generated operator injects into the ti_runtime seam and drives a - Newton solve (matches the reference solve to <1e-10). -* AC-4: the tangent matvec consumes the opt_einsum ContractionPlan (the recorded - optimiser path appears in the emitted source; no hand-rolled einsum). -""" - -from __future__ import annotations - -import numpy as np -import pytest - -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.codegen.einsum_optimizer import ( - MAX_LINES_ABSOLUTE, - MAX_LINES_TI_FUNC, - MAX_LINES_TI_KERNEL, - Tier, - estimate_unrolled_lines, -) -from mechdsl.codegen.taichi_printer import emit -from mechdsl.ir.element_ir import create_hex8_element_ir -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.einsum_extract import ( - TANGENT_MATVEC_APPLY_EINSUM, - build_tangent_matvec_plan, - tangent_matvec_apply_spec, -) -from mechdsl.lowering.fe_localise import localise_and_optimize -from tests._e2e_helpers import _import_generated_module -from tests.ref.ref_hex8_elastic import ( - element_tangent_matvec, - generate_hex8_mesh, - solve_elastic, -) - -# Steel-like SVK (matches test_pj1_svk_spike.py / test_ref_elastic.py). -_E_YOUNG = 200.0e3 -_NU = 0.3 -_LAM = _E_YOUNG * _NU / ((1 + _NU) * (1 - 2 * _NU)) -_MU = _E_YOUNG / (2 * (1 + _NU)) - -# Generated-vs-reference tolerance (PlanJune14 / 07-CONVENTIONS §6). -_GATE_TOL = 1e-10 - - -def _make_svk_source() -> str: - """Emit the SVK Taichi solver source (carries the generated kernel).""" - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": _E_YOUNG, "nu": _NU}), - boundaries=(BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET),), - ) - loc_result, plans = localise_and_optimize(problem_ir) - bundle = ArtifactBundle.from_pipeline(problem_ir, loc_result, plans) - return emit(bundle) - - -class TestTaskP3_2: - """Tests for Task P3-2: generated @ti.kernel matrix-free tangent. AC 1-4.""" - - @pytest.mark.slow - def test_generated_tangent_matches_reference_to_1e_10(self, tmp_path): - """AC-1: generated @ti.kernel tangent matvec matches ref_hex8_elastic to <1e-10.""" - import taichi as ti - - source = _make_svk_source() - mod = _import_generated_module(source, tmp_path, name="gen_p3_2_matvec") - assert hasattr(mod, "svk_tangent_matvec_apply"), ( - "generated module is missing the P3-2 @ti.kernel matrix-free tangent" - ) - - # Small 2x1x1 mesh, finite displacement, random direction. - coords, conn = generate_hex8_mesh(2, 1, 1, 2.0, 1.0, 1.0) - n_nodes = coords.shape[0] - n_elem = conn.shape[0] - rng = np.random.default_rng(7) - u_np = np.zeros((n_nodes, 3)) - u_np[:, 0] = 0.04 * coords[:, 0] - u_np[:, 1] = -0.01 * coords[:, 1] - v_np = rng.standard_normal((n_nodes, 3)) * 1e-2 - - mod.allocate_fields(n_nodes, n_elem) - mod.x_ref.from_numpy(coords) - mod.elem_nodes.from_numpy(conn.astype(np.int32)) - mod.u.from_numpy(u_np) - - out = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - v_field = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - v_field.from_numpy(v_np) - - # Generated matrix-free tangent matvec: out = K(u) · v. - mod.svk_tangent_matvec_apply(out, v_field, _LAM, _MU) - kv_gen = out.to_numpy() - - # Reference: assemble the handwritten element tangent matvec. - kv_ref = np.zeros((n_nodes, 3)) - for e in range(n_elem): - nodes = conn[e] - kv_e = element_tangent_matvec(u_np[nodes], coords[nodes], v_np[nodes], _LAM, _MU) - for a in range(8): - kv_ref[nodes[a]] += kv_e[a] - - max_diff = float(np.max(np.abs(kv_gen - kv_ref))) - assert max_diff < _GATE_TOL, ( - f"generated matvec differs from reference: max|Kv_gen - Kv_ref| = " - f"{max_diff:.3e} >= {_GATE_TOL:.0e}" - ) - - @pytest.mark.unit - def test_generated_tangent_passes_jit_budget_counter(self): - """AC-2: the generated tangent contraction stays ≤512 lines/@ti.func (budget counter).""" - element_ir = create_hex8_element_ir() - plan = build_tangent_matvec_plan(element_ir) - spec = tangent_matvec_apply_spec(element_ir) - - # Re-run the budget counter over the recorded optimiser path: the - # tangent contraction the generated kernel emits must fit the - # 512-line @ti.func budget and be Tier <= 2 (no Tier-3 restructuring). - lines = estimate_unrolled_lines( - spec.einsum_string, - list(spec.operand_shapes), - plan.contraction_path, - ) - assert lines <= MAX_LINES_TI_FUNC, ( - f"generated tangent contraction is {lines} lines > {MAX_LINES_TI_FUNC} @ti.func budget" - ) - assert plan.tier <= int(Tier.TIER_2) - - def test_full_svk_kernel_within_kernel_and_absolute_budget(self): - """The FULL emitted ``svk_tangent_matvec_apply`` @ti.kernel honours BOTH - the 2000 ``@ti.kernel`` budget and the 5000 absolute ceiling — the honest - budget test (PlanJune14 WI-1). - - The original AC-2 budget test only gated the inner contraction - ``@ti.func``; it never measured the full kernel, which (with a - ``ti.static`` N_QP=8 q-loop) ran ~5311 unrolled lines — over the absolute - ceiling. WI-1's runtime-q lever divides the per-QP unroll by 8, bringing - the full SVK kernel to ~678 unrolled, comfortably under both limits. The - count uses the project's "unrolled lines" weighting (07-CONVENTIONS - §JIT-budget) via :func:`count_unrolled_kernel_lines`. - """ - from tests._e2e_helpers import count_unrolled_kernel_lines - - source = _make_svk_source() - unrolled = count_unrolled_kernel_lines(source, "svk_tangent_matvec_apply") - assert unrolled <= MAX_LINES_TI_KERNEL, ( - f"full svk_tangent_matvec_apply is {unrolled} unrolled lines > " - f"{MAX_LINES_TI_KERNEL} @ti.kernel budget" - ) - assert unrolled <= MAX_LINES_ABSOLUTE, ( - f"full svk_tangent_matvec_apply is {unrolled} unrolled lines > " - f"{MAX_LINES_ABSOLUTE} absolute ceiling" - ) - - @pytest.mark.slow - def test_generated_operator_drives_newton_via_seam(self, tmp_path): - """AC-3: the generated operator injects into the ti_runtime seam and drives a Newton solve.""" - import taichi as ti - - # Reuse the PJ-1 spike's PCG body + Dirichlet seam kernels: the generated - # operator is interchangeable with the spike's hand-written one, which is - # the whole point of P3-2 (production replacement of the spike operator). - from tests.spike.svk_hex8_taichi import ( - _apply_dirichlet, - _mask_free, - _PCGWorkspace, - _set_constrained, - pcg, - ) - from ti_runtime import vector_ops as vops - from ti_runtime.seams import IdentityPreconditioner, LinearSolveContext - - source = _make_svk_source() - mod = _import_generated_module(source, tmp_path, name="gen_p3_2_newton") - - # Uniaxial-stretch single Hex8 patch (the PJ-1 gate problem). - coords, conn = generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - n_nodes = coords.shape[0] - n_elem = conn.shape[0] - bc_mask = np.zeros((n_nodes, 3), dtype=bool) - bc_values = np.zeros((n_nodes, 3), dtype=np.float64) - left = np.abs(coords[:, 0]) < 1e-12 - right = np.abs(coords[:, 0] - 1.0) < 1e-12 - bc_mask[left, :] = True - bc_mask[right, 0] = True - bc_values[right, 0] = 0.1 - f_ext_np = np.zeros((n_nodes, 3), dtype=np.float64) - - # Reference solve (handwritten NumPy Newton + ScipyCG). - u_ref, res_ref = solve_elastic( - coords, - conn, - _LAM, - _MU, - bc_mask, - bc_values, - f_ext_np, - tol=1e-10, - cg_tol=1e-12, - ) - assert len(res_ref) >= 2, "expected a nonlinear (Newton-iterated) solve" - - # Allocate the generated module's fields and load the mesh + BCs. - mod.allocate_fields(n_nodes, n_elem) - mod.x_ref.from_numpy(coords) - mod.elem_nodes.from_numpy(conn.astype(np.int32)) - mod.f_ext.from_numpy(f_ext_np) - - free = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - bc_val = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - free.from_numpy((~bc_mask).astype(np.float64)) - bc_val.from_numpy(bc_values) - - # Inject the GENERATED operator into the ti_runtime apply_A seam. - v_bc = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - - def apply_A(out_field, x_field): - _mask_free(v_bc, x_field, free) # zero constrained DOFs of the direction - mod.svk_tangent_matvec_apply(out_field, v_bc, _LAM, _MU) - _set_constrained(out_field, x_field, free) # identity rows on constrained DOFs - - ctx = LinearSolveContext() - ctx.set_operator(apply_A) - ctx.set_preconditioner(IdentityPreconditioner()) - - # Thin Newton loop driven by the generated internal-force kernel + the - # generated tangent operator through the seam. - resid = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - du = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - ws = _PCGWorkspace.alloc(n_nodes) - _apply_dirichlet(mod.u, free, bc_val) - - r0 = None - for _ in range(50): - mod.compute_internal_force(_LAM, _MU) - vops.copy(resid, mod.f_ext) - vops.axpy(resid, -1.0, mod.f_int) # R = f_ext - f_int - _mask_free(resid, resid, free) - r_norm = vops.norm2(resid) - if r0 is None: - r0 = r_norm - if r_norm < 1e-10 * r0: - break - du.fill(0.0) - pcg(ctx, ws, resid, du, 1e-12, 2000) - _mask_free(du, du, free) - vops.axpy(mod.u, 1.0, du) - else: - pytest.fail("generated-operator Newton solve did not converge") - - u_gen = mod.u.to_numpy() - assert np.max(np.abs(u_gen)) > 1e-3, "expected a nonzero converged displacement" - max_diff = float(np.max(np.abs(u_gen - u_ref))) - assert max_diff < _GATE_TOL, ( - f"generated-operator solve differs from reference: " - f"max|u_gen - u_ref| = {max_diff:.3e} >= {_GATE_TOL:.0e}" - ) - - @pytest.mark.unit - def test_generated_tangent_routes_through_optimizer_not_handrolled(self): - """AC-4: the tangent matvec consumes the opt_einsum ContractionPlan (no hand-rolled contraction).""" - source = _make_svk_source() - - # The generated kernel must exist and target the ti_runtime seam. - assert "def svk_tangent_matvec_apply(" in source - assert "from ti_runtime import tensor_ti as _tt" in source - - # The optimiser-recorded path must be embedded in the emitted source — - # the kernel realises THIS path, it does not hand-roll a contraction. - element_ir = create_hex8_element_ir() - plan = build_tangent_matvec_plan(element_ir) - assert plan.einsum_string == TANGENT_MATVEC_APPLY_EINSUM - assert str(list(plan.contraction_path)) in source, ( - "the emitted kernel must embed the opt_einsum ContractionPlan path " - f"{list(plan.contraction_path)}" - ) - assert plan.einsum_string in source - - # The three recorded pairwise steps are realised as step comments, proving - # the emission is path-driven rather than a single hand-written einsum. - assert "step 1" in source and "step 2" in source and "step 3" in source diff --git a/packages/mechdsl-core/tests/plan_tests/test_p4_1.py b/packages/mechdsl-core/tests/plan_tests/test_p4_1.py deleted file mode 100644 index 0fd3d7d..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p4_1.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Tests for Task P4-1 (PlanJune14 Phase 4). - -Generated Jacobi (point-diagonal) preconditioner from `dev/algorithms/jacobi.tex`, -realised via `mechdsl.solver.jacobi_preconditioner.GeneratedJacobiPreconditioner` -(the `ti_runtime.vector_ops.ediv` primitive + the `PreconditionerBase` seam) and -injected through `ti_runtime` `set_preconditioner`. All-Taichi (Option 1) — no -NumPy in the apply hot path. - -Grammar-gap note: algo2code cannot yet express elementwise vector divide -(`z = r / d`) for `ti.Vector.field`; the body is the authorized minimal fallback -(a `@ti.kernel` `ediv` wired to the seam), with `jacobi.tex` as the LaTeX source. -""" - -# NOTE: no `from __future__ import annotations` — these tests define a @ti.kernel -# whose ti.template() annotations Taichi must evaluate eagerly (PEP 563 breaks JIT). - -import numpy as np -import pytest -import taichi as ti - -from mechdsl.solver.jacobi_preconditioner import GeneratedJacobiPreconditioner -from ti_runtime import vector_ops as vops -from ti_runtime.seams import ( - DiagonalPreconditioner, - IdentityPreconditioner, - LinearSolveContext, -) - -pytestmark = pytest.mark.slow # executes Taichi kernels (JIT) - - -def _vfield(vals: np.ndarray): - vals = np.ascontiguousarray(vals, dtype=np.float64) - f = ti.Vector.field(vals.shape[1], ti.f64, shape=vals.shape[0]) - f.from_numpy(vals) - return f - - -class TestTaskP4_1: - """Tests for Task P4-1: generated Jacobi preconditioner. AC 1-3.""" - - def test_generated_jacobi_equals_diagonal_preconditioner(self): - """AC-1: generated Jacobi M^{-1}r == ti_runtime DiagonalPreconditioner on a known diagonal.""" - ti.init(arch=ti.cpu, default_fp=ti.f64) - rng = np.random.default_rng(3) - rv = rng.standard_normal((4, 3)) - dv = np.abs(rng.standard_normal((4, 3))) + 0.5 # strictly positive diagonal - - r = _vfield(rv) - d_gen = _vfield(dv) - d_ref = _vfield(dv) - z_gen = ti.Vector.field(3, ti.f64, shape=4) - z_ref = ti.Vector.field(3, ti.f64, shape=4) - - GeneratedJacobiPreconditioner(diag=d_gen, eps=1e-12).apply(z_gen, r) - DiagonalPreconditioner(d_ref, eps=1e-12).apply(z_ref, r) - - np.testing.assert_allclose(z_gen.to_numpy(), z_ref.to_numpy(), rtol=1e-12, atol=1e-14) - # And it is genuinely M^{-1} r = r / d. - np.testing.assert_allclose(z_gen.to_numpy(), rv / dv, rtol=1e-12) - - def test_generated_jacobi_injects_via_set_preconditioner(self): - """AC-2: the generated body injects via set_preconditioner and apply_preconditioner(z, r) works.""" - ti.init(arch=ti.cpu, default_fp=ti.f64) - rv = np.array([[2.0, 4.0, 6.0], [8.0, 10.0, 12.0]]) - dv = np.array([[2.0, 4.0, 8.0], [4.0, 5.0, 6.0]]) - r = _vfield(rv) - d = _vfield(dv) - z = ti.Vector.field(3, ti.f64, shape=2) - - ctx = LinearSolveContext().set_preconditioner(GeneratedJacobiPreconditioner(diag=d)) - ctx.apply_preconditioner(z, r) - - np.testing.assert_allclose(z.to_numpy(), rv / dv, rtol=1e-12) - - def test_jacobi_reduces_pcg_iterations_vs_identity(self): - """AC-3: PCG with the generated Jacobi converges in fewer iters than identity (conditioned SPD).""" - from tests.spike.svk_hex8_taichi import _PCGWorkspace, pcg - - ti.init(arch=ti.cpu, default_fp=ti.f64) - n = 6 - # Diagonal SPD operator A = diag(d) with 3 distinct eigenvalues {2,5,11}. - # Unpreconditioned CG converges in #distinct-eigenvalues (=3) iterations; - # Jacobi (M = A) gives M^{-1}A = I → 1 iteration. - dv = np.tile([2.0, 5.0, 11.0], (n, 1)) - diag = _vfield(dv) - - @ti.kernel - def _apply_diag_op(out: ti.template(), x: ti.template(), d: ti.template()): - for i in out: - out[i] = d[i] * x[i] - - def make_apply_A(d): - def apply_A(out, x): - _apply_diag_op(out, x, d) - - return apply_A - - rng = np.random.default_rng(11) - bv = rng.standard_normal((n, 3)) - - def _solve(precond): - b = _vfield(bv) - x = ti.Vector.field(3, ti.f64, shape=n) # zero initial guess - ws = _PCGWorkspace.alloc(n) - ctx = LinearSolveContext().set_operator(make_apply_A(diag)).set_preconditioner(precond) - iters, _res = pcg(ctx, ws, b, x, tol=1e-12, maxiter=50) - return iters, x.to_numpy() - - jac_iters, x_jac = _solve(GeneratedJacobiPreconditioner(diag=diag)) - id_iters, x_id = _solve(IdentityPreconditioner()) - - expected = bv / dv # A = diag(d) → x = b / d - np.testing.assert_allclose(x_jac, expected, atol=1e-9) - np.testing.assert_allclose(x_id, expected, atol=1e-9) - - assert jac_iters < id_iters, ( - f"Jacobi should converge in fewer iters than identity; " - f"jacobi={jac_iters}, identity={id_iters}" - ) - assert jac_iters <= 2, ( - f"Jacobi on a diagonal operator should converge ~1 iter; got {jac_iters}" - ) - - -def test_ediv_primitive_guards_near_zero_diagonal(): - """The ti_runtime ediv primitive guards against a near-zero diagonal via max(d, eps).""" - ti.init(arch=ti.cpu, default_fp=ti.f64) - r = _vfield(np.array([[1.0, 1.0, 1.0]])) - d = _vfield(np.array([[0.0, 1e-20, 2.0]])) # zero / tiny / normal - z = ti.Vector.field(3, ti.f64, shape=1) - vops.ediv(z, r, d, 1e-12) - out = z.to_numpy()[0] - assert np.isfinite(out).all(), "ediv must produce finite output on a near-zero diagonal" - assert out[0] == pytest.approx(1.0 / 1e-12) # guarded by eps - assert out[2] == pytest.approx(0.5) diff --git a/packages/mechdsl-core/tests/plan_tests/test_p4_2.py b/packages/mechdsl-core/tests/plan_tests/test_p4_2.py deleted file mode 100644 index 07dc683..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p4_2.py +++ /dev/null @@ -1,413 +0,0 @@ -"""Tests for Task P4-2 (PlanJune14 Phase 4). - -Generated matrix-free PCG (``dev/algorithms/pcg.tex``, callable operator ``A`` + -callable preconditioner ``M_inv``) transpiled via ``algo2code`` in *runtime mode* -and injected through the ``ti_runtime`` ``set_solver`` seam, solving against the -**P3-2 generated SVK tangent operator** (``set_operator`` / ``apply_A``). -All-Taichi on-device (Option 1): **no NumPy / ``.to_numpy()`` in the generated -solve hot path**. - -The generated PCG body productionizes the PJ-1 spike's hand-written ``pcg`` -(``tests/spike/svk_hex8_taichi.py``): same algorithm, but *derived from LaTeX*. -The reusable seam-solve helper lives in -``mechdsl.solver.seam_solve`` (``bind_generated_pcg_solver`` / ``build_seam_pcg``). - -Acceptance criteria covered: - AC-1 Generated PCG injected via ``set_solver`` solves the PJ-1 SVK patch to - <1e-10 over the seam, driven by the **P3-2 generated tangent operator**. - AC-2 Generated PCG matches the canonical PCG LaTeX behaviour (converged + - max-iter-exhausted paths) — vs the PJ-1 spike ``pcg`` body, on device. - AC-3 No NumPy / ``.to_numpy()`` in the generated solve hot path (source/AST - check on the transpiled body, mirroring test_pj1_svk_spike). - AC-4 The issue #307 / PCG-parity suite stays green. -""" - -# NOTE: no ``from __future__ import annotations`` — this test imports the spike -# and the generated seam PCG, both of which define @ti.kernel bodies whose -# ti.template() annotations Taichi must evaluate eagerly (PEP 563 breaks JIT). - -import ast - -import numpy as np -import pytest - -from mechdsl.solver.seam_solve import ( - bind_generated_pcg_solver, - build_seam_pcg, - transpile_seam_pcg, -) - -# Steel-like SVK (matches test_p3_2.py / test_pj1_svk_spike.py). -_E_YOUNG = 200.0e3 -_NU = 0.3 -_LAM = _E_YOUNG * _NU / ((1 + _NU) * (1 - 2 * _NU)) -_MU = _E_YOUNG / (2 * (1 + _NU)) - -# Generated-vs-reference tolerance (PlanJune14 / 07-CONVENTIONS §6). -_GATE_TOL = 1e-10 - - -def _make_svk_source() -> str: - """Emit the SVK Taichi solver source (carries the P3-2 generated kernel).""" - from mechdsl.codegen.artifact import ArtifactBundle - from mechdsl.codegen.taichi_printer import emit - from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, - ) - from mechdsl.lowering.fe_localise import localise_and_optimize - - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": _E_YOUNG, "nu": _NU}), - boundaries=(BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET),), - ) - loc_result, plans = localise_and_optimize(problem_ir) - bundle = ArtifactBundle.from_pipeline(problem_ir, loc_result, plans) - return emit(bundle) - - -class TestTaskP4_2: - """Tests for Task P4-2: generated PCG via set_solver. AC 1-4.""" - - @pytest.mark.slow - def test_generated_pcg_solves_svk_patch_via_set_solver(self, tmp_path): - """AC-1: generated PCG (set_solver) solves the SVK patch <1e-10 over the seam. - - Drives the generated PCG against the **P3-2 generated SVK tangent - operator** (``svk_tangent_matvec_apply``), injected via ``set_operator``, - with the generated PCG body injected via ``set_solver``. The whole linear - solve runs on device over ``ti.Vector.field`` DOF vectors — no NumPy in - the hot path. The Newton loop reuses the spike's Dirichlet seam kernels - (interchangeable with the generated operator — the point of P3-2/P4-2). - """ - import taichi as ti - - from tests._e2e_helpers import _import_generated_module - from tests.ref.ref_hex8_elastic import generate_hex8_mesh, solve_elastic - from tests.spike.svk_hex8_taichi import ( - _apply_dirichlet, - _mask_free, - _set_constrained, - ) - from ti_runtime import vector_ops as vops - from ti_runtime.seams import IdentityPreconditioner, LinearSolveContext - - ti.init(arch=ti.cpu, default_fp=ti.f64) - - # P3-2 generated operator module (carries svk_tangent_matvec_apply). - source = _make_svk_source() - mod = _import_generated_module(source, tmp_path, name="gen_p4_2_op") - assert hasattr(mod, "svk_tangent_matvec_apply"), ( - "generated module is missing the P3-2 @ti.kernel matrix-free tangent" - ) - - # Uniaxial-stretch single Hex8 patch (the PJ-1 gate problem). - coords, conn = generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - n_nodes = coords.shape[0] - n_elem = conn.shape[0] - bc_mask = np.zeros((n_nodes, 3), dtype=bool) - bc_values = np.zeros((n_nodes, 3), dtype=np.float64) - left = np.abs(coords[:, 0]) < 1e-12 - right = np.abs(coords[:, 0] - 1.0) < 1e-12 - bc_mask[left, :] = True - bc_mask[right, 0] = True - bc_values[right, 0] = 0.1 - f_ext_np = np.zeros((n_nodes, 3), dtype=np.float64) - - # Reference solve (handwritten NumPy Newton + ScipyCG). - u_ref, res_ref = solve_elastic( - coords, conn, _LAM, _MU, bc_mask, bc_values, f_ext_np, tol=1e-10, cg_tol=1e-12 - ) - assert len(res_ref) >= 2, "expected a nonlinear (Newton-iterated) solve" - - # Load the mesh + BCs into the generated module's fields. - mod.allocate_fields(n_nodes, n_elem) - mod.x_ref.from_numpy(coords) - mod.elem_nodes.from_numpy(conn.astype(np.int32)) - mod.f_ext.from_numpy(f_ext_np) - - free = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - bc_val = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - free.from_numpy((~bc_mask).astype(np.float64)) - bc_val.from_numpy(bc_values) - - # ── Inject the P3-2 GENERATED operator into the apply_A seam ────────── - v_bc = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - - def apply_A(out_field, x_field): - _mask_free(v_bc, x_field, free) # zero constrained DOFs of the direction - mod.svk_tangent_matvec_apply(out_field, v_bc, _LAM, _MU) - _set_constrained(out_field, x_field, free) # identity rows on constrained DOFs - - ctx = LinearSolveContext() - ctx.set_operator(apply_A) - ctx.set_preconditioner(IdentityPreconditioner()) - # ── Inject the GENERATED PCG body into the set_solver seam ──────────── - bind_generated_pcg_solver(ctx) - - # Thin Newton loop: generated internal-force kernel + generated tangent - # operator + generated PCG solver, all over the seam. - resid = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - du = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - _apply_dirichlet(mod.u, free, bc_val) - - r0 = None - for _ in range(50): - mod.compute_internal_force(_LAM, _MU) - vops.copy(resid, mod.f_ext) - vops.axpy(resid, -1.0, mod.f_int) # R = f_ext - f_int - _mask_free(resid, resid, free) - r_norm = vops.norm2(resid) - if r0 is None: - r0 = r_norm - if r_norm < 1e-10 * r0: - break - du.fill(0.0) - # Solve K(u)·du = R via the GENERATED PCG injected at set_solver. - ctx.solver.solve(resid, du, 1e-12, 2000) - _mask_free(du, du, free) - vops.axpy(mod.u, 1.0, du) - else: - pytest.fail("generated-PCG Newton solve did not converge") - - u_gen = mod.u.to_numpy() # boundary extraction only (verification output) - assert np.max(np.abs(u_gen)) > 1e-3, "expected a nonzero converged displacement" - max_diff = float(np.max(np.abs(u_gen - u_ref))) - assert max_diff < _GATE_TOL, ( - f"generated-PCG seam solve differs from reference: " - f"max|u_gen - u_ref| = {max_diff:.3e} >= {_GATE_TOL:.0e}" - ) - - @pytest.mark.slow - def test_generated_pcg_matches_canonical_pcg_behaviour(self): - """AC-2: generated PCG matches the canonical PCG behaviour (converged + max-iter). - - Drives the generated PCG and the PJ-1 spike ``pcg`` (the hand-written - realisation of the same canonical PCG LaTeX) over the **same** injected - SPD operator + fields, on device. Asserts they agree on: - * the converged path (same solution, same iteration count), and - * the max-iteration-exhausted path (same partial iterate + iter count). - This is the behavioural parity the canonical PCG LaTeX pins; a bitwise - NumPy-vs-Taichi parity is not meaningful (Option 1 note). - """ - import taichi as ti - - from tests.spike.svk_hex8_taichi import _PCGWorkspace - from tests.spike.svk_hex8_taichi import pcg as spike_pcg - from ti_runtime import vector_ops as vops - from ti_runtime.seams import IdentityPreconditioner, LinearSolveContext - - ti.init(arch=ti.cpu, default_fp=ti.f64) - - # Block-diagonal SPD operator (mirrors test_p2_2 / ti-runtime test_seams). - m_np = np.array([[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]]) - - @ti.kernel - def apply_M(out: ti.template(), x: ti.template()): - mat = ti.Matrix([[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]], dt=ti.f64) - for i in out: - out[i] = mat @ x[i] - - rng = np.random.default_rng(7) - n = 5 - b_np = rng.standard_normal((n, 3)) - - def _vfield(vals): - vals = np.ascontiguousarray(vals, dtype=np.float64) - f = ti.Vector.field(vals.shape[1], ti.f64, shape=vals.shape[0]) - f.from_numpy(vals) - return f - - generated_pcg = build_seam_pcg() - - def _seam_ctx(): - ctx = LinearSolveContext().set_operator(apply_M) - ctx.set_preconditioner(IdentityPreconditioner()) - return ctx - - def _run_generated(b, x, tol, maxiter): - ctx = _seam_ctx() - - def operator_A(out, vec): - ctx.apply_A(out, vec) - - def precond_M_inv(r_in, z_out): - ctx.apply_preconditioner(z_out, r_in) - - return generated_pcg(operator_A, b, x, precond_M_inv, tol, maxiter) - - def _run_spike(b, x, tol, maxiter): - ctx = _seam_ctx() - ws = _PCGWorkspace.alloc(x.shape[0]) - return spike_pcg(ctx, ws, b, x, tol, maxiter) - - # ── Converged path: tol loose enough to converge before maxiter ────── - x_gen = ti.Vector.field(3, ti.f64, shape=n) - x_spk = ti.Vector.field(3, ti.f64, shape=n) - _, k_gen, _, conv_gen = _run_generated(_vfield(b_np), x_gen, 1e-12, 200) - k_spk, _ = _run_spike(_vfield(b_np), x_spk, 1e-12, 200) - - expected = np.linalg.solve(m_np, b_np.T).T - np.testing.assert_allclose(x_gen.to_numpy(), expected, atol=1e-9, rtol=0) - np.testing.assert_allclose(x_gen.to_numpy(), x_spk.to_numpy(), atol=1e-10, rtol=0) - assert int(k_gen) == int(k_spk), ( - f"converged-path iteration counts differ: generated={int(k_gen)}, spike={int(k_spk)}" - ) - assert int(conv_gen) == 1, ( - f"converged path must report converged=1; got {int(conv_gen)} (WI-2)" - ) - - # ── Max-iter-exhausted path: tol unreachable in `maxiter` iters ────── - max_it = 2 - xg = ti.Vector.field(3, ti.f64, shape=n) - xs = ti.Vector.field(3, ti.f64, shape=n) - _, kg, rg, conv_g = _run_generated(_vfield(b_np), xg, 1e-30, max_it) - ks, rs = _run_spike(_vfield(b_np), xs, 1e-30, max_it) - - assert int(kg) == max_it and int(ks) == max_it, ( - f"max-iter path should exhaust to {max_it}; generated={int(kg)}, spike={int(ks)}" - ) - assert int(conv_g) == 0, ( - f"max-iter-exhausted path must report converged=0; got {int(conv_g)} (WI-2)" - ) - np.testing.assert_allclose(xg.to_numpy(), xs.to_numpy(), atol=1e-10, rtol=0) - assert abs(float(rg) - float(rs)) < 1e-10, ( - f"max-iter residual norms differ: generated={float(rg)}, spike={float(rs)}" - ) - - # Residual sanity on the converged solution (on device, no NumPy in solve). - ax = ti.Vector.field(3, ti.f64, shape=n) - apply_M(ax, x_gen) - vops.axpy(ax, -1.0, _vfield(b_np)) - assert vops.norm2(ax) < 1e-9 - - def test_generated_pcg_no_numpy_in_solve(self): - """AC-3: the generated solver body has no NumPy / .to_numpy() in the hot path. - - Source + AST check on the transpiled ``pcg`` body (mirrors how - test_pj1_svk_spike checks the spike's hot path). The generated PCG must - call only the matrix-free operator ``A(out, x)``, the preconditioner - ``M_inv(r, z)``, and ``ti_runtime.vector_ops`` primitives — never NumPy. - """ - code = transpile_seam_pcg() - - # Source-level: no numpy import, no .to_numpy() / np. in the body. - assert "import numpy" not in code, f"generated PCG must not import numpy:\n{code}" - assert ".to_numpy(" not in code, f"generated PCG must not call .to_numpy():\n{code}" - assert "np." not in code, f"generated PCG must not reference np.:\n{code}" - # It must be matrix-free: no dense _matvec emitted or called. - assert "def _matvec(" not in code and "_matvec(" not in code, ( - f"generated PCG must be matrix-free (no dense _matvec):\n{code}" - ) - - # AST-level: parse the generated module, isolate the `pcg` function, and - # walk every node — assert no attribute access named `to_numpy` and no - # name `np`/`numpy` is referenced inside the solver body. - tree = ast.parse(code) - pcg_fn = next( - (n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == "pcg"), - None, - ) - assert pcg_fn is not None, "generated module is missing the `pcg` driver function" - - for node in ast.walk(pcg_fn): - if isinstance(node, ast.Attribute): - assert node.attr != "to_numpy", "pcg body must not call .to_numpy() (host copy)" - if isinstance(node, ast.Name): - assert node.id not in ("np", "numpy"), ( - "pcg body must not reference numpy in the solve hot path" - ) - - # And the on-device primitives the body DOES use are the ti_runtime ones. - assert "from ti_runtime import vector_ops as _v" in code - for prim in ("_v.dot(", "_v.norm2(", "_v.vec_add(", "_v.copy("): - assert prim in code, f"expected ti_runtime primitive {prim} in the generated body" - - @pytest.mark.slow - def test_generated_pcg_with_generated_jacobi_cuts_iterations(self): - """The GENERATED PCG bound with the GENERATED Jacobi converges in fewer - iterations than with identity — exercising the ``M_inv(r, z)`` (out-last) - seam binding end-to-end with a *non-trivial* preconditioner. - - Identity-preconditioner tests cannot catch a preconditioner mis-binding: - CG converges to the right answer for any SPD ``M``, so only the iteration - *rate* would change. This is the one test where a wrong ``M_inv`` mapping - through the generated PCG would actually fail. - """ - import taichi as ti - - from mechdsl.solver import GeneratedJacobiPreconditioner, make_seam_solver - from ti_runtime.seams import IdentityPreconditioner - - ti.init(arch=ti.cpu, default_fp=ti.f64) - - def _vfield(vals): - vals = np.ascontiguousarray(vals, dtype=np.float64) - f = ti.Vector.field(vals.shape[1], ti.f64, shape=vals.shape[0]) - f.from_numpy(vals) - return f - - n = 6 - # Diagonal SPD operator A = diag(d) with 3 distinct eigenvalues {2, 5, 11}: - # unpreconditioned CG converges in 3 iterations; Jacobi (M = diag(d)) gives - # M^{-1} A = I -> 1 iteration. A wrong M_inv binding loses that speedup. - dv = np.tile([2.0, 5.0, 11.0], (n, 1)) - diag = _vfield(dv) - - @ti.kernel - def apply_A(out: ti.template(), x: ti.template()): - for i in out: - out[i] = diag[i] * x[i] - - bv = np.random.default_rng(5).standard_normal((n, 3)) - expected = bv / dv # A = diag(d) -> x = b / d - - def _solve(precond): - ctx = make_seam_solver(operator=apply_A, preconditioner=precond) - x = ti.Vector.field(3, ti.f64, shape=n) # zero initial guess - result = ctx.solver.solve(_vfield(bv), x, 1e-12, 100) - return int(result[1]), x.to_numpy() - - jac_iters, x_jac = _solve(GeneratedJacobiPreconditioner(diag=diag)) - id_iters, x_id = _solve(IdentityPreconditioner()) - - np.testing.assert_allclose(x_jac, expected, atol=1e-9) - np.testing.assert_allclose(x_id, expected, atol=1e-9) - assert jac_iters < id_iters, ( - "generated Jacobi must cut PCG iterations vs identity through the " - f"generated PCG; jacobi={jac_iters}, identity={id_iters}" - ) - assert jac_iters <= 2, ( - f"Jacobi on a diagonal operator should converge ~1 iter; got {jac_iters}" - ) - - @pytest.mark.slow - @pytest.mark.e2e - def test_issue_307_pcg_parity_suite_stays_green(self, tmp_path): - """AC-4: the issue #307 / PCG-parity suite stays green alongside P4-2. - - Runs the canonical-PCG transpiler-parity gate functions in-process (the - same gate ``recovery_plan_latex_contract/test_pcg_transpiler_parity.py`` - enforces) so a P4-2 regression on the shared algo2code PCG codegen path is - caught here too. Authoring ``dev/algorithms/pcg.tex`` must not perturb - the canonical matrix-``A`` PCG codegen — the generated bodies share the - same algo2code pipeline. - """ - from tests.plan_tests.recovery_plan_latex_contract.test_pcg_transpiler_parity import ( - test_converged_path_is_identical_to_machine_precision, - test_maxiter_exhaust_path_matches, - ) - - # Each re-inits Taichi and rebuilds its own generated PCG + hand twin. - # The parity gate functions take pytest's ``tmp_path`` (issue #307 W6 - # wrote the generated module to a managed temp dir); forward this test's - # own ``tmp_path`` fixture since we call them directly, not via pytest. - test_converged_path_is_identical_to_machine_precision(tmp_path) - test_maxiter_exhaust_path_matches(tmp_path) diff --git a/packages/mechdsl-core/tests/plan_tests/test_p4_3.py b/packages/mechdsl-core/tests/plan_tests/test_p4_3.py deleted file mode 100644 index fa829dd..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p4_3.py +++ /dev/null @@ -1,349 +0,0 @@ -"""Tests for Task P4-3 (PlanJune14 Phase 4) — the final Phase-4 task. - -Validate the all-Taichi generated **seam** solve path (generated matrix-free PCG -over the ``ti_runtime`` ``set_solver`` seam + the P3-2 generated SVK tangent -operator via ``set_operator``, optionally the P4-1 generated Jacobi via -``set_preconditioner``) against the **imported** solvers (``ScipyCGSolver`` / -``PCGSolver``) on the reference SVK problem, and expose it as a SELECTABLE -runtime entry point (:func:`mechdsl.solver.import_adapter.make_seam_solver`). - -DECISION (Option 1, all-Taichi seam, opt-in): P4-3 does **not** flip the global -``get_default_solver`` default — ``ScipyCGSolver`` stays the fallback; the -guarded global flip is deferred until broader regression coverage (Phase 5 J2 + -Phase 7 governance). - -The seam path's interface (``LinearSolveContext`` + on-device -``ti.Vector.field`` DOF vectors) is deliberately distinct from the host-NumPy -``LinearSolverInterface`` (matvec callback) consumed by ``newton.py``. The two -are kept as separate, selectable paths; the seam solver is NOT forced through -the NumPy callback. - -Acceptance criteria covered: - AC-1 The all-Taichi seam solve matches ``ScipyCGSolver``/``PCGSolver`` to - <1e-10 on the reference SVK patch (slow — Taichi JIT). - AC-2 The generated seam path is selectable via :func:`make_seam_solver`. - AC-3 ``get_default_solver()`` still returns ``ScipyCGSolver`` (default NOT - flipped, per the Option-1 decision). - AC-4 The imported solver fallback (``build_solver('fallback')`` / - ``build_solver('generated')``) is still selectable and correct. -""" - -# NOTE: no ``from __future__ import annotations`` — this test imports the spike -# and the generated seam PCG, both of which define @ti.kernel bodies whose -# ti.template() annotations Taichi must evaluate eagerly (PEP 563 breaks JIT). - -import numpy as np -import pytest - -from mechdsl.solver.import_adapter import ( - Algo2CodePCGSolver, - LinearSolverInterface, - PCGSolver, - ScipyCGSolver, - build_solver, - get_default_solver, - make_seam_solver, -) - -# Steel-like SVK (matches test_p3_2.py / test_p4_2.py / test_pj1_svk_spike.py). -_E_YOUNG = 200.0e3 -_NU = 0.3 -_LAM = _E_YOUNG * _NU / ((1 + _NU) * (1 - 2 * _NU)) -_MU = _E_YOUNG / (2 * (1 + _NU)) - -# Generated-vs-imported tolerance (PlanJune14 / 07-CONVENTIONS §6). -_GATE_TOL = 1e-10 - - -def _make_svk_source() -> str: - """Emit the SVK Taichi solver source (carries the P3-2 generated kernel).""" - from mechdsl.codegen.artifact import ArtifactBundle - from mechdsl.codegen.taichi_printer import emit - from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, - ) - from mechdsl.lowering.fe_localise import localise_and_optimize - - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": _E_YOUNG, "nu": _NU}), - boundaries=(BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET),), - ) - loc_result, plans = localise_and_optimize(problem_ir) - bundle = ArtifactBundle.from_pipeline(problem_ir, loc_result, plans) - return emit(bundle) - - -def _svk_patch_bcs(coords: np.ndarray): - """Uniaxial-stretch single-Hex8 patch BCs (the PJ-1 / P4-2 gate problem).""" - n_nodes = coords.shape[0] - bc_mask = np.zeros((n_nodes, 3), dtype=bool) - bc_values = np.zeros((n_nodes, 3), dtype=np.float64) - left = np.abs(coords[:, 0]) < 1e-12 - right = np.abs(coords[:, 0] - 1.0) < 1e-12 - bc_mask[left, :] = True - bc_mask[right, 0] = True - bc_values[right, 0] = 0.1 - return bc_mask, bc_values - - -def _solve_svk_patch_numpy(solver: LinearSolverInterface) -> np.ndarray: - """Solve the SVK patch with a host-NumPy Newton loop using ``solver``. - - This is the *imported-solver* reference path: a NumPy Newton loop whose - inner linear solve is driven by the given ``LinearSolverInterface`` - (``ScipyCGSolver`` or ``PCGSolver``). Returns the converged displacement - field, used as the <1e-10 oracle for the all-Taichi seam solve. - """ - from tests.ref.ref_hex8_elastic import ( - apply_dirichlet, - apply_tangent_matvec, - assemble_internal_force, - generate_hex8_mesh, - ) - - coords, conn = generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - n_nodes = coords.shape[0] - ndof = n_nodes * 3 - bc_mask, bc_values = _svk_patch_bcs(coords) - f_ext = np.zeros((n_nodes, 3), dtype=np.float64) - - u = apply_dirichlet(np.zeros((n_nodes, 3), dtype=np.float64), bc_mask, bc_values) - r0_norm: float | None = None - - for _ in range(50): - f_int = assemble_internal_force(u, coords, conn, _LAM, _MU) - R = f_ext - f_int - R[bc_mask] = 0.0 - r_norm = float(np.linalg.norm(R)) - if r0_norm is None: - r0_norm = r_norm - if r0_norm < 1e-15 or r_norm < 1e-10 * r0_norm: - break - - def matvec(v_flat: np.ndarray, _u: np.ndarray = u) -> np.ndarray: - v = v_flat.reshape((n_nodes, 3)) - return apply_tangent_matvec(_u, v, coords, conn, _LAM, _MU, bc_mask).ravel() - - du_flat, _it, _res = solver.solve( - matvec, R.ravel(), np.zeros(ndof, dtype=np.float64), 1e-12, 2000 - ) - du = du_flat.reshape((n_nodes, 3)) - du[bc_mask] = 0.0 - u = u + du - else: # pragma: no cover - convergence is asserted by the caller - raise RuntimeError("imported-solver Newton solve did not converge") - - return u - - -class TestTaskP4_3: - """Tests for Task P4-3: validate seam path + selectable (no default flip). AC 1-4.""" - - @pytest.mark.slow - def test_all_taichi_seam_solve_matches_imported_solvers(self, tmp_path): - """AC-1: the all-Taichi seam solve matches ScipyCG/PCG to <1e-10 on the reference problem. - - Drives the generated PCG (injected via ``make_seam_solver`` / - ``set_solver``) against the **P3-2 generated SVK tangent operator** - (``set_operator``), on device over ``ti.Vector.field`` DOF vectors — no - NumPy in the seam hot path. The converged displacement is then compared - against the SAME SVK patch solved by the **imported** ``ScipyCGSolver`` - AND ``PCGSolver`` (host-NumPy Newton). All three must agree to <1e-10. - """ - import taichi as ti - - from tests._e2e_helpers import _import_generated_module - from tests.ref.ref_hex8_elastic import generate_hex8_mesh - from tests.spike.svk_hex8_taichi import ( - _apply_dirichlet, - _mask_free, - _set_constrained, - ) - from ti_runtime import vector_ops as vops - from ti_runtime.seams import IdentityPreconditioner - - ti.init(arch=ti.cpu, default_fp=ti.f64) - - # ── Imported-solver oracles (host-NumPy Newton + ScipyCG / PCG) ─────── - u_scipy = _solve_svk_patch_numpy(ScipyCGSolver()) - u_pcg = _solve_svk_patch_numpy(PCGSolver()) - # The two imported solvers must themselves agree (sanity on the oracle). - assert float(np.max(np.abs(u_scipy - u_pcg))) < _GATE_TOL, ( - "imported ScipyCG and PCG disagree on the SVK patch — oracle is unstable" - ) - - # ── All-Taichi seam solve (generated PCG + P3-2 generated operator) ─── - source = _make_svk_source() - mod = _import_generated_module(source, tmp_path, name="gen_p4_3_op") - assert hasattr(mod, "svk_tangent_matvec_apply"), ( - "generated module is missing the P3-2 @ti.kernel matrix-free tangent" - ) - - coords, conn = generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - n_nodes = coords.shape[0] - n_elem = conn.shape[0] - bc_mask, bc_values = _svk_patch_bcs(coords) - f_ext_np = np.zeros((n_nodes, 3), dtype=np.float64) - - mod.allocate_fields(n_nodes, n_elem) - mod.x_ref.from_numpy(coords) - mod.elem_nodes.from_numpy(conn.astype(np.int32)) - mod.f_ext.from_numpy(f_ext_np) - - free = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - bc_val = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - free.from_numpy((~bc_mask).astype(np.float64)) - bc_val.from_numpy(bc_values) - - v_bc = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - - def apply_A(out_field, x_field): - _mask_free(v_bc, x_field, free) # zero constrained DOFs of the direction - mod.svk_tangent_matvec_apply(out_field, v_bc, _LAM, _MU) - _set_constrained(out_field, x_field, free) # identity rows on constrained DOFs - - # ── SELECTABLE entry point: opt into the all-Taichi seam solver ─────── - ctx = make_seam_solver( - operator=apply_A, - preconditioner=IdentityPreconditioner(), - ) - - resid = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - du = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - _apply_dirichlet(mod.u, free, bc_val) - - r0 = None - for _ in range(50): - mod.compute_internal_force(_LAM, _MU) - vops.copy(resid, mod.f_ext) - vops.axpy(resid, -1.0, mod.f_int) # R = f_ext - f_int - _mask_free(resid, resid, free) - r_norm = vops.norm2(resid) - if r0 is None: - r0 = r_norm - if r_norm < 1e-10 * r0: - break - du.fill(0.0) - ctx.solver.solve(resid, du, 1e-12, 2000) # generated PCG over the seam - _mask_free(du, du, free) - vops.axpy(mod.u, 1.0, du) - else: - pytest.fail("generated-PCG seam Newton solve did not converge") - - u_seam = mod.u.to_numpy() # boundary extraction only (verification output) - assert np.max(np.abs(u_seam)) > 1e-3, "expected a nonzero converged displacement" - - diff_scipy = float(np.max(np.abs(u_seam - u_scipy))) - diff_pcg = float(np.max(np.abs(u_seam - u_pcg))) - assert diff_scipy < _GATE_TOL, ( - f"seam solve differs from imported ScipyCGSolver: " - f"max|u_seam - u_scipy| = {diff_scipy:.3e} >= {_GATE_TOL:.0e}" - ) - assert diff_pcg < _GATE_TOL, ( - f"seam solve differs from imported PCGSolver: " - f"max|u_seam - u_pcg| = {diff_pcg:.3e} >= {_GATE_TOL:.0e}" - ) - - @pytest.mark.unit - def test_generated_seam_path_is_selectable(self): - """AC-2: the all-Taichi generated path is selectable via the new entry point. - - ``make_seam_solver`` is the opt-in selector. It must be a distinct - surface from ``build_solver`` (it returns the seam's native - ``LinearSolveContext`` interface, not a host-NumPy - ``LinearSolverInterface``), and it must bind the generated PCG at the - ``set_solver`` seam so ``ctx.solver.solve`` is callable. - """ - import taichi as ti - - from mechdsl.solver import make_seam_solver as exported_make_seam_solver - from ti_runtime.seams import IdentityPreconditioner, LinearSolveContext - - ti.init(arch=ti.cpu, default_fp=ti.f64) - - # Re-exported from the package surface (selectable from mechdsl.solver). - assert exported_make_seam_solver is make_seam_solver - assert callable(make_seam_solver) - - # A trivial SPD operator over Taichi fields (interface-shape check). - @ti.kernel - def apply_identity(out: ti.template(), x: ti.template()): - for i in out: - out[i] = x[i] - - ctx = make_seam_solver(operator=apply_identity, preconditioner=IdentityPreconditioner()) - - # The seam path returns the seam interface — NOT a NumPy LinearSolverInterface. - assert isinstance(ctx, LinearSolveContext) - assert not hasattr(ctx, "solve"), ( - "make_seam_solver must NOT return a LinearSolverInterface — it returns " - "the on-device LinearSolveContext seam interface (distinct boundary)" - ) - # The generated PCG is bound at the set_solver seam, so ctx.solver.solve runs it. - assert callable(ctx.solver.solve) - - # End-to-end on-device sanity: with the identity operator, PCG returns b. - n = 4 - b = ti.Vector.field(3, ti.f64, shape=n) - x = ti.Vector.field(3, ti.f64, shape=n) - rng = np.random.default_rng(3) - b_np = rng.standard_normal((n, 3)) - b.from_numpy(np.ascontiguousarray(b_np, dtype=np.float64)) - ctx.solver.solve(b, x, 1e-12, 50) - np.testing.assert_allclose(x.to_numpy(), b_np, atol=1e-10, rtol=0) - - @pytest.mark.unit - def test_global_default_not_flipped(self): - """AC-3: get_default_solver() still returns the imported ScipyCGSolver (default NOT flipped). - - Per the Option-1 decision, P4-3 validates + exposes the seam path but - does NOT flip the global default. The default must remain the imported - ``ScipyCGSolver`` (a host-NumPy ``LinearSolverInterface``), never the - generated/seam path. - """ - default = get_default_solver() - assert isinstance(default, ScipyCGSolver), ( - "get_default_solver() must still return ScipyCGSolver — the global " - "default flip is intentionally deferred (Option-1 decision)." - ) - assert not isinstance(default, Algo2CodePCGSolver) - # It is a host-NumPy solver, not the on-device seam interface. - assert hasattr(default, "solve") - - @pytest.mark.unit - def test_imported_solver_fallback_still_correct(self): - """AC-4: imported solver fallback still selectable and correct; no regression. - - ``build_solver('fallback')`` → ``ScipyCGSolver``; - ``build_solver('generated')`` → ``Algo2CodePCGSolver``. Both remain - selectable, satisfy ``LinearSolverInterface``, and solve a known SPD - system correctly — the seam path's existence must not perturb them. - """ - fallback = build_solver("fallback") - generated = build_solver("generated") - assert isinstance(fallback, ScipyCGSolver) - assert isinstance(generated, Algo2CodePCGSolver) - # No-arg default is the fallback. - assert isinstance(build_solver(), ScipyCGSolver) - with pytest.raises(ValueError, match="Unknown solver mode"): - build_solver("seam") # type: ignore[arg-type] # seam is NOT a build_solver mode - - # Both imported solvers solve a known 3x3 SPD system correctly. - A = np.array([[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]], dtype=np.float64) - b = np.array([1.0, 2.0, 3.0], dtype=np.float64) - x_ref = np.linalg.solve(A, b) - - def matvec(v: np.ndarray) -> np.ndarray: - return A @ v - - for solver in (fallback, generated): - x, _it, _res = solver.solve(matvec, b, np.zeros(3), 1e-12, 100) - np.testing.assert_allclose(x, x_ref, atol=1e-10) diff --git a/packages/mechdsl-core/tests/plan_tests/test_p5_1.py b/packages/mechdsl-core/tests/plan_tests/test_p5_1.py deleted file mode 100644 index 66f69be..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p5_1.py +++ /dev/null @@ -1,709 +0,0 @@ -"""Tests for Task P5-1 (PlanJune14 Phase 5) — J2 plasticity through the seams. - -Wire the **matrix-free algorithmic consistent tangent** for the J2 plastic branch -(the linearisation of the radial return map — NOT ∂²Ψ/∂E², per -``.claude/rules/symbolic.md``) through the **PJ-3 generated ``@ti.kernel`` matvec -path** (``emit_j2_tangent_matvec_kernel`` → ``apply_A`` seam → opt_einsum -``ContractionPlan``). The J2 scalar return-map already transpiles -(``mechdsl/lib/plasticity.py`` execs ``transpile_radial_return_j2``); P5-1 connects -its algorithmic tangent to the generated matrix-free operator, manages the history -fields (eps_p, α) **on-device**, and validates against ``tests/ref/ref_hex8_plastic``. - -This is deliberately distinct from the existing **host-NumPy** ``tangent_matvec`` -path (covered by ``test_plastic_emission.py`` / ``test_e2e_plastic.py``), which -snapshots ``alpha.to_numpy()`` per quadrature point. P5-1 requires **no NumPy in -the plastic operator/solve hot path**. - -Acceptance criteria covered: - AC-1 The generated matrix-free J2 algorithmic tangent matvec matches - ``tests/ref/ref_hex8_plastic`` within tolerance (slow — Taichi JIT). - AC-2 History/state fields (eps_p, α) evolve correctly across Newton steps - **on-device** — no ``.to_numpy()`` in the matvec/solve hot path. - AC-3 The plastic branch passes the JIT-budget counter - (``estimate_unrolled_lines ≤ MAX_LINES_TI_FUNC``); split via the - optimizer path if needed — never hand-unroll. - -NOTE: no ``from __future__ import annotations`` — the generated module defines -``@ti.kernel`` bodies whose ``ti.template()`` annotations Taichi must evaluate -eagerly (PEP 563 breaks JIT — see ``test_p3_2.py`` / ``test_p4_3.py``). -""" - -import numpy as np -import pytest - -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.codegen.einsum_optimizer import ( - MAX_LINES_ABSOLUTE, - MAX_LINES_TI_FUNC, - Tier, - estimate_unrolled_lines, -) -from mechdsl.codegen.taichi_printer import emit -from mechdsl.ir.element_ir import create_hex8_element_ir -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.einsum_extract import ( - TANGENT_MATVEC_APPLY_EINSUM, - build_tangent_matvec_plan, - tangent_matvec_apply_spec, -) -from mechdsl.lowering.fe_localise import localise_and_optimize - -# Steel-like J2 power-law (matches test_e2e_plastic.py / ref_hex8_plastic). -_E_YOUNG = 200.0e3 -_NU = 0.3 -_SIGMA_Y0 = 200.0 -_K_HARD = 100.0 -_N_HARD = 0.3 -_LAM = _E_YOUNG * _NU / ((1 + _NU) * (1 - 2 * _NU)) -_MU = _E_YOUNG / (2 * (1 + _NU)) - -# Generated-vs-reference tolerance (PlanJune14 / 07-CONVENTIONS §6). -_GATE_TOL = 1e-10 - - -def _make_j2_source() -> str: - """Emit the J2 Taichi solver source (carries the P5-1 generated kernel).""" - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec( - model="j2_power_law", - params={ - "E": _E_YOUNG, - "nu": _NU, - "sigma_y0": _SIGMA_Y0, - "K": _K_HARD, - "n": _N_HARD, - }, - ), - boundaries=(BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET),), - ) - loc_result, plans = localise_and_optimize(problem_ir) - bundle = ArtifactBundle.from_pipeline(problem_ir, loc_result, plans) - return emit(bundle) - - -def _j2_material(): - """The reference J2 material matching the emitted solver's parameters.""" - from mechdsl.symbolic.models.j2_power_law import J2PowerLawMaterial - - return J2PowerLawMaterial(E=_E_YOUNG, nu=_NU, sigma_y0=_SIGMA_Y0, K=_K_HARD, n=_N_HARD) - - -def _matvec_body(source: str) -> str: - """Slice the generated ``j2_tangent_matvec_apply`` kernel body from *source*.""" - marker = "def j2_tangent_matvec_apply(" - start = source.find(marker) - assert start >= 0, "generated module is missing the P5-1 @ti.kernel J2 tangent" - rest = source[start:] - next_boundary = len(rest) - for boundary in ("\ndef ", "\nclass ", "\n@ti.kernel", "\n# ===="): - idx = rest.find(boundary, 1) - if idx != -1 and idx < next_boundary: - next_boundary = idx - return rest[:next_boundary] - - -class TestTaskP51: - """Tests for Task P5-1: J2 plasticity through the seams. AC covered: 1, 2, 3.""" - - @pytest.mark.slow - @pytest.mark.e2e - def test_j2_algorithmic_tangent_matvec_parity_vs_ref(self, tmp_path): - """Verifies: the generated matrix-free J2 algorithmic consistent-tangent - matvec (PJ-3 ``@ti.kernel`` path) reproduces the reference plastic tangent. - AC-1. Passes when: K(u)·v from the generated operator matches - ``tests/ref/ref_hex8_plastic`` to < 1e-10 (07-CONVENTIONS §6).""" - import taichi as ti - - from tests._e2e_helpers import _import_generated_module - from tests.ref.ref_hex8_elastic import generate_hex8_mesh - from tests.ref.ref_hex8_plastic import element_tangent_matvec_plastic - - source = _make_j2_source() - mod = _import_generated_module(source, tmp_path, name="gen_p5_1_matvec") - assert hasattr(mod, "j2_tangent_matvec_apply"), ( - "generated module is missing the P5-1 @ti.kernel matrix-free J2 tangent" - ) - - # 2x1x1 mesh, finite displacement large enough to drive the plastic - # branch (eps_yield ~ sigma_y0/E ~ 1e-3; a ~4% axial stretch yields). - coords, conn = generate_hex8_mesh(2, 1, 1, 2.0, 1.0, 1.0) - n_nodes = coords.shape[0] - n_elem = conn.shape[0] - rng = np.random.default_rng(11) - u_np = np.zeros((n_nodes, 3)) - u_np[:, 0] = 0.04 * coords[:, 0] - u_np[:, 1] = -0.012 * coords[:, 1] - v_np = rng.standard_normal((n_nodes, 3)) * 1e-2 - - mat = _j2_material() - - # Per-element alpha history: pre-yielded state (alpha_old > 0) so the - # algorithmic-tangent plastic branch is exercised, AND a guarantee that - # n_hard < 1's H' singularity at alpha=0 is avoided. Build it by running - # the reference internal-force update once at u_np from alpha_old = 0. - from tests.ref.ref_hex8_plastic import element_internal_force_plastic - - alpha_hist = np.zeros((n_elem, 8), dtype=np.float64) - for e in range(n_elem): - nodes = conn[e] - _f, alpha_new_e = element_internal_force_plastic( - u_np[nodes], coords[nodes], mat, np.zeros(8) - ) - alpha_hist[e] = alpha_new_e - assert float(np.max(alpha_hist)) > 1e-6, ( - "test setup failed to drive any quadrature point plastic — " - "the algorithmic plastic-branch tangent would not be exercised" - ) - - mod.allocate_fields(n_nodes, n_elem) - mod.x_ref.from_numpy(coords) - mod.elem_nodes.from_numpy(conn.astype(np.int32)) - mod.u.from_numpy(u_np) - mod.alpha.from_numpy(alpha_hist) - - out = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - v_field = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - v_field.from_numpy(v_np) - - # Generated matrix-free J2 algorithmic-tangent matvec: out = K(u) · v. - mod.j2_tangent_matvec_apply(out, v_field, _LAM, _MU, _SIGMA_Y0, _K_HARD, _N_HARD) - kv_gen = out.to_numpy() - - # Reference: assemble the handwritten plastic element tangent matvec with - # the SAME alpha history (the tangent linearises about the stored state). - kv_ref = np.zeros((n_nodes, 3)) - for e in range(n_elem): - nodes = conn[e] - kv_e = element_tangent_matvec_plastic( - u_np[nodes], coords[nodes], v_np[nodes], mat, alpha_hist[e] - ) - for a in range(8): - kv_ref[nodes[a]] += kv_e[a] - - max_diff = float(np.max(np.abs(kv_gen - kv_ref))) - assert max_diff < _GATE_TOL, ( - f"generated J2 algorithmic-tangent matvec differs from reference: " - f"max|Kv_gen - Kv_ref| = {max_diff:.3e} >= {_GATE_TOL:.0e}" - ) - - @pytest.mark.slow - @pytest.mark.e2e - def test_j2_tangent_matvec_parity_near_first_yield_n_lt_1(self, tmp_path): - """Verifies: WI-3 near-first-yield coverage of the previously-untested - committed-alpha sliver ``(1e-30, 1e-12]`` with ``n_hard < 1``. - - At a committed ``alpha_old`` in this sliver the reference - ``yield_stress_derivative`` returns 0 (its ``alpha <= 1e-12`` n<1 - singularity guard), while the *naive* ``K*n*alpha^(n-1)`` diverges - (~1e10). The old emitted ``1e-30`` floor used the naive value during the - return-map's first Newton iteration; the WI-3 ``1e-12`` floor makes the - generated H' agree with the reference (0) instead. This test drives every - quadrature point plastic from a committed alpha seeded *inside* the - sliver and asserts the generated matrix-free tangent still matches - ``element_tangent_matvec_plastic`` to < 1e-10 — i.e. the floor change - keeps generated/reference agreement in the regime that - ``test_j2_algorithmic_tangent_matvec_parity_vs_ref`` (alpha > 1e-6) never - touches. The existing ``test_p5_1.py`` parity case forces alpha > 1e-6, - so this regime had zero coverage. - - COUPLING (cognitive-debt mitigation, dev/plans/pj14_fix.md): the H' floor - (1e-12) must equal the reference ``yield_stress_derivative`` boundary, and - the in-loop convergence check sets the ``converged`` flag that the - post-loop non-convergence guard keys off — both anchored on the same - ``effective_tol``. Those relationships are asserted below so they fail - loudly if a future edit drifts one without the other. - """ - import taichi as ti - - from mechdsl.symbolic.models.j2_power_law import yield_stress_derivative - from tests._e2e_helpers import _import_generated_module - from tests.ref.ref_hex8_elastic import generate_hex8_mesh - from tests.ref.ref_hex8_plastic import element_tangent_matvec_plastic - - source = _make_j2_source() - - # Structural coupling guards (cheap, no JIT): the emitted floor matches the - # reference boundary, and the convergence check sets the converged flag the - # non-convergence guard keys off (action-at-a-distance between two blocks). - matvec_body = _matvec_body(source) - assert "if alpha_trial > 1e-12 else 0.0" in matvec_body, ( - "emitted H' floor must be 1e-12 (matches reference yield_stress_derivative)" - ) - assert "if alpha_new > 1e-12 else 0.0" in matvec_body, ( - "emitted tangent-assembly H' floor must be 1e-12" - ) - assert "effective_tol = ti.max(1e-12, 1e-12 * stress_ref)" in matvec_body - assert "ti.abs(f) < effective_tol" in matvec_body, ( - "return-map convergence check must key off effective_tol" - ) - # WI-C: the non-convergence guard is the explicit converged flag (closes the - # (effective_tol, 1e3*effective_tol] band the old f_final magnitude test let - # through). The flag is SET by the effective_tol convergence check above. - assert "converged = 1" in matvec_body, ( - "convergence check must set the converged flag (keyed off effective_tol)" - ) - assert "if converged == 0:" in matvec_body, ( - "non-convergence guard must key off the converged flag set on convergence" - ) - - # The reference is the source of truth: in the sliver it returns 0, and the - # naive formula it guards against diverges. This is exactly the regime the - # 1e-12 floor protects. - mat = _j2_material() - assert mat.n < 1.0, "near-yield singularity guard only matters for n_hard < 1" - alpha_sliver = 5.0e-13 # in (1e-30, 1e-12] - assert 1e-30 < alpha_sliver <= 1e-12 - assert yield_stress_derivative(mat, alpha_sliver) == 0.0, ( - "reference must return H'=0 in the (1e-30, 1e-12] sliver" - ) - assert mat.K * mat.n * alpha_sliver ** (mat.n - 1.0) > 1e9, ( - "naive H' must diverge in the sliver (this is what the floor guards)" - ) - - mod = _import_generated_module(source, tmp_path, name="gen_p5_1_near_yield") - assert hasattr(mod, "j2_tangent_matvec_apply") - - # 2x1x1 mesh, displacement large enough to drive every QP plastic. - coords, conn = generate_hex8_mesh(2, 1, 1, 2.0, 1.0, 1.0) - n_nodes = coords.shape[0] - n_elem = conn.shape[0] - rng = np.random.default_rng(7) - u_np = np.zeros((n_nodes, 3)) - u_np[:, 0] = 0.04 * coords[:, 0] - u_np[:, 1] = -0.012 * coords[:, 1] - v_np = rng.standard_normal((n_nodes, 3)) * 1e-2 - - # Committed alpha history seeded entirely inside the sliver (n<1 regime). - alpha_hist = np.full((n_elem, 8), alpha_sliver, dtype=np.float64) - - mod.allocate_fields(n_nodes, n_elem) - mod.x_ref.from_numpy(coords) - mod.elem_nodes.from_numpy(conn.astype(np.int32)) - mod.u.from_numpy(u_np) - mod.alpha.from_numpy(alpha_hist) - - out = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - v_field = ti.Vector.field(3, dtype=ti.f64, shape=n_nodes) - v_field.from_numpy(v_np) - - mod.j2_tangent_matvec_apply(out, v_field, _LAM, _MU, _SIGMA_Y0, _K_HARD, _N_HARD) - kv_gen = out.to_numpy() - - # Reference linearises about the SAME committed sliver alpha. - kv_ref = np.zeros((n_nodes, 3)) - for e in range(n_elem): - nodes = conn[e] - kv_e = element_tangent_matvec_plastic( - u_np[nodes], coords[nodes], v_np[nodes], mat, alpha_hist[e] - ) - for a in range(8): - kv_ref[nodes[a]] += kv_e[a] - - max_diff = float(np.max(np.abs(kv_gen - kv_ref))) - assert np.all(np.isfinite(kv_gen)), ( - "generated near-yield tangent produced non-finite values — the return " - "map likely diverged (the 1e-12 floor should prevent this)" - ) - assert max_diff < _GATE_TOL, ( - f"generated near-first-yield (n<1, alpha in (1e-30,1e-12]) tangent " - f"differs from reference: max|Kv_gen - Kv_ref| = {max_diff:.3e} " - f">= {_GATE_TOL:.0e}" - ) - - @pytest.mark.slow - def test_history_state_evolves_on_device_across_newton(self, tmp_path): - """Verifies: history fields (eps_p, α) advance correctly across Newton - steps with the matvec kept on-device. AC-2. Passes when: committed α - matches the reference history evolution AND no ``.to_numpy()`` appears in - the plastic operator/solve hot path (on-device archival only at step - boundary).""" - - from tests._e2e_helpers import _import_generated_module - from tests.ref.ref_hex8_elastic import generate_hex8_mesh - from tests.ref.ref_hex8_plastic import element_internal_force_plastic - - source = _make_j2_source() - - # (a) The generated J2 operator hot path must contain NO NumPy: the kernel - # reads alpha from the device field and never snapshots it. This is - # the structural guarantee that history is managed on-device. - matvec_body = _matvec_body(source) - assert ".to_numpy()" not in matvec_body, ( - "generated J2 tangent operator must not call .to_numpy() — history " - "(alpha) must be read on-device, not snapshotted to NumPy" - ) - assert "alpha.from_numpy" not in matvec_body, ( - "generated J2 tangent operator must not write the alpha field — " - "history advances only in compute_internal_force, never in the matvec" - ) - # It DOES read the device history field directly (read-only). - assert "alpha[e, q]" in matvec_body, ( - "generated J2 tangent operator must read alpha[e, q] from the device field" - ) - # And it runs the return map on-device (algorithmic tangent, not d2Psi/dE2). - assert "for _it in range(20):" in matvec_body, ( - "generated J2 tangent must re-run the radial-return Newton loop on-device" - ) - - # (b) On-device history evolution: compute_internal_force advances alpha[e, q] - # on the device. Drive a small displacement-controlled step and check - # the committed alpha matches the reference return-map evolution. - mod = _import_generated_module(source, tmp_path, name="gen_p5_1_history") - coords, conn = generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - n_nodes = coords.shape[0] - n_elem = conn.shape[0] - mat = _j2_material() - - mod.allocate_fields(n_nodes, n_elem) - mod.x_ref.from_numpy(coords) - mod.elem_nodes.from_numpy(conn.astype(np.int32)) - - # A displacement past yield (eps ~ 0.6% > eps_yield ~ 0.1%). - u_np = np.zeros((n_nodes, 3)) - u_np[:, 0] = 0.006 * coords[:, 0] - mod.u.from_numpy(u_np) - mod.alpha.from_numpy(np.zeros((n_elem, 8))) - - # On-device history advance (the residual kernel writes alpha[e, q]). - mod.compute_internal_force(_LAM, _MU, _SIGMA_Y0, _K_HARD, _N_HARD) - alpha_dev = mod.alpha.to_numpy() # step-boundary archival only - - # Reference history evolution from alpha_old = 0 at the same displacement. - alpha_ref = np.zeros((n_elem, 8), dtype=np.float64) - for e in range(n_elem): - nodes = conn[e] - _f, alpha_new_e = element_internal_force_plastic( - u_np[nodes], coords[nodes], mat, np.zeros(8) - ) - alpha_ref[e] = alpha_new_e - - assert float(np.max(alpha_ref)) > 1e-6, ( - "test setup failed to drive any quadrature point plastic" - ) - max_diff = float(np.max(np.abs(alpha_dev - alpha_ref))) - assert max_diff < 1e-10, ( - f"on-device history (alpha) diverged from the reference return-map " - f"evolution: max|alpha_dev - alpha_ref| = {max_diff:.3e}" - ) - - def test_jit_budget_respected_plastic_branch(self): - """Verifies: the J2 re-linearised matrix-free branch stays within the JIT - budget (the primary PJ risk). AC-3. Passes when: - ``estimate_unrolled_lines(...) <= MAX_LINES_TI_FUNC`` for the emitted - plastic tangent kernel (split via the optimizer ContractionPlan if - needed; no hand-unrolling).""" - element_ir = create_hex8_element_ir() - plan = build_tangent_matvec_plan(element_ir) - spec = tangent_matvec_apply_spec(element_ir) - - # The J2 plastic branch rides the SAME optimiser-recorded contraction as - # SVK; only the per-QP A-formation differs. Re-run the budget counter over - # the recorded path: the contraction must fit the 512-line @ti.func budget - # and stay Tier <= 2 (no Tier-3 restructuring). - lines = estimate_unrolled_lines( - spec.einsum_string, - list(spec.operand_shapes), - plan.contraction_path, - ) - assert lines <= MAX_LINES_TI_FUNC, ( - f"J2 tangent contraction is {lines} lines > {MAX_LINES_TI_FUNC} @ti.func budget" - ) - assert plan.tier <= int(Tier.TIER_2) - - def test_full_j2_kernel_within_absolute_budget(self): - """The FULL emitted ``j2_tangent_matvec_apply`` @ti.kernel honours the - absolute JIT ceiling — the honest budget test (PlanJune14 WI-1). - - The pre-existing ``test_jit_budget_respected_plastic_branch`` only gated - the inner contraction ``@ti.func`` (~203 lines); Codex correctly flagged - that it never measured the *full* kernel, whose C_ep (3⁴) + A-transform - (3⁶) physics nesting once multiplied by a ``ti.static`` N_QP=8 q-loop ran - ~18k unrolled lines (well over both limits). WI-1's runtime-q lever - divides the per-QP unroll by 8, bringing the full kernel under the 5000 - absolute ceiling. The count is the project's "unrolled lines" weighting - (07-CONVENTIONS §JIT-budget) via :func:`count_unrolled_kernel_lines`. - - NOTE: the J2 kernel lands at ~2268 unrolled — under the 5000 ABSOLUTE - ceiling (the must-have) but marginally OVER the 2000 ``@ti.kernel`` soft - target. Driving it under 2000 requires a *second* lever (Tier-3 on the - C_ep/A physics loops, or optimiser-routing the A-formation), which - inverts the "physics indices → ti.static" convention and is a deliberate - out-of-scope decision for WI-1 (see ``dev/plans/pj14_fix.md``). The hard - assertion here is therefore the absolute ceiling; the @ti.kernel target - is asserted for the SVK kernel (which clears it) in ``test_p3_2.py``. - """ - from tests._e2e_helpers import count_unrolled_kernel_lines - - source = _make_j2_source() - unrolled = count_unrolled_kernel_lines(source, "j2_tangent_matvec_apply") - assert unrolled <= MAX_LINES_ABSOLUTE, ( - f"full j2_tangent_matvec_apply is {unrolled} unrolled lines > " - f"{MAX_LINES_ABSOLUTE} absolute ceiling" - ) - - def test_j2_generated_kernel_routes_through_optimizer_not_handrolled(self): - """The J2 matvec consumes the opt_einsum ContractionPlan (no hand-rolled - contraction) — the SAME path as the SVK P3-2 kernel.""" - source = _make_j2_source() - - # The generated kernel must exist and target the ti_runtime seam. - assert "def j2_tangent_matvec_apply(" in source - assert "from ti_runtime import tensor_ti as _tt" in source - - # The optimiser-recorded path must be embedded in the emitted source. - element_ir = create_hex8_element_ir() - plan = build_tangent_matvec_plan(element_ir) - assert plan.einsum_string == TANGENT_MATVEC_APPLY_EINSUM - assert str(list(plan.contraction_path)) in source, ( - "the emitted J2 kernel must embed the opt_einsum ContractionPlan path " - f"{list(plan.contraction_path)}" - ) - assert plan.einsum_string in source - - # The three recorded pairwise steps are realised as step comments. - assert "step 1" in source and "step 2" in source and "step 3" in source - - def test_j2_generated_kernel_uses_algorithmic_not_energy_tangent(self): - """The dissipative-model rule: the generated J2 tangent is the algorithmic - consistent tangent (linearisation of the return map), NOT ∂²Ψ/∂E².""" - body = _matvec_body(_make_j2_source()) - - # Algorithmic markers: the return-map Newton loop, the plastic multiplier, - # and the Simo-Hughes Box 3.5 consistent-tangent terms are all present. - assert "for _it in range(20):" in body, "return-map Newton loop missing" - assert "dl -= f / df" in body, "plastic-multiplier Newton update missing" - assert "sigma_eq - 3.0 * mu * dl - sy" in body, "yield residual missing" - # Consistent-tangent assembly (theta scaling + deviatoric/flow structure). - assert "theta = 1.0 - 3.0 * mu * dl / sigma_eq" in body, ( - "algorithmic-tangent return-map scaling (theta) missing" - ) - assert "n_flow = S_dev_trial / sigma_eq" in body, ( - "algorithmic-tangent flow direction (n) missing" - ) - # It must NOT differentiate a stored energy: no SymPy / energy-diff hooks. - # (The kernel comment legitimately states it is "NOT d2Psi/dE2"; what must - # be absent is an actual symbolic-differentiation call site.) - assert "sympy" not in body.lower() - assert ".diff(" not in body - assert "hessian" not in body.lower() - - @pytest.mark.slow - @pytest.mark.e2e - def test_generated_newton_driver_committed_alpha_vs_ref_multistep(self, tmp_path): - """WI-2 (PlanJune14 / dev/plans/pj14_fix.md): decisive multi-step, - multi-iteration plastic Newton regression for the *generated* driver's - single-``alpha``-field history management. - - Codex finding #2 (CONFIRMED, then FIXED): the generated J2 path uses a - single ``alpha`` field as both committed and trial plastic history. - ``compute_internal_force`` reads ``alpha[e, q]`` as ``alpha_old``, runs - the radial return, and writes ``alpha_new`` back into the SAME field. - Before the WI-2 fix the generated ``newton_solve`` driver did NO - snapshot/restore of ``alpha`` between residual evaluations, so iteration - k read iteration k-1's *trial* alpha as ``alpha_old`` — plastic strain - ratcheted and Newton stalled (diverged on plasticity). The reference - ``solve_plastic`` instead keeps ``alpha_old``/``alpha_current`` separate, - assembles every Newton iteration from the COMMITTED ``alpha_old``, and - ``commit()``s only on convergence. - - WI-2 fix (faithful to the reference, gated to the plastic path): the - generated ``newton_solve`` snapshots the committed history - (``_alpha_committed.copy_from(alpha)``) before the Newton loop and - restores it (``alpha.copy_from(_alpha_committed)``) at the top of every - iteration before ``compute_internal_force`` — so each residual/tangent - evaluation starts from the committed step-start state. On non-convergence - it restores the committed history before raising (mirrors the reference - ``rollback()``). The snapshot/restore is an on-device ``copy_from`` into - the device-resident ``_alpha_committed`` mirror field (no host round-trip). - - This test drives the *generated* ``newton_solve`` across multiple - displacement-controlled load steps well past yield (step-boundary commit - only, matching the reference ``commit()``) and asserts the converged - displacement AND the committed alpha match the reference ``solve_plastic`` - within the strict 1e-10 gate tolerance (07-CONVENTIONS §6). - - It is genuinely decisive because it asserts (1) plasticity is active - (committed alpha > 0 at yielded QPs), and (2) >1 Newton iteration occurs - in at least one load step — otherwise the single-field aliasing would - never be exercised across iterations. - """ - from tests._e2e_helpers import _import_generated_module - from tests.ref.ref_hex8_elastic import generate_hex8_mesh - from tests.ref.ref_hex8_plastic import solve_plastic - - source = _make_j2_source() - - # --- Structural guard (WI-2 fix): the generated driver snapshots the - # committed plastic history before the Newton loop and restores it - # each iteration before compute_internal_force, so every residual eval - # reads the committed alpha_old (not the previous iteration's trial). - # This is the single-field committed/trial separation under test; if a - # future edit removes the snapshot/restore the guard fails loudly and - # the numerical drift below would re-confirm finding #2. - ns_start = source.find("def newton_solve(") - assert ns_start >= 0, "generated module missing newton_solve driver" - ns_body = source[ns_start : source.find("\ndef ", ns_start + 1)] - assert "_alpha_committed.copy_from(alpha)" in ns_body, ( - "newton_solve must snapshot the committed plastic history before the " - "Newton loop (the WI-2 fix for single-field committed/trial aliasing), " - "now via on-device copy_from into the _alpha_committed mirror field" - ) - assert "alpha.copy_from(_alpha_committed)" in ns_body, ( - "newton_solve must restore the committed plastic history each iteration " - "(and on non-convergence) — matches reference solve_plastic alpha_old; " - "now via on-device copy_from from the _alpha_committed mirror field" - ) - assert "compute_internal_force(" in ns_body, ( - "newton_solve must call compute_internal_force, which mutates alpha in place" - ) - - mat = _j2_material() - - # 1-element unit cube, displacement-controlled uniaxial tension. - coords, conn = generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - n_nodes = coords.shape[0] - n_elem = conn.shape[0] - - left_nodes = np.where(np.abs(coords[:, 0]) < 1e-12)[0] - right_nodes = np.where(np.abs(coords[:, 0] - 1.0) < 1e-12)[0] - - # eps_yield ~ sigma_y0/E ~ 1e-3; 0.01 total strain over 5 steps is ~10x - # yield => strongly plastic, several Newton iterations per step. - total_disp = 0.01 - n_steps = 5 - - # bc_mask: fixed left face (all DOFs) + prescribed right face x-DOF. - bc_mask = np.zeros((n_nodes, 3), dtype=bool) - bc_mask[left_nodes, :] = True - bc_mask[right_nodes, 0] = True - - # --- Generated solver: drive newton_solve once per load step, with only - # step-boundary alpha commit (matching the reference commit()). No - # per-iteration alpha reset — the generated driver mutates the single - # field in place across its own Newton iterations. - mod = _import_generated_module(source, tmp_path, name="gen_p5_1_wi2_driver") - mod.allocate_fields(n_nodes, n_elem) - mod.x_ref.from_numpy(coords) - mod.elem_nodes.from_numpy(conn.astype(np.int32)) - mod.u.from_numpy(np.zeros((n_nodes, 3))) - mod.f_ext.from_numpy(np.zeros((n_nodes, 3))) - - # Committed history at step boundaries (the generated analogue of the - # reference HistoryFields.alpha_old). Initialised to zero (virgin state). - alpha_committed = np.zeros((n_elem, 8), dtype=np.float64) - - # Flat constrained-DOF indices for newton_solve's BC enforcement. - bc_dofs = np.where(bc_mask.ravel())[0].astype(np.int64) - - iters_per_step: list[int] = [] - for step in range(1, n_steps + 1): - fraction = step / n_steps - bc_values = np.zeros((n_nodes, 3), dtype=np.float64) - bc_values[right_nodes, 0] = fraction * total_disp - bc_values_flat = bc_values.ravel()[bc_dofs] - - # Seed the live alpha field with the COMMITTED state for this step. - # newton_solve will mutate it in place across its iterations. - mod.alpha.from_numpy(alpha_committed) - - n_iters = mod.newton_solve( - _LAM, - _MU, - _SIGMA_Y0, - _K_HARD, - _N_HARD, - bc_dofs=bc_dofs, - bc_values=bc_values_flat, - max_iter=50, - tol_abs=1e-12, - tol_rel=1e-8, - ) - iters_per_step.append(int(n_iters)) - - # Commit: the converged live alpha becomes the next step's committed - # state (the generated analogue of HistoryFields.commit()). - alpha_committed = mod.alpha.to_numpy().copy() - - u_gen = mod.u.to_numpy() - alpha_gen = alpha_committed - - # --- Reference solver: identical problem, internal load stepping with - # committed/trial separation + commit()/rollback(). - bc_values_ref = np.zeros((n_nodes, 3), dtype=np.float64) - bc_values_ref[right_nodes, 0] = total_disp - f_ext_ref = np.zeros((n_nodes, 3), dtype=np.float64) - - u_ref, history_ref, ref_residuals = solve_plastic( - coords, - conn, - mat, - bc_mask, - bc_values_ref, - f_ext_ref, - n_steps=n_steps, - tol=1e-8, - max_iter=50, - ) - alpha_ref = history_ref.alpha_old # committed history at full load - - # --- Yielding + multi-iteration evidence (else the drift is untested). --- - assert float(np.max(alpha_gen)) > 1e-6, ( - f"generated committed alpha never yielded (max={np.max(alpha_gen):.3e}); " - "the single-field history drift would not be exercised" - ) - assert float(np.max(alpha_ref)) > 1e-6, "reference never yielded — bad test setup" - # At least one step took >1 Newton iteration (so alpha is read+written - # more than once within that step from the single field). - assert max(iters_per_step) > 1, ( - f"no load step took >1 Newton iteration (iters/step={iters_per_step}); " - "the multi-iteration single-field aliasing is not exercised" - ) - # The reference must also show multi-iteration steps (same regime). - assert any(len(r) > 2 for r in ref_residuals), ( - f"reference Newton converged in <=1 update every step " - f"(residual lengths={[len(r) for r in ref_residuals]})" - ) - - # --- Decisive comparison: displacement AND committed alpha. --- - max_u_diff = float(np.max(np.abs(u_gen - u_ref))) - max_alpha_diff = float(np.max(np.abs(alpha_gen - alpha_ref))) - - assert max_u_diff < _GATE_TOL, ( - f"generated driver displacement drifted from reference solve_plastic: " - f"max|u_gen - u_ref| = {max_u_diff:.3e} >= {_GATE_TOL:.0e} " - f"(iters/step={iters_per_step})" - ) - assert max_alpha_diff < _GATE_TOL, ( - f"generated driver COMMITTED ALPHA drifted from reference solve_plastic " - f"across multi-iteration Newton steps: " - f"max|alpha_gen - alpha_ref| = {max_alpha_diff:.3e} >= {_GATE_TOL:.0e} " - f"(max alpha_gen={np.max(alpha_gen):.3e}, max alpha_ref={np.max(alpha_ref):.3e}, " - f"iters/step={iters_per_step}) — finding #2 (single-field history " - f"drift) is CONFIRMED if this fails" - ) - - def test_svk_source_has_no_j2_kernel(self): - """The J2 generated kernel is gated to the J2 path only — SVK source - must not carry it (keeps every non-J2 golden byte-identical).""" - svk_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": _E_YOUNG, "nu": _NU}), - boundaries=(BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET),), - ) - loc_result, plans = localise_and_optimize(svk_ir) - svk_source = emit(ArtifactBundle.from_pipeline(svk_ir, loc_result, plans)) - assert "def j2_tangent_matvec_apply(" not in svk_source - # SVK keeps its own generated kernel. - assert "def svk_tangent_matvec_apply(" in svk_source diff --git a/packages/mechdsl-core/tests/plan_tests/test_p6_1.py b/packages/mechdsl-core/tests/plan_tests/test_p6_1.py deleted file mode 100644 index ac96d3e..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p6_1.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Tests for Task P6-1 (PlanJune14 Phase 6) — time integrators through a seam. - -**OPTIONAL phase** (plan lines 136–137, "optional/next"). Author Newmark-β (HHT -optional) as a LaTeX algorithm box (``dev/algorithms/newmark.tex``), transpile it -via algo2code, and inject it through a **new ``ti_runtime`` time-integration seam** -(``set_integrator`` / ``step``, mirroring the ``LinearSolveContext.set_operator`` / -``set_solver`` linear-solve seam from P2-2/P4) — proving the "any algorithm box" -claim end-to-end. Validate a small dynamic problem against an analytic SDOF -response. - -This is distinct from the existing **explicit** central-difference integrator -(``test_explicit_integrator.py`` — lumped mass, P7-1 explicit dynamics), which is -a different scheme and is NOT seam-injected. That test's single-step analytic -hand-calc is a useful *pattern* for case 1, and explicit dynamics is a regression -baseline (the new seam must not break it), but it covers neither P6-1 case. - -Acceptance criteria covered: - AC-1 The generated Newmark-β integrator advances a dynamic problem correctly — - matches an analytic single-DOF (SDOF) response within tolerance (slow — - Taichi JIT; e2e — LaTeX → algo2code → injected step → verify). - AC-2 The integrator is injected via the ``ti_runtime`` time-integration seam, - with **no NumPy in the step hot path**. - -NOTE: no ``from __future__ import annotations`` — the seam-injected step imports -Taichi-templated generated code that needs eager ``@ti.template()`` evaluation -(PEP 563 stringifies the annotations and breaks the JIT — the -test_p3_2 / test_p4_3 / test_p5_1 finding). - -algo2code grammar result (2026-06-15): the Newmark box transpiles **directly** — -no grammar gap. The predictor/corrector updates are all ``scalar*vector`` / -``vector +/- vector`` (fused ``_v.vec_add``), the coefficients are scalar -arithmetic, and the acceleration solve is an in-place ``callable`` (the -matrix-free seam). The generated ``newmark_step`` body is pure -``_v.vec_add`` / ``_v.copy`` / ``solve_a(...)`` — no NumPy. -""" - -import ast -import math - -import numpy as np -import pytest - -from mechdsl.solver.seam_integrate import ( - bind_generated_newmark_integrator, - build_seam_newmark, - transpile_seam_newmark, -) - -# Average-acceleration (trapezoidal) Newmark: unconditionally stable, second -# order, no algorithmic damping. gamma = 1/2 is what makes the scheme 2nd order. -_BETA = 0.25 -_GAMMA = 0.5 - - -def _make_undamped_sdof_solve_a(k: float, m: float, dt: float): - """Build the in-place acceleration solve for an undamped SDOF: m·ü + k·u = 0. - - Newmark's only system solve is for ``a_{n+1}``: - (M + γ·dt·C + β·dt²·K) a_{n+1} = F_{n+1} − C·v_pred − K·u_pred. - Undamped (C = 0), free (F = 0): (m + β·dt²·k) a_{n+1} = −k·u_pred. - - Returned as a ``@ti.kernel``-backed in-place callable - ``solve_a(u_pred, v_pred, a_out)`` (out LAST — the seam convention). The - coefficients ride as runtime kernel args (not closure-baked) so the kernel - JITs once regardless of dt (the ti_runtime scalar-arg discipline). - """ - import taichi as ti - - denom = m + _BETA * dt * dt * k - - @ti.kernel - def _solve( - u_pred: ti.template(), v_pred: ti.template(), a_out: ti.template(), kk: ti.f64, dn: ti.f64 - ): - for i in a_out: - a_out[i] = (-kk * u_pred[i]) / dn - - def solve_a(u_pred, v_pred, a_out): - _solve(u_pred, v_pred, a_out, k, denom) - - return solve_a - - -class TestTaskP61: - """Tests for Task P6-1: time integrators through a seam. AC covered: 1, 2.""" - - @pytest.mark.slow - @pytest.mark.e2e - def test_generated_newmark_matches_analytic_sdof(self): - """AC-1: the generated Newmark-β integrator (LaTeX → algo2code → injected - step) advances an SDOF oscillator and matches the analytic response. - - SDOF, undamped, free vibration: m·ü + k·u = 0, u(0)=1, v(0)=0. - Closed form: u(t) = cos(ω t), ω = sqrt(k/m). - - Tolerance justification (Newmark-β order of accuracy): - Newmark-β with γ = 1/2 is **second-order** accurate; its global error - over a fixed interval scales as O((ω·Δt)²) (period elongation, no - amplitude error for the trapezoidal rule). We integrate one full period - at Δt = T/80, so (ω·Δt) = 2π/80 ≈ 0.0785 and the error floor is - ~O(6e-3). A 5e-3 bound is therefore accuracy-appropriate (NOT a - machine-precision 1e-10 bound, which a 2nd-order scheme cannot meet at a - finite Δt). We additionally **prove** the order is genuinely 2 by a - step-refinement check: halving Δt must quarter the error (ratio ≈ 4). - """ - import taichi as ti - - ti.init(arch=ti.cpu, default_fp=ti.f64) - - from ti_runtime.seams import TimeIntegrationContext - - m, k = 2.0, 50.0 - omega = math.sqrt(k / m) - period = 2.0 * math.pi / omega - - def _integrate(steps_per_period: int) -> float: - """Run one period at the given resolution; return the max |error|.""" - dt = period / steps_per_period - n_steps = steps_per_period # exactly one period - - u = ti.Vector.field(1, ti.f64, shape=1) - v = ti.Vector.field(1, ti.f64, shape=1) - a = ti.Vector.field(1, ti.f64, shape=1) - u.from_numpy(np.array([[1.0]])) # u(0) = 1 - v.from_numpy(np.array([[0.0]])) # v(0) = 0 - a.from_numpy(np.array([[-k * 1.0 / m]])) # a(0) = M⁻¹(F − K u₀) = −k/m - - # ── Time-integration seam: inject the acceleration solve + the - # GENERATED Newmark step, then advance via ctx.step(...) ────────── - ctx = TimeIntegrationContext(dt=dt, beta=_BETA, gamma=_GAMMA) - ctx.set_accel_solve(_make_undamped_sdof_solve_a(k, m, dt)) - bind_generated_newmark_integrator(ctx) - - max_err = 0.0 - for s in range(n_steps): - ctx.step(u, v, a) - t = (s + 1) * dt - # .to_numpy() is a step-boundary archival read only — NOT in the - # generated step's hot path (which is all on-device _v.* calls). - max_err = max(max_err, abs(float(u.to_numpy()[0, 0]) - math.cos(omega * t))) - return max_err - - err_coarse = _integrate(40) - err_fine = _integrate(80) - - # Accuracy: one period at Δt = T/80 tracks cos(ω t) to well within 5e-3. - assert err_fine < 5.0e-3, ( - f"generated Newmark-β SDOF error {err_fine:.3e} exceeds the " - f"2nd-order bound 5e-3 at Δt = T/80" - ) - # Order: halving Δt must quarter the error (2nd order ⇒ ratio ≈ 4), - # proving the scheme is genuinely 2nd order and not coincidentally close. - ratio = err_coarse / err_fine - assert 3.0 < ratio < 5.0, ( - f"step-refinement ratio {ratio:.2f} is not ≈4: the generated " - f"Newmark-β step is not 2nd-order accurate (err40={err_coarse:.3e}, " - f"err80={err_fine:.3e})" - ) - - @pytest.mark.slow - def test_integrator_injects_via_time_integration_seam(self): - """AC-2: the transpiled integrator advances via the new ti_runtime - time-integration seam (set_integrator / set_accel_solve / step), - analogous to the linear-solve seam — AND the step hot path has no NumPy. - - Two halves: - (a) Behaviour: the seam-injected generated step actually advances the - state (and matches a hand-rolled Newmark reference step), proving - the body is driven *through* the seam, not bypassed. - (b) No NumPy in the step hot path: source + AST check on the generated - ``newmark_step`` body (mirrors test_p4_2 AC-3). ``.to_numpy()`` only - at step-boundary archival, never inside the generated step. - """ - import taichi as ti - - ti.init(arch=ti.cpu, default_fp=ti.f64) - - from ti_runtime.seams import TimeIntegrationContext - - # ── (a) Behaviour through the seam ─────────────────────────────────── - m, k = 1.0, 100.0 - dt = 1.0e-2 - denom = m + _BETA * dt * dt * k - - u = ti.Vector.field(1, ti.f64, shape=1) - v = ti.Vector.field(1, ti.f64, shape=1) - a = ti.Vector.field(1, ti.f64, shape=1) - u.from_numpy(np.array([[1.0]])) - v.from_numpy(np.array([[0.0]])) - a.from_numpy(np.array([[-k / m]])) - - ctx = TimeIntegrationContext(dt=dt, beta=_BETA, gamma=_GAMMA) - ctx.set_accel_solve(_make_undamped_sdof_solve_a(k, m, dt)) - bind_generated_newmark_integrator(ctx) - - # Seam must refuse to step before an integrator is injected. - bare = TimeIntegrationContext(dt=dt, beta=_BETA, gamma=_GAMMA) - bare.set_accel_solve(_make_undamped_sdof_solve_a(k, m, dt)) - with pytest.raises(RuntimeError, match="no body injected"): - bare.step(u, v, a) - - # Hand-rolled single Newmark step (the reference the seam must reproduce). - u0, v0, a0 = 1.0, 0.0, -k / m - u_pred = u0 + dt * v0 + dt * dt * (0.5 - _BETA) * a0 - v_pred = v0 + dt * (1.0 - _GAMMA) * a0 - a1 = (-k * u_pred) / denom - u1 = u_pred + _BETA * dt * dt * a1 - v1 = v_pred + _GAMMA * dt * a1 - - ctx.step(u, v, a) # advance one step through the seam - - np.testing.assert_allclose(u.to_numpy()[0, 0], u1, rtol=0, atol=1e-12) - np.testing.assert_allclose(v.to_numpy()[0, 0], v1, rtol=0, atol=1e-12) - np.testing.assert_allclose(a.to_numpy()[0, 0], a1, rtol=0, atol=1e-12) - # The state genuinely moved (the seam is not a no-op). - assert abs(float(u.to_numpy()[0, 0]) - u0) > 1e-9 - - # ── (b) No NumPy in the generated step hot path ────────────────────── - code = transpile_seam_newmark() - - # Source level. - assert "import numpy" not in code, f"generated Newmark must not import numpy:\n{code}" - assert ".to_numpy(" not in code, f"generated Newmark must not call .to_numpy():\n{code}" - assert "np." not in code, f"generated Newmark must not reference np.:\n{code}" - # Matrix-free: no dense _matvec emitted or called. - assert "def _matvec(" not in code and "_matvec(" not in code, ( - f"generated Newmark must be matrix-free (no dense _matvec):\n{code}" - ) - - # AST level: isolate the newmark_step driver, walk every node, assert no - # `.to_numpy()` attribute access and no `np`/`numpy` name in the body. - tree = ast.parse(code) - step_fn = next( - ( - node - for node in ast.walk(tree) - if isinstance(node, ast.FunctionDef) and node.name == "newmark_step" - ), - None, - ) - assert step_fn is not None, "generated module is missing the `newmark_step` driver" - for node in ast.walk(step_fn): - if isinstance(node, ast.Attribute): - assert node.attr != "to_numpy", "newmark_step must not call .to_numpy()" - if isinstance(node, ast.Name): - assert node.id not in ("np", "numpy"), ( - "newmark_step must not reference numpy in the step hot path" - ) - - # The on-device primitives the body DOES use are the ti_runtime ones, and - # the only non-primitive call is the injected acceleration solve. - assert "from ti_runtime import vector_ops as _v" in code - assert "_v.vec_add(" in code, "expected the fused AXPBY primitive in the generated body" - assert "_v.copy(" in code, "expected the ti_runtime copy primitive in the generated body" - assert "solve_a(" in code, "expected the injected acceleration-solve seam call" - - # build_seam_newmark returns the same cached generated callable used above. - assert build_seam_newmark().__name__ == "newmark_step" diff --git a/packages/mechdsl-core/tests/plan_tests/test_p7_1.py b/packages/mechdsl-core/tests/plan_tests/test_p7_1.py deleted file mode 100644 index 04d9f2b..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p7_1.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Tests for Task P7-1 (PlanJune14 Phase 7) — design-doc addenda governance. - -**GOVERNANCE phase** (plan lines 138–143, "governance — no re-drift"). Write the -design-doc addenda that record the architecture change: extend Decision D8 in -``dev/design_docs/06-CODEGEN.md`` (solver now generated, not imported), mark -§8.3 in ``dev/design_docs/11-ALGO2CODE.md`` as the operator seam built, and apply -the deferred §2.4 edit (W5, previously hook-blocked in PlanJune14). - -This is distinct from the **prior-phase implementation** (P2-2, P4-3) that -built the seams and the generated solver/operator. P7-1's job is to update -the *design documentation* so the codebase doesn't re-drift to stale claims -like "solver imported" or "operator seam not yet built". The addenda are -**externally observable**: tests anchor them to prevent future doc rot. - -Acceptance criteria covered: - AC-1 The extended D8 addendum is present in 06-CODEGEN, consistent with the - opt-in flipped default (P4-3: generated is OPT-IN, imported is fallback). - AC-2 The §8.3 operator seam in 11-ALGO2CODE is marked as built and documents - the `% type A callable` directive; the deferred §2.4 edit is applied. - - -NOTE: no `from __future__ import annotations` — these tests read and parse design -docs (immutable files), so PEP 563 is not a concern. -""" - -import re -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - """Walk up to the workspace root (the dir holding ``pyproject.toml`` + ``dev/``).""" - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "pyproject.toml").is_file() and (parent / "dev").is_dir(): - return parent - raise RuntimeError("could not locate repo root (pyproject.toml + dev/)") - - -_DESIGN_DOCS = _repo_root() / "dev" / "design_docs" - - -def _normalize(text: str) -> str: - """Lower-case, strip Markdown blockquote markers, and collapse whitespace. - - Addenda are prose that wraps across lines and (for D8) lives inside a ``>`` - blockquote, so anchor phrases are matched against a normalized stream rather - than the raw file — robust to re-wrapping during a manual paste. - """ - no_quote = re.sub(r"(?m)^\s*>\s?", "", text) - return re.sub(r"\s+", " ", no_quote).lower() - - -class TestTaskP71: - """Tests for Task P7-1: design-doc addenda governance. AC covered: 1, 2.""" - - # xfail until the maintainer pastes the staged addenda (see module docstring). - # strict=True ⇒ an xpass (docs applied) hard-fails, forcing marker removal. - _PENDING = "P7-1 addenda not yet pasted into dev/design_docs/ (manual maintainer step)" - - @pytest.mark.docs - def test_d8_addendum_solver_generated_imported_fallback(self): - """AC-1: Decision D8 addendum in 06-CODEGEN reflects the architecture: - solver is now GENERATED (the primary path via P2-2 and P4-3); the - imported linear solver is a fallback only. The addendum must be present - and consistent with the opt-in flipped default (P4-3 makes generated - opt-in; imported is NOT the global default). - """ - text = _normalize((_DESIGN_DOCS / "06-CODEGEN.md").read_text(encoding="utf-8")) - - anchors = { - "Decision D8 addendum heading": "decision d8 addendum", - "'solver is now generated' claim": "the solver is now generated", - "imported solver kept as fallback": "default fallback", - "imported ScipyCGSolver named as the fallback": "scipycgsolver", - "generated path is opt-in (PJ-4 Option 1)": "opt-in", - } - missing = [label for label, needle in anchors.items() if needle not in text] - assert not missing, ( - "06-CODEGEN.md is missing the D8 addendum anchors: " - + ", ".join(missing) - + ". Apply Edit 1 from dev/tasks/PlanJune14/P7-1_pending_design_doc_addenda.md." - ) - - @pytest.mark.docs - def test_algo2code_section_8_3_operator_seam_built_and_2_4_edit(self): - """AC-2: Section §8.3 in 11-ALGO2CODE documents the built operator seam - and the `% type A callable` directive, and the deferred §2.4 edit (W5) - is applied. The seam is no longer a "to-be-built" claim but a completed - design anchor. - """ - text = _normalize((_DESIGN_DOCS / "11-ALGO2CODE.md").read_text(encoding="utf-8")) - - # §8.3 — operator seam marked built (Edit 3). - seam_anchors = { - "§8.3 operator-interface heading": "matrix-free operator interface", - "marked built": "built", - "`% type A callable` directive documented": "% type a callable", - "injected via set_operator seam": "set_operator".lower(), - "PlanJune14 seam-coverage note (proves built, not planned)": "seam coverage", - } - # §2.4 — deferred W5 edit (Edit 4): fail-loud + spectral intrinsics note. - w5_anchors = { - "unsupported constructs now raise UnsupportedConstructError": "unsupportedconstructerror", - "spectral / matrix-free intrinsics note": "spectral", - "Gamma0 Green-operator callable named": "gamma0", - } - missing = [label for label, needle in seam_anchors.items() if needle not in text] - missing += [label for label, needle in w5_anchors.items() if needle not in text] - assert not missing, ( - "11-ALGO2CODE.md is missing the §8.3/§2.4 addenda anchors: " - + ", ".join(missing) - + ". Apply Edits 3 and 4 from " - "dev/tasks/PlanJune14/P7-1_pending_design_doc_addenda.md." - ) diff --git a/packages/mechdsl-core/tests/plan_tests/test_p7_2.py b/packages/mechdsl-core/tests/plan_tests/test_p7_2.py deleted file mode 100644 index 3e8ba1b..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_p7_2.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Tests for Task P7-2 (PlanJune14 Phase 7) — governance allowlist + STATUS_LEGEND. - -**Governance phase** (plan lines 138–143, "governance (no re-drift)"). Allowlist the -new generated/runtime modules in the anti-drift governance test (test_p7_5.py) so they -don't trip the anti-drift guard; add the PlanJune14 STATUS_LEGEND vocabulary so the -tracker vocabulary matches the legend. - -Acceptance criteria covered: - AC-1 test_p7_5 / test_p7_6 green with the new modules allowlisted. - → **COVERED by existing governance guard**: recovery_plan_latex_contract/test_p7_5.py - (lines ~55–82: ti_runtime, generated solver/operator modules already - allowlisted in ACTIVE_PLAN_STEMS / ACTIVE_TASK_DIRS / ACTIVE_TRACKER_STEMS). - DO NOT create a stub for AC-1; the standing guard is active. - AC-2 STATUS_LEGEND includes PlanJune14 vocabulary (pending, etc.). - → **MISSING**. dev/tracking/STATUS_LEGEND.md currently lists only recovery-era - values (not_started, in_progress, done, deferred, implemented-via-substitute); - no test asserts the legend includes PlanJune14 vocab. Stub case below. -""" - -import re -from pathlib import Path - -import pytest - -# Repo root: this file is packages/mechdsl-core/tests/plan_tests/test_p7_2.py -# .parents[0] = plan_tests/ -# .parents[1] = tests/ -# .parents[2] = mechdsl-core/ -# .parents[3] = packages/ -# .parents[4] = repo root -_REPO_ROOT = Path(__file__).resolve().parents[4] - -_STATUS_LEGEND = _REPO_ROOT / "dev" / "tracking" / "STATUS_LEGEND.md" -_PLANJUNE14_TRACKER = _REPO_ROOT / "dev" / "tracking" / "tasks-tracker_PlanJune14.md" - -# Status values the PlanJune14 tracker rows actually use (derived empirically from -# the tracker table — excludes values that appear only in prose/protocol sections). -# Assertion (b) below uses this as a belt-and-suspenders pin on the known vocab; -# assertion (c) is the authoritative drift guard that dynamically checks every value -# the tracker actually uses against the legend, so the two cannot silently diverge. -_PLANJUNE14_STATUS_VALUES = {"pending", "done"} - - -def _extract_legend_values(legend_text: str) -> set[str]: - """Extract all backtick-quoted status values from the legend markdown. - - Matches lines of the form ``| `value` | ... |`` in the legend tables. - """ - return set(re.findall(r"`([a-z_-]+)`", legend_text)) - - -def _extract_tracker_row_statuses(tracker_text: str) -> set[str]: - """Extract status-column values from the main tracker table rows. - - The main task table has 10 columns: - Task ID | Title | Status | Owner | Blocked by (open) | Blocks | - Plan lines | PR/Commit | Verified by | Completed on - - Only considers rows that start with ``| P`` (task data rows with at least - 10 pipe-delimited columns), skipping the secondary 3-column phase→test-file - mapping tables that also start with ``| P``. - - A valid status token matches ``^[a-z][a-z_-]*$`` (lowercase, no slashes, - no backticks, no path separators). - """ - # Matches plain lowercase identifiers like done, pending, in_progress, deferred - _status_re = re.compile(r"^[a-z][a-z_-]*$") - statuses: set[str] = set() - for line in tracker_text.splitlines(): - # Task data rows look like: | P0-1 | Title | done | ... - if not line.startswith("| P"): - continue - cols = [c.strip() for c in line.split("|")] - # cols[0] == '', cols[1] == task_id, cols[2] == title, cols[3] == status - # The main table has ≥11 entries (including leading/trailing empty strings). - # Secondary tables (Task ID | Title | Test file) have only 5. - if len(cols) < 11: - continue - candidate = cols[3] - if candidate and _status_re.match(candidate): - statuses.add(candidate) - return statuses - - -class TestTaskP7_2: - """Tests for Task P7-2: governance allowlist + STATUS_LEGEND vocab. - - Case 1 (AC-1: test_p7_5 green with ti_runtime allowlisted) is covered by the - standing anti-drift governance guard at recovery_plan_latex_contract/test_p7_5.py - — ti_runtime and generated modules are already allowlisted (lines ~55–82). - - Case 2 (AC-2: STATUS_LEGEND parses and includes PlanJune14 vocab) is implemented - below. - """ - - @pytest.mark.docs - def test_status_legend_includes_planjune14_vocab(self): - """AC-2: dev/tracking/STATUS_LEGEND.md parses and includes the PlanJune14 - status vocabulary (e.g., 'pending'). - - PlanJune14 task tracker uses status values (pending, etc.) that must be - documented in the canonical STATUS_LEGEND.md. This test verifies: - (a) The legend file exists and is parseable. - (b) Every status value the PlanJune14 tracker actually uses in its task rows - is documented in the legend. - (c) The tracker task rows reference only legend-defined status values. - """ - # (a) Legend file exists and is non-empty. - assert _STATUS_LEGEND.exists(), ( - f"STATUS_LEGEND not found at {_STATUS_LEGEND}. " - "Create dev/tracking/STATUS_LEGEND.md with the canonical vocab." - ) - legend_text = _STATUS_LEGEND.read_text(encoding="utf-8") - assert legend_text.strip(), "STATUS_LEGEND.md is empty." - - # (b) Every value the PlanJune14 tracker uses must appear in the legend. - legend_values = _extract_legend_values(legend_text) - missing = _PLANJUNE14_STATUS_VALUES - legend_values - assert not missing, ( - f"STATUS_LEGEND is missing PlanJune14 vocab: {sorted(missing)}. " - f"Legend currently documents: {sorted(legend_values)}" - ) - - # (c) Tracker rows must only reference legend-documented values. - assert _PLANJUNE14_TRACKER.exists(), ( - f"PlanJune14 tracker not found at {_PLANJUNE14_TRACKER}." - ) - tracker_text = _PLANJUNE14_TRACKER.read_text(encoding="utf-8") - actual_row_statuses = _extract_tracker_row_statuses(tracker_text) - undocumented = actual_row_statuses - legend_values - assert not undocumented, ( - f"Tracker rows use status values not in STATUS_LEGEND: " - f"{sorted(undocumented)}. " - f"Add them to dev/tracking/STATUS_LEGEND.md." - ) diff --git a/packages/mechdsl-core/tests/plan_tests/test_pj316_fail_loud.py b/packages/mechdsl-core/tests/plan_tests/test_pj316_fail_loud.py deleted file mode 100644 index d6191fb..0000000 --- a/packages/mechdsl-core/tests/plan_tests/test_pj316_fail_loud.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Behavioural fail-loud tests for the PR #316 review fixes (pj316_resolution). - -Closes the two highest-value coverage gaps the review flagged: the NaN -non-convergence sentinel must survive (T1, WI-1) and an exhausted seam PCG must -raise rather than hand back a garbage increment (T2, WI-2). Both replace -source-substring guards (``"if converged == 0:" in body``) with real behaviour — -exactly the kind of test whose absence let the GPU NaN-clamp defect ship. -""" - -# NOTE: no ``from __future__ import annotations`` -- this module defines -# ``@ti.kernel`` bodies whose ``ti.template()`` / ``ti.i32`` annotations Taichi -# must evaluate eagerly; PEP 563 stringification breaks the JIT (PJ-0/PJ-1). - -import math - -import numpy as np -import pytest - - -def _vfield(ti, vals: np.ndarray): - vals = np.ascontiguousarray(vals, dtype=np.float64) - f = ti.Vector.field(vals.shape[1], ti.f64, shape=vals.shape[0]) - f.from_numpy(vals) - return f - - -class TestNaNSentinelSurvivesClamp: - """T1 / WI-1: the ``else:``-gated clamp must not erase the NaN sentinel.""" - - @pytest.mark.slow - def test_else_gate_keeps_nan_on_nonconvergence(self): - """The emitted structure ``if converged==0: dl=NaN; else: dl=max(dl,0)`` - keeps ``dl`` NaN on the non-converged path on **every** backend. - - This mirrors the shipped golden (generated_plastic.py.golden) after - WI-1: the clamp lives under ``else:``, so it never runs when - ``converged == 0`` and the NaN sentinel reaches the Newton driver - intact. Before WI-1 the clamp was unconditional, and a GPU - ``fmax(NaN, 0.0) -> 0.0`` erased the sentinel. - """ - ti = pytest.importorskip("taichi") - ti.init(arch=ti.cpu, default_fp=ti.f64) - - @ti.kernel - def gated_dl(converged: ti.i32) -> ti.f64: - dl = ti.f64(0.7) # an already-valid (positive) multiplier - # Intentionally the if/else *block* (not a ternary) -- it mirrors the - # exact structure the emitter produces in the shipped golden, which - # is what this test pins. noqa: keep the block form. - if converged == 0: # noqa: SIM108 - dl = ti.f64(float("nan")) - else: - dl = ti.max(dl, 0.0) - return dl - - # Non-converged -> the sentinel survives (clamp gated out). - assert math.isnan(gated_dl(0)), ( - "the else-gate must leave dl = NaN on the non-converged path so the " - "Newton driver's isfinite guard can fail loud (WI-1)" - ) - # Converged -> the clamp runs and a valid multiplier is preserved. - assert gated_dl(1) == pytest.approx(0.7) - - @pytest.mark.slow - def test_unconditional_max_is_backend_risky(self): - """Characterise the hazard the gate removes: ``ti.max(NaN, 0.0)``. - - On the CPU/LLVM test backend ``max(NaN, 0.0)`` (NaN first) *preserves* - the NaN, so this asserts it is not silently clamped to ``0.0`` here. The - defect is backend-dependent: on CUDA/Metal ``fmax(NaN, 0.0) -> 0.0`` - erases the sentinel. That platform split is precisely why WI-1 gates the - clamp behind ``else:`` instead of relying on this characterisation. - """ - ti = pytest.importorskip("taichi") - ti.init(arch=ti.cpu, default_fp=ti.f64) - - @ti.kernel - def raw_max_nan() -> ti.f64: - return ti.max(ti.f64(float("nan")), 0.0) - - assert raw_max_nan() != 0.0, ( - "an unconditional ti.max(NaN, 0.0) must NOT clamp to 0.0 on this " - "backend; the GPU divergence is why the clamp is gated (WI-1)" - ) - - -class TestSeamPCGNonConvergenceRaises: - """T2 / WI-2: an exhausted seam PCG must raise, not return a garbage du.""" - - @pytest.mark.slow - def test_exhausted_seam_pcg_raises(self): - """``ctx.solver.solve`` raises ``RuntimeError`` when the inner PCG does - not converge within ``maxiter`` (fail-loud, not a silent bad increment). - """ - ti = pytest.importorskip("taichi") - ti.init(arch=ti.cpu, default_fp=ti.f64) - - from mechdsl.solver import make_seam_solver - from ti_runtime.seams import IdentityPreconditioner - - # A genuinely coupled SPD operator: one PCG iteration cannot reach an - # unreachable 1e-30 relative tolerance, so the solve exhausts maxiter. - @ti.kernel - def apply_spd(out: ti.template(), x: ti.template()): - mat = ti.Matrix([[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]], dt=ti.f64) - for i in out: - out[i] = mat @ x[i] - - ctx = make_seam_solver(operator=apply_spd, preconditioner=IdentityPreconditioner()) - n = 5 - b = _vfield(ti, np.random.default_rng(3).standard_normal((n, 3))) - x = ti.Vector.field(3, ti.f64, shape=n) # zero initial guess - - with pytest.raises(RuntimeError, match="did not converge"): - ctx.solver.solve(b, x, 1e-30, 1) # tol unreachable in 1 iteration - - @pytest.mark.slow - def test_converged_seam_pcg_returns_triple(self): - """The success path is unchanged: a converging solve returns the - ``(x, iterations, residual)`` 3-tuple and does **not** raise. - """ - ti = pytest.importorskip("taichi") - ti.init(arch=ti.cpu, default_fp=ti.f64) - - from mechdsl.solver import make_seam_solver - from ti_runtime.seams import IdentityPreconditioner - - # Diagonal operator -> converges quickly under generous maxiter/tol. - dv = np.tile([2.0, 5.0, 11.0], (4, 1)) - diag = _vfield(ti, dv) - - @ti.kernel - def apply_diag(out: ti.template(), x: ti.template()): - for i in out: - out[i] = diag[i] * x[i] - - ctx = make_seam_solver(operator=apply_diag, preconditioner=IdentityPreconditioner()) - b_np = np.random.default_rng(5).standard_normal((4, 3)) - x = ti.Vector.field(3, ti.f64, shape=4) - - result = ctx.solver.solve(_vfield(ti, b_np), x, 1e-12, 100) - assert len(result) == 3, "converged seam solve returns (x, iters, residual)" - np.testing.assert_allclose(x.to_numpy(), b_np / dv, atol=1e-9, rtol=0) diff --git a/packages/mechdsl-core/tests/ref/ref_hex8_elastic.py b/packages/mechdsl-core/tests/ref/ref_hex8_elastic.py index abb3f3d..5f26d9b 100644 --- a/packages/mechdsl-core/tests/ref/ref_hex8_elastic.py +++ b/packages/mechdsl-core/tests/ref/ref_hex8_elastic.py @@ -118,7 +118,7 @@ def _shape_grad_reference( dN_dxi = _BASIS.gradient(xi, eta, zeta) # Reference Jacobian: J0 = dX/d(xi) = X^T @ dN/d(xi) -> (3, 3) - J0 = X_elem.T @ dN_dxi # (3,3) + J0 = X_elem.T @ dN_dxi detJ0 = float(np.linalg.det(J0)) if detJ0 <= 0.0: @@ -165,7 +165,7 @@ def element_internal_force( dN_dX, detJ0 = _shape_grad_reference(X_elem, xi, eta, zeta) # 2. Displacement gradient: du/dX = u^T @ dN/dX -> (3, 3) - grad_u = u_elem.T @ dN_dX # (3, 3) + grad_u = u_elem.T @ dN_dX # 3. Deformation gradient F = I + du/dX F = deformation_gradient(grad_u) diff --git a/packages/mechdsl-core/tests/ref/ref_hex8_plastic.py b/packages/mechdsl-core/tests/ref/ref_hex8_plastic.py index 879dce7..6f0efed 100644 --- a/packages/mechdsl-core/tests/ref/ref_hex8_plastic.py +++ b/packages/mechdsl-core/tests/ref/ref_hex8_plastic.py @@ -86,13 +86,13 @@ def _shape_grad_reference( Determinant of the reference Jacobian. """ dN_dxi = _BASIS.gradient(xi, eta, zeta) - J0 = X_elem.T @ dN_dxi # (3, 3) + J0 = X_elem.T @ dN_dxi detJ0 = float(np.linalg.det(J0)) if detJ0 <= 0.0: msg = f"Non-positive Jacobian determinant ({detJ0:.6e}) — check element connectivity." raise ValueError(msg) J0_inv = np.linalg.inv(J0) - dN_dX = dN_dxi @ J0_inv # (8, 3) + dN_dX = dN_dxi @ J0_inv return dN_dX, detJ0 @@ -205,17 +205,17 @@ def element_tangent_matvec_plastic( dN_dX, detJ0 = _shape_grad_reference(X_elem, xi, eta, zeta) # Current kinematics - grad_u = u_elem.T @ dN_dX # (3, 3) + grad_u = u_elem.T @ dN_dX F = deformation_gradient(grad_u) E = green_lagrange(F) # Radial return gives PK2 stress and algorithmic tangent result = radial_return(mat, E, float(alpha_elem[q])) - S = result.stress # (3, 3) - C4 = result.tangent # (3, 3, 3, 3) + S = result.stress + C4 = result.tangent # Linearisation in direction v - grad_v = v_elem.T @ dN_dX # (3, 3) + grad_v = v_elem.T @ dN_dX dE = 0.5 * (F.T @ grad_v + grad_v.T @ F) # linearised E dS = np.einsum("ijkl,kl->ij", C4, dE) # linearised PK2 dP = grad_v @ S + F @ dS # linearised PK1 @@ -452,7 +452,6 @@ def solve_plastic( if R0_norm < 1e-15: break - # Convergence check assert R0_norm is not None if R_norm < tol * R0_norm: break diff --git a/packages/mechdsl-core/tests/ref/ref_hex8_ul.py b/packages/mechdsl-core/tests/ref/ref_hex8_ul.py index 7760f32..2461ce3 100644 --- a/packages/mechdsl-core/tests/ref/ref_hex8_ul.py +++ b/packages/mechdsl-core/tests/ref/ref_hex8_ul.py @@ -77,7 +77,7 @@ def _shape_grad_current( x_elem = X_elem + u_elem # Current Jacobian: j = x^T @ dN/d(xi) -> (3, 3) - j = x_elem.T @ dN_dxi # (3, 3) + j = x_elem.T @ dN_dxi detj = float(np.linalg.det(j)) if detj <= 0.0: @@ -87,7 +87,7 @@ def _shape_grad_current( j_inv = np.linalg.inv(j) # dN/dx = dN/d(xi) @ j^{-1} - dN_dx = dN_dxi @ j_inv # (8, 3) + dN_dx = dN_dxi @ j_inv return dN_dx, detj @@ -131,10 +131,10 @@ def element_internal_force_ul( dN_dxi = _BASIS.gradient(xi, eta, zeta) J0 = X_elem.T @ dN_dxi J0_inv = np.linalg.inv(J0) - dN_dX = dN_dxi @ J0_inv # (8, 3) + dN_dX = dN_dxi @ J0_inv # 3. Displacement gradient and deformation gradient - grad_u = u_elem.T @ dN_dX # (3, 3) + grad_u = u_elem.T @ dN_dX F = deformation_gradient(grad_u) J = float(np.linalg.det(F)) @@ -149,7 +149,7 @@ def element_internal_force_ul( # 6. Integrate over current config: f_a += w_q * det(j) * dN_dx @ sigma^T # dN_dx is (8, 3); sigma^T is (3, 3) # For node a: dN_dx[a, :] @ sigma^T -> (3,) - f_int += w_q * detj * (dN_dx @ sigma.T) # (8, 3) + f_int += w_q * detj * (dN_dx @ sigma.T) return f_int @@ -193,7 +193,7 @@ def element_tangent_matvec_ul( Kv : (8, 3) Tangent stiffness matvec result. """ - C4 = material_tangent_4th(SVKMaterial(lam, mu)) # constant (3,3,3,3) + C4 = material_tangent_4th(SVKMaterial(lam, mu)) Kv = np.zeros((8, 3), dtype=np.float64) for q in range(_QUAD.n_points): @@ -222,7 +222,7 @@ def element_tangent_matvec_ul( c_tau = truesdell_tangent(C4, sigma, F=F) # 5. Velocity gradient in current config: grad_v = v^T @ dN_dx - grad_v = v_elem.T @ dN_dx # (3, 3) + grad_v = v_elem.T @ dN_dx # 6. Material contribution: dsigma_mat_{ij} = c^tau_{ijkl} * grad_v_{kl} dsigma_mat = np.einsum("ijkl,kl->ij", c_tau, grad_v) @@ -421,7 +421,6 @@ def solve_elastic_ul( # Already at equilibrium break - # Convergence check assert R0_norm is not None if R_norm < tol * R0_norm: break @@ -441,7 +440,6 @@ def matvec(v_flat: NDArray, _u: NDArray = u) -> NDArray: # Ensure constrained DOFs are not modified du[bc_mask] = 0.0 - # Update u = u + du else: raise RuntimeError( diff --git a/packages/mechdsl-core/tests/spike/__init__.py b/packages/mechdsl-core/tests/spike/__init__.py deleted file mode 100644 index 0497e99..0000000 --- a/packages/mechdsl-core/tests/spike/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""PJ-1 — SVK all-Taichi spike (architecture gate for PlanJune14). - -The spike proves the *Seams & Bodies* architecture composes: a matrix-free -``@ti.kernel`` SVK tangent operator (the body PJ-3 will *generate*) injected into -the ``ti_runtime`` solver seams, driven by a PCG body (the body PJ-2's algo2code -backend will *generate*) and a thin Newton loop — solving a single Hex8 SVK patch -all-on-device and matching the handwritten NumPy reference to <1e-10. -""" diff --git a/packages/mechdsl-core/tests/spike/svk_hex8_taichi.py b/packages/mechdsl-core/tests/spike/svk_hex8_taichi.py deleted file mode 100644 index f413f67..0000000 --- a/packages/mechdsl-core/tests/spike/svk_hex8_taichi.py +++ /dev/null @@ -1,472 +0,0 @@ -r"""All-Taichi St. Venant-Kirchhoff Hex8 spike (PlanJune14 **PJ-1**). - -This module is the *architecture gate* of PlanJune14: it demonstrates that the -**Seams & Bodies** model composes end-to-end into an all-Taichi nonlinear solve -with **no NumPy in the operator/solve hot path**, matching the handwritten NumPy -reference (``tests/ref/ref_hex8_elastic.solve_elastic``) to < 1e-10. - -What it composes ----------------- - -* **Operator (the body PJ-3 will generate).** A matrix-free ``@ti.kernel`` SVK - tangent that recomputes ``K_e · v_e`` on the fly per matvec — element tangents - are *never stored* (locked decision **D-A**, 06-CODEGEN §3.3). The kinematics - (F→E→S) and the consistent linearisation ``dP = grad_v·S + F·(C:dE)`` are - evaluated inline using the Tier-1 ``ti_runtime.tensor_ti`` ``@ti.func`` helpers - and ``ti_runtime.hex8`` shape gradients. -* **Seams (``ti_runtime``, PJ-0).** The operator is injected through - :class:`ti_runtime.seams.LinearSolveContext` (``set_operator`` / ``apply_A``); - the solver body calls only ``apply_A`` / ``apply_preconditioner`` plus the - ``ti_runtime.vector_ops`` primitives. -* **Solver (the body PJ-2's algo2code backend will generate).** A matrix-free PCG - whose lines map one-to-one to the canonical PCG algpseudocode - (``algo2code.library.pcg``); here it operates over Taichi vector fields via the - seams instead of a dense ``A`` field. The hand-written line-by-line numpy twin - of the *same* LaTeX lives in ``mechdsl.solver.import_adapter.Algo2CodePCGSolver``. -* **Driver.** A thin Newton-Raphson loop; residual norms come from on-device - reductions, so the operator and the linear solve never touch ``.to_numpy()``. - -The ``@ti.kernel`` / ``@ti.func`` bodies and the :func:`pcg` driver are the -*operator/solve hot path*: they are deliberately free of ``np.`` / ``.to_numpy()`` -(asserted by ``test_pj1_svk_spike``). NumPy appears only at the boundaries — host -setup (``_alloc_*``) and the final field→host extraction for verification. -""" - -# NOTE: no ``from __future__ import annotations`` here — this module defines -# ``@ti.kernel`` / ``@ti.func`` bodies and Taichi requires *eager* annotation -# evaluation (PEP 563 would turn ``ti.template()`` into a string and break the -# JIT, the PJ-0 finding). Plain-Python forward references are quoted instead. - -from dataclasses import dataclass - -import numpy as np -import taichi as ti - -from ti_runtime import fields, hex8 -from ti_runtime import tensor_ti as tt -from ti_runtime import vector_ops as vops -from ti_runtime.seams import IdentityPreconditioner, LinearSolveContext - -# Tiny pivot guard for the PCG breakdown test (matches the canonical LaTeX -# ``\If{$|pq| < 10^{-300}$}``). -_PQ_FLOOR = 1e-300 - - -# =========================================================================== -# Operator body — matrix-free SVK Hex8 kernels (the body PJ-3 will generate) -# =========================================================================== - - -@ti.func -def _grad_col(dN: ti.template(), a: ti.template()): - """Row ``a`` of an ``(8,3)`` shape-gradient matrix as a 3-vector.""" - return ti.Vector([dN[a, 0], dN[a, 1], dN[a, 2]], dt=ti.f64) - - -@ti.kernel -def _svk_internal_force( - fout: ti.template(), - u: ti.template(), - coords: ti.template(), - conn: ti.template(), - lam: ti.f64, - mu: ti.f64, - n_elem: ti.i32, -): - """Matrix-free assembly of the global internal force ``f_int(u)``. - - Total-Lagrangian SVK; mirrors ``ref_hex8_elastic.element_internal_force`` - integrated over 2×2×2 Gauss points and scattered to nodes. ``fout`` is - assumed zeroed by the caller; the element→node scatter uses Taichi's - implicit atomic add. - """ - I3 = tt.identity3() - for e in range(n_elem): - for q in ti.static(range(hex8.N_QP)): - xi, eta, zeta = ti.static(hex8.QUAD_POINTS[q]) - w = ti.static(hex8.QUAD_WEIGHTS[q]) - dN_xi = hex8.shape_grad_natural(xi, eta, zeta) # (8,3) ∂N/∂ξ - - # Reference Jacobian J0 = X_elem^T · ∂N/∂ξ and ∂N/∂X = ∂N/∂ξ · J0^{-1} - J0 = ti.Matrix.zero(ti.f64, 3, 3) - for a in ti.static(range(hex8.N_NODES)): - J0 += coords[conn[e, a]].outer_product(_grad_col(dN_xi, a)) - detJ0 = tt.det3(J0) - J0inv_T = tt.inv3(J0).transpose() - - # Material displacement gradient grad_u = u_elem^T · ∂N/∂X - grad_u = ti.Matrix.zero(ti.f64, 3, 3) - for a in ti.static(range(hex8.N_NODES)): - grad_u += u[conn[e, a]].outer_product(J0inv_T @ _grad_col(dN_xi, a)) - - F = tt.deformation_gradient(grad_u) - E = tt.green_lagrange(F) - S = lam * tt.trace3(E) * I3 + 2.0 * mu * E # PK2 (SVK) - P = F @ S # PK1 - - scale = w * detJ0 - for a in ti.static(range(hex8.N_NODES)): - grad_N_a = J0inv_T @ _grad_col(dN_xi, a) - fout[conn[e, a]] += scale * (P @ grad_N_a) - - -@ti.kernel -def _svk_tangent_matvec( - out: ti.template(), - v: ti.template(), - u: ti.template(), - coords: ti.template(), - conn: ti.template(), - lam: ti.f64, - mu: ti.f64, - n_elem: ti.i32, -): - """Matrix-free tangent matvec ``out = K(u) · v`` — recomputed per call (D-A). - - Exact linearisation of the SVK internal force (06-CODEGEN §3.3): with - ``dE = sym(F^T·grad_v)`` and the constant SVK tangent ``C:dE = lam·tr(dE)·I + - 2·mu·dE``, the linearised PK1 is ``dP = grad_v·S + F·(C:dE)``. Element - contributions are scattered to nodes (implicit atomic add); ``out`` is assumed - zeroed by the caller. No element stiffness is ever formed or stored. - """ - I3 = tt.identity3() - for e in range(n_elem): - for q in ti.static(range(hex8.N_QP)): - xi, eta, zeta = ti.static(hex8.QUAD_POINTS[q]) - w = ti.static(hex8.QUAD_WEIGHTS[q]) - dN_xi = hex8.shape_grad_natural(xi, eta, zeta) - - J0 = ti.Matrix.zero(ti.f64, 3, 3) - for a in ti.static(range(hex8.N_NODES)): - J0 += coords[conn[e, a]].outer_product(_grad_col(dN_xi, a)) - detJ0 = tt.det3(J0) - J0inv_T = tt.inv3(J0).transpose() - - grad_u = ti.Matrix.zero(ti.f64, 3, 3) - grad_v = ti.Matrix.zero(ti.f64, 3, 3) - for a in ti.static(range(hex8.N_NODES)): - grad_N_a = J0inv_T @ _grad_col(dN_xi, a) - grad_u += u[conn[e, a]].outer_product(grad_N_a) - grad_v += v[conn[e, a]].outer_product(grad_N_a) - - F = tt.deformation_gradient(grad_u) - E = tt.green_lagrange(F) - S = lam * tt.trace3(E) * I3 + 2.0 * mu * E # current PK2 - - dE = 0.5 * (F.transpose() @ grad_v + grad_v.transpose() @ F) - dS = lam * tt.trace3(dE) * I3 + 2.0 * mu * dE # C : dE (SVK) - dP = grad_v @ S + F @ dS # linearised PK1 - - scale = w * detJ0 - for a in ti.static(range(hex8.N_NODES)): - grad_N_a = J0inv_T @ _grad_col(dN_xi, a) - out[conn[e, a]] += scale * (dP @ grad_N_a) - - -# =========================================================================== -# Dirichlet seam kernels (a "free mask": 1.0 on free DOFs, 0.0 on constrained) -# =========================================================================== - - -@ti.kernel -def _apply_dirichlet(u: ti.template(), free: ti.template(), bc_val: ti.template()): - """``u = free·u + (1-free)·bc_val`` — set prescribed values on constrained DOFs.""" - for I in u: - one = ti.Vector([1.0, 1.0, 1.0], dt=ti.f64) - u[I] = free[I] * u[I] + (one - free[I]) * bc_val[I] - - -@ti.kernel -def _mask_free(dst: ti.template(), src: ti.template(), free: ti.template()): - """``dst = free·src`` — copy with constrained DOFs zeroed.""" - for I in dst: - dst[I] = free[I] * src[I] - - -@ti.kernel -def _set_constrained(out: ti.template(), src: ti.template(), free: ti.template()): - """``out = free·out + (1-free)·src`` — overwrite constrained DOFs with ``src``.""" - for I in out: - one = ti.Vector([1.0, 1.0, 1.0], dt=ti.f64) - out[I] = free[I] * out[I] + (one - free[I]) * src[I] - - -# =========================================================================== -# Solver body — matrix-free PCG over the seams (the body PJ-2 will generate) -# =========================================================================== - - -@dataclass -class _PCGWorkspace: - """Pre-allocated scratch fields reused across every Newton linear solve.""" - - r: "ti.Field" - z: "ti.Field" - p: "ti.Field" - q: "ti.Field" - Ax: "ti.Field" - - @staticmethod - def alloc(n: int) -> "_PCGWorkspace": - return _PCGWorkspace( - r=fields.vector_field(3, n), - z=fields.vector_field(3, n), - p=fields.vector_field(3, n), - q=fields.vector_field(3, n), - Ax=fields.vector_field(3, n), - ) - - -def pcg( - ctx: LinearSolveContext, - ws: "_PCGWorkspace", - b: "ti.Field", - x: "ti.Field", - tol: float, - maxiter: int, -) -> tuple[int, float]: - """Preconditioned Conjugate Gradient over the injection seams. - - A line-by-line realisation of the canonical PCG algpseudocode - (``algo2code.library.pcg.PCG_ALGORITHM_LATEX``) operating on Taichi *vector - fields*: the LaTeX ``A · p`` is the injected matrix-free operator - (``ctx.apply_A``) and ``M^{-1}(r)`` is ``ctx.apply_preconditioner``; every - other line is a ``ti_runtime.vector_ops`` primitive. Solves ``A x = b`` in - place (``x`` carries the initial guess). Returns ``(iterations, residual)``. - - No ``np.`` / ``.to_numpy()`` here: ``dot`` / ``norm2`` are on-device kernel - reductions returning Python floats. - """ - apply_A = ctx.apply_A - apply_M_inv = ctx.apply_preconditioner - - # r = b - A·x - apply_A(ws.Ax, x) - vops.copy(ws.r, b) - vops.axpy(ws.r, -1.0, ws.Ax) - - r0 = vops.norm2(ws.r) - if r0 == 0.0: - return 0, 0.0 - - apply_M_inv(ws.z, ws.r) # z = M^{-1} r - vops.copy(ws.p, ws.z) # p = z - rho = vops.dot(ws.r, ws.z) # ρ = rᵀz - - for k in range(1, maxiter + 1): - apply_A(ws.q, ws.p) # q = A·p - pq = vops.dot(ws.p, ws.q) # pᵀq - if abs(pq) < _PQ_FLOOR: - break - alpha = rho / pq - vops.axpy(x, alpha, ws.p) # x += α p - vops.axpy(ws.r, -alpha, ws.q) # r -= α q - r_norm = vops.norm2(ws.r) - if r_norm < tol * r0: - return k, r_norm - apply_M_inv(ws.z, ws.r) # z = M^{-1} r - rho_new = vops.dot(ws.r, ws.z) # ρ_new = rᵀz - beta = rho_new / rho - vops.xpay(ws.p, beta, ws.z) # p = β p + z - rho = rho_new - - return maxiter, vops.norm2(ws.r) - - -# =========================================================================== -# Operator factory — bind the mesh/state to the seam's apply(out, x) contract -# =========================================================================== - - -def make_svk_operator( - u: "ti.Field", - coords: "ti.Field", - conn: "ti.Field", - free: "ti.Field", - lam: float, - mu: float, - n_elem: int, -): - """Build the injected matrix-free operator ``apply_A(out, v): out = K(u)·v``. - - Wraps :func:`_svk_tangent_matvec` with the same Dirichlet treatment as the - reference (``ref_hex8_elastic.apply_tangent_matvec``): the input direction is - masked to free DOFs, the matvec scatters element contributions, and the - constrained rows are set to identity (``out = v`` there) so the global system - stays non-singular for CG. ``u`` is read live, so the operator always reflects - the current Newton iterate without rebinding. - - No ``np.`` / ``.to_numpy()``: a per-operator scratch field ``v_bc`` holds the - masked direction so the caller's ``v`` is never mutated. - """ - v_bc = fields.vector_field(3, u.shape[0]) - - def apply_A(out: "ti.Field", v: "ti.Field") -> None: - _mask_free(v_bc, v, free) # v_bc = free · v - out.fill(0.0) - _svk_tangent_matvec(out, v_bc, u, coords, conn, lam, mu, n_elem) - _set_constrained(out, v, free) # identity rows on constrained DOFs - - return apply_A - - -# =========================================================================== -# Thin Newton driver -# =========================================================================== - - -@dataclass -class SVKProblem: - """A single-material SVK BVP on a Hex8 mesh (host/NumPy description).""" - - coords: np.ndarray # (n_nodes, 3) reference coordinates - conn: np.ndarray # (n_elem, 8) connectivity (int) - lam: float - mu: float - bc_mask: np.ndarray # (n_nodes, 3) bool — True on constrained DOFs - bc_values: np.ndarray # (n_nodes, 3) prescribed displacements - f_ext: np.ndarray # (n_nodes, 3) external force - - -def _alloc_fields(prob: SVKProblem): - """Boundary: move the host problem description onto Taichi fields.""" - n_nodes = prob.coords.shape[0] - n_elem = prob.conn.shape[0] - - coords = fields.vector_field(3, n_nodes) - conn = fields.index_field((n_elem, hex8.N_NODES)) - free = fields.vector_field(3, n_nodes) - bc_val = fields.vector_field(3, n_nodes) - f_ext = fields.vector_field(3, n_nodes) - u = fields.vector_field(3, n_nodes) - - coords.from_numpy(np.ascontiguousarray(prob.coords, dtype=np.float64)) - conn.from_numpy(np.ascontiguousarray(prob.conn, dtype=np.int32)) - free.from_numpy(np.ascontiguousarray(~prob.bc_mask, dtype=np.float64)) # 1=free - bc_val.from_numpy(np.ascontiguousarray(prob.bc_values, dtype=np.float64)) - f_ext.from_numpy(np.ascontiguousarray(prob.f_ext, dtype=np.float64)) - u.fill(0.0) - - return n_nodes, n_elem, coords, conn, free, bc_val, f_ext, u - - -def solve_svk_hex8( - prob: SVKProblem, - *, - arch: str = "cpu", - newton_tol: float = 1e-10, - newton_max_iter: int = 50, - cg_tol: float = 1e-12, - cg_max_iter: int = 2000, -) -> tuple[np.ndarray, list[float]]: - """Solve an SVK Hex8 BVP fully on-device, returning ``(u, residual_history)``. - - Re-initialises Taichi for ``arch`` (clean field state), then runs Newton with - the matrix-free SVK tangent operator injected into the ``ti_runtime`` seams and - solved by :func:`pcg`. The operator and linear solve stay entirely on-device; - only the final ``u.to_numpy()`` (verification output) crosses the boundary. - """ - fields.init(arch=arch, default_fp=ti.f64) - n_nodes, n_elem, coords, conn, free, bc_val, f_ext, u = _alloc_fields(prob) - - # Newton/PCG scratch — allocated once, reused every iteration. - f_int = fields.vector_field(3, n_nodes) - resid = fields.vector_field(3, n_nodes) - du = fields.vector_field(3, n_nodes) - ws = _PCGWorkspace.alloc(n_nodes) - - # Initial guess: prescribed values on constrained DOFs, zero elsewhere. - _apply_dirichlet(u, free, bc_val) - - ctx = LinearSolveContext() - ctx.set_operator(make_svk_operator(u, coords, conn, free, prob.lam, prob.mu, n_elem)) - ctx.set_preconditioner(IdentityPreconditioner()) # unpreconditioned (PJ-4 adds Jacobi) - - residual_history: list[float] = [] - r0_norm: float | None = None - - for newton_iter in range(newton_max_iter): - # Residual R = f_ext - f_int(u), constrained DOFs zeroed. - f_int.fill(0.0) - _svk_internal_force(f_int, u, coords, conn, prob.lam, prob.mu, n_elem) - vops.copy(resid, f_ext) - vops.axpy(resid, -1.0, f_int) # R = f_ext - f_int - _mask_free(resid, resid, free) # zero constrained rows - - r_norm = vops.norm2(resid) - residual_history.append(r_norm) - - if newton_iter == 0: - r0_norm = r_norm - if r0_norm < 1e-15: - break - assert r0_norm is not None - if r_norm < newton_tol * r0_norm: - break - - # Solve K(u) · du = R (matrix-free, injected operator + PCG). - du.fill(0.0) - pcg(ctx, ws, resid, du, cg_tol, cg_max_iter) - _mask_free(du, du, free) # keep constrained DOFs fixed - vops.axpy(u, 1.0, du) # u += du - else: - raise RuntimeError( - f"Newton did not converge after {newton_max_iter} iterations; " - f"final |R| = {residual_history[-1]:.3e}" - ) - - return u.to_numpy(), residual_history - - -# =========================================================================== -# Element-level helpers (boundary NumPy) — used by the convention-parity tests -# =========================================================================== - - -def single_element_internal_force( - u_elem: np.ndarray, X_elem: np.ndarray, lam: float, mu: float, arch: str = "cpu" -) -> np.ndarray: - """Run the Taichi internal-force kernel on one Hex8 element → ``(8,3)`` array. - - Isolates the operator's kinematics/quadrature convention from the solver so - parity vs ``ref_hex8_elastic.element_internal_force`` can be checked directly. - """ - fields.init(arch=arch, default_fp=ti.f64) - coords = fields.vector_field(3, hex8.N_NODES) - u = fields.vector_field(3, hex8.N_NODES) - conn = fields.index_field((1, hex8.N_NODES)) - fout = fields.vector_field(3, hex8.N_NODES) - - coords.from_numpy(np.ascontiguousarray(X_elem, dtype=np.float64)) - u.from_numpy(np.ascontiguousarray(u_elem, dtype=np.float64)) - conn.from_numpy(np.arange(hex8.N_NODES, dtype=np.int32).reshape(1, hex8.N_NODES)) - fout.fill(0.0) - - _svk_internal_force(fout, u, coords, conn, lam, mu, 1) - return fout.to_numpy() - - -def single_element_tangent_matvec( - u_elem: np.ndarray, - X_elem: np.ndarray, - v_elem: np.ndarray, - lam: float, - mu: float, - arch: str = "cpu", -) -> np.ndarray: - """Run the Taichi tangent kernel on one Hex8 element → ``(8,3)`` array.""" - fields.init(arch=arch, default_fp=ti.f64) - coords = fields.vector_field(3, hex8.N_NODES) - u = fields.vector_field(3, hex8.N_NODES) - v = fields.vector_field(3, hex8.N_NODES) - conn = fields.index_field((1, hex8.N_NODES)) - out = fields.vector_field(3, hex8.N_NODES) - - coords.from_numpy(np.ascontiguousarray(X_elem, dtype=np.float64)) - u.from_numpy(np.ascontiguousarray(u_elem, dtype=np.float64)) - v.from_numpy(np.ascontiguousarray(v_elem, dtype=np.float64)) - conn.from_numpy(np.arange(hex8.N_NODES, dtype=np.int32).reshape(1, hex8.N_NODES)) - out.fill(0.0) - - _svk_tangent_matvec(out, v, u, coords, conn, lam, mu, 1) - return out.to_numpy() diff --git a/packages/mechdsl-core/tests/test_analytical.py b/packages/mechdsl-core/tests/test_analytical.py index 9f750dc..44e5415 100644 --- a/packages/mechdsl-core/tests/test_analytical.py +++ b/packages/mechdsl-core/tests/test_analytical.py @@ -33,7 +33,7 @@ def _unit_cube_coords() -> np.ndarray: for j in range(2): for i in range(2): verts.append([float(i), float(j), float(k)]) - return np.array(verts) # shape (8, 3) + return np.array(verts) def _rot_z(theta: float) -> np.ndarray: @@ -49,7 +49,7 @@ def _rot_z(theta: float) -> np.ndarray: # --------------------------------------------------------------------------- -# P2-T1: patch_test_reference +# patch_test_reference # --------------------------------------------------------------------------- @@ -144,7 +144,7 @@ def test_patch_rejects_asymmetric_strain(self) -> None: strain = np.array( [ [0.01, 0.02, 0.0], - [0.00, 0.00, 0.0], # E_yx != E_xy + [0.00, 0.00, 0.0], [0.00, 0.00, 0.0], ] ) @@ -168,7 +168,7 @@ def test_patch_rejects_wrong_coord_shape(self) -> None: # --------------------------------------------------------------------------- -# P2-T2: rigid_body_reference +# rigid_body_reference # --------------------------------------------------------------------------- @@ -260,7 +260,7 @@ def test_rigid_body_rejects_non_orthogonal(self) -> None: [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], ] - ) # det = 2, not orthogonal + ) t = np.zeros(3) with pytest.raises(ValueError, match="orthogonal"): rigid_body_reference(coords, bad_R, t) @@ -276,7 +276,7 @@ def test_rigid_body_rejects_improper_rotation(self) -> None: # --------------------------------------------------------------------------- -# P2-T3: cantilever_euler_bernoulli +# cantilever_euler_bernoulli # --------------------------------------------------------------------------- @@ -357,7 +357,7 @@ def test_cantilever_rejects_non_positive_E(self) -> None: # --------------------------------------------------------------------------- -# P2-T4: uniaxial_tension_hardening +# uniaxial_tension_hardening # --------------------------------------------------------------------------- @@ -479,7 +479,6 @@ def test_uniaxial_continuity_at_yield_point(self) -> None: # so we allow abs=0.01 to confirm continuity without a jump of order sigma_y0. assert stress_below == pytest.approx(stress_above, abs=0.01) - # Both stresses must be near sigma_y0 (250 MPa) assert stress_below == pytest.approx(self._sy0, rel=1e-4) assert stress_above == pytest.approx(self._sy0, rel=1e-4) @@ -509,7 +508,6 @@ def test_uniaxial_large_strain_monotonic(self) -> None: stresses = np.array(stresses) eps_ps = np.array(eps_ps) - # Both must be non-decreasing across all values assert np.all(np.diff(stresses) >= -1e-10), "stress is not monotonically non-decreasing" assert np.all(np.diff(eps_ps) >= -1e-14), "eps_p is not monotonically non-decreasing" @@ -592,7 +590,7 @@ def test_uniaxial_rejects_negative_eps_total(self) -> None: # --------------------------------------------------------------------------- -# P2-T5: Combined acceptance tests +# Combined acceptance tests # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/test_anisotropic_emission.py b/packages/mechdsl-core/tests/test_anisotropic_emission.py index 9a8410f..01c1242 100644 --- a/packages/mechdsl-core/tests/test_anisotropic_emission.py +++ b/packages/mechdsl-core/tests/test_anisotropic_emission.py @@ -32,7 +32,7 @@ if TYPE_CHECKING: from pathlib import Path -# Single-family HGO, the committed example energy (dev/examples/hgo_energy.tex). +# Single-family HGO, the committed example energy (examples/hgo_energy.tex). _HGO_ENERGY = r""" % declare metric gDD --dim 3 % declare EDD --dim 3 diff --git a/packages/mechdsl-core/tests/test_artifacts.py b/packages/mechdsl-core/tests/test_artifacts.py index c195fdd..39ded79 100644 --- a/packages/mechdsl-core/tests/test_artifacts.py +++ b/packages/mechdsl-core/tests/test_artifacts.py @@ -329,7 +329,6 @@ def test_golden_drift_detection(self, golden_dir: Path, tmp_path: Path) -> None: # Load the real golden data original = _load_golden(golden_dir, "elastic_cantilever.npz") - # Create perturbed copy in a temp directory perturbed_dir = tmp_path / "golden_perturbed" perturbed_dir.mkdir() @@ -395,7 +394,7 @@ def test_golden_load_missing_key(self, golden_dir: Path) -> None: # =========================================================================== -# Test 6: Necking bar golden regression (P3-3) +# Test 6: Necking bar golden regression # =========================================================================== diff --git a/packages/mechdsl-core/tests/test_benchmarks.py b/packages/mechdsl-core/tests/test_benchmarks.py index 2cdd5ea..bf29baf 100644 --- a/packages/mechdsl-core/tests/test_benchmarks.py +++ b/packages/mechdsl-core/tests/test_benchmarks.py @@ -43,7 +43,7 @@ # --------------------------------------------------------------------------- # Steel-like SVK elastic -E_YOUNG = 200.0e3 # [MPa] +E_YOUNG = 200.0e3 NU = 0.3 LAM = E_YOUNG * NU / ((1 + NU) * (1 - 2 * NU)) MU = E_YOUNG / (2 * (1 + NU)) @@ -55,7 +55,7 @@ # (ref_hex8_elastic.py) accumulates O(1e-10) roundoff in multi-element # Gauss quadrature when MU ~ 77,000. Unit-material tests in # test_patch_test.py achieve 1e-12 because MU = 1 keeps roundoff near -# machine epsilon. See dev/plans/reviews/sprint3_phase1.md decision log. +# machine epsilon. _RIGID_BODY_TOL_STEEL = 1e-9 @@ -507,9 +507,9 @@ def test_tip_displacement_within_beam_theory(self, cantilever_problem: dict): # 3D Hex8 is typically stiffer than E-B beam theory, so FEM deflection # is usually less than the beam theory prediction. On a coarse 4x2x1 # mesh, 3D shear effects and mesh coarseness cause significant deviation. - # The 5% tolerance per PLAN-A requires a 40x8x4 mesh; this coarse - # 4x2x1 test verifies convergence, direction and coarse-mesh-appropriate - # accuracy (within a factor of 2). + # The 5% tolerance requires a 40x8x4 mesh; this coarse 4x2x1 test + # verifies convergence, direction and coarse-mesh-appropriate accuracy + # (within a factor of 2). ratio = tip_uz / delta_eb assert 0.25 < ratio < 2.0, ( f"Tip deflection {tip_uz:.6e} vs E-B {delta_eb:.6e}: " diff --git a/packages/mechdsl-core/tests/test_boundary_codegen.py b/packages/mechdsl-core/tests/test_boundary_codegen.py index 303a8ce..f6f863a 100644 --- a/packages/mechdsl-core/tests/test_boundary_codegen.py +++ b/packages/mechdsl-core/tests/test_boundary_codegen.py @@ -351,7 +351,7 @@ def diag_matvec(v): # --------------------------------------------------------------------------- -# R3.5.2 — T4: Invalid face name error path +# Invalid face name error path # --------------------------------------------------------------------------- @@ -374,7 +374,7 @@ def test_invalid_axis_raises(self) -> None: # --------------------------------------------------------------------------- -# R3.5.3 — __post_init__ validation tests for DirichletBC/NeumannBC +# __post_init__ validation tests for DirichletBC/NeumannBC # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/test_boundary_neumann.py b/packages/mechdsl-core/tests/test_boundary_neumann.py index eadd235..fd4d806 100644 --- a/packages/mechdsl-core/tests/test_boundary_neumann.py +++ b/packages/mechdsl-core/tests/test_boundary_neumann.py @@ -37,9 +37,7 @@ _UPDATE_GOLDEN = False -# Canonical fixture: traction "0 0 -1000" on hex8 face tagged 'z1' — -# matches the example in dev/plans/post_recovery_plan.md (P1-1, P1-7 -# acceptance criteria). +# Canonical fixture: traction "0 0 -1000" on hex8 face tagged 'z1'. _CANONICAL_SPEC = NeumannKernelSpec( bc_name="load", surface_tag="z1", diff --git a/packages/mechdsl-core/tests/test_codegen.py b/packages/mechdsl-core/tests/test_codegen.py index e11d10c..d20bfa7 100644 --- a/packages/mechdsl-core/tests/test_codegen.py +++ b/packages/mechdsl-core/tests/test_codegen.py @@ -98,7 +98,7 @@ def _count_pattern(source: str, pattern: str) -> int: # =========================================================================== -# P9.2: Generated vs Handwritten Structural Equivalence +# Generated vs Handwritten Structural Equivalence # =========================================================================== @@ -242,9 +242,7 @@ def test_plastic_history_field_read_write(self): """Generated plastic code reads alpha before constitutive and writes back.""" _, source = _make_plastic_bundle() - # Must read alpha from the field assert "alpha_old = alpha[e, q]" in source, "Missing alpha read" - # Must write alpha back assert "alpha[e, q] = alpha_new" in source, "Missing alpha write" def test_plastic_tangent_preserves_history(self): @@ -258,7 +256,6 @@ def test_plastic_tangent_preserves_history(self): """ _, source = _make_plastic_bundle() - # Analytical J2 path must snapshot alpha and call the symbolic return map. assert "alpha_np = alpha.to_numpy()" in source, ( "Analytical J2 tangent must snapshot the alpha field" ) @@ -268,7 +265,7 @@ def test_plastic_tangent_preserves_history(self): # Neither the FD save/restore pattern nor any write back to the field. # Scoped to the tangent_matvec body: newton_solve legitimately # snapshots/restores alpha for committed/trial history separation - # (WI-2, dev/plans/pj14_fix.md) — that is the driver, not the tangent. + # — that is the driver, not the tangent. start = source.find("def tangent_matvec(") assert start >= 0, "tangent_matvec definition not found" rest = source[start:] @@ -325,7 +322,7 @@ def test_svk_and_j2_differ(self): # =========================================================================== -# P9.2: Golden file snapshot tests +# Golden file snapshot tests # =========================================================================== diff --git a/packages/mechdsl-core/tests/test_compile_pipeline.py b/packages/mechdsl-core/tests/test_compile_pipeline.py index 5bd812f..31edc23 100644 --- a/packages/mechdsl-core/tests/test_compile_pipeline.py +++ b/packages/mechdsl-core/tests/test_compile_pipeline.py @@ -59,7 +59,7 @@ def _make_plastic_ir() -> ProblemIR: # ============================================================================ -# P4-T1: compile() import +# compile() import # ============================================================================ @@ -80,7 +80,7 @@ def test_same_function(self): # ============================================================================ -# P4-T2: Pipeline integration tests +# Pipeline integration tests # ============================================================================ diff --git a/packages/mechdsl-core/tests/test_constitutive_abc.py b/packages/mechdsl-core/tests/test_constitutive_abc.py index 93b737c..a988bf7 100644 --- a/packages/mechdsl-core/tests/test_constitutive_abc.py +++ b/packages/mechdsl-core/tests/test_constitutive_abc.py @@ -50,7 +50,7 @@ def j2_strain() -> np.ndarray: # ============================================================================ -# P1-T1: ConstitutiveModel ABC +# ConstitutiveModel ABC # ============================================================================ @@ -97,7 +97,7 @@ def test_abc_defines_five_abstract_methods(self): # ============================================================================ -# P1-T2: SVKModel wrapper +# SVKModel wrapper # ============================================================================ @@ -157,7 +157,7 @@ def test_svk_is_not_dissipative(self, svk_mat: SVKMaterial): # ============================================================================ -# P1-T3: J2Model wrapper +# J2Model wrapper # ============================================================================ @@ -233,7 +233,7 @@ def test_j2_is_dissipative(self, j2_mat: J2PowerLawMaterial): # ============================================================================ -# P1-T5: Cross-model integration (ABC contract verification) +# Cross-model integration (ABC contract verification) # ============================================================================ diff --git a/packages/mechdsl-core/tests/test_convected.py b/packages/mechdsl-core/tests/test_convected.py index 1f712a9..b440906 100644 --- a/packages/mechdsl-core/tests/test_convected.py +++ b/packages/mechdsl-core/tests/test_convected.py @@ -128,7 +128,6 @@ def test_known_strain(self): E = green_lagrange_convected(g=g, G=G) - # E = 0.5 * (g - I) expected = sp.Rational(1, 2) * (g - G) assert sp.simplify(E - expected) == sp.zeros(3) diff --git a/packages/mechdsl-core/tests/test_convected_curvilinear.py b/packages/mechdsl-core/tests/test_convected_curvilinear.py index 21a44ec..5def906 100644 --- a/packages/mechdsl-core/tests/test_convected_curvilinear.py +++ b/packages/mechdsl-core/tests/test_convected_curvilinear.py @@ -23,7 +23,7 @@ ) # --------------------------------------------------------------------------- -# P2-1: Covariant/contravariant bases + metric tensors +# Covariant/contravariant bases + metric tensors # --------------------------------------------------------------------------- @@ -77,7 +77,7 @@ def test_cylindrical_g_ij_construction(self): G_metric = G_ref.T @ G_ref # = diag(1, r^2, 1) # Use a simple diagonal F with a known stretch - lam = sp.Rational(3, 2) # stretch = 1.5 + lam = sp.Rational(3, 2) F_sym = sp.diag(lam, lam, lam) # g_IJ = G_ref^T (F^T F) G_ref = G_ref^T (lam^2 I) G_ref = lam^2 G_metric @@ -184,7 +184,6 @@ def test_covariant_bases_curvilinear(self): cov = covariant_bases(F, G_ref_vecs=G_ref) assert len(cov) == 3 - # g_I = F @ col_I(G_ref) for i in range(3): expected = F @ G_ref.col(i) assert sp.simplify(cov[i] - expected) == sp.zeros(3, 1) @@ -250,7 +249,7 @@ def test_contravariant_bases_from_metric(self): # --------------------------------------------------------------------------- -# P2-2: Christoffel symbols from metric +# Christoffel symbols from metric # --------------------------------------------------------------------------- @@ -410,7 +409,7 @@ def test_christoffel_computation_under_5_seconds(self): # --------------------------------------------------------------------------- -# P2-3: Covariant derivatives (vectors and tensors) +# Covariant derivatives (vectors and tensors) # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/test_convected_patch.py b/packages/mechdsl-core/tests/test_convected_patch.py index 5b31fd4..3cc8fa1 100644 --- a/packages/mechdsl-core/tests/test_convected_patch.py +++ b/packages/mechdsl-core/tests/test_convected_patch.py @@ -86,7 +86,7 @@ def _svk_stress_cartesian( # --------------------------------------------------------------------------- -# P2-5: Curvilinear patch test + Cartesian equivalence +# Curvilinear patch test + Cartesian equivalence # --------------------------------------------------------------------------- @@ -196,7 +196,7 @@ def test_cartesian_convected_equivalence(self): (max difference < 1e-12). """ lam, mu = 1.0, 1.0 - G_ref_cartesian = np.eye(3) # Cartesian: G_ref = I + G_ref_cartesian = np.eye(3) # Test multiple deformation states deformation_cases = { @@ -253,7 +253,6 @@ def test_cartesian_convected_equivalence(self): # Explicit Cartesian G_ref_vecs = I g_explicit = compute_convected_metric(F_sym, G_ref_vecs=sp.eye(3)) - # Must be identical diff_sym = sp.simplify(g_fast - g_explicit) assert diff_sym == sp.zeros(3), ( f"{name}: symbolic g differs between fast and explicit paths" diff --git a/packages/mechdsl-core/tests/test_critical_timestep.py b/packages/mechdsl-core/tests/test_critical_timestep.py index c51ba1a..4b7a5de 100644 --- a/packages/mechdsl-core/tests/test_critical_timestep.py +++ b/packages/mechdsl-core/tests/test_critical_timestep.py @@ -95,9 +95,6 @@ def _two_element_hex8_stretched() -> tuple[np.ndarray, np.ndarray]: return coords, conn -# ── Test class for P7-2 ────────────────────────────────────────────────────── - - class TestTaskP7_2: """Tests for Task P7-2: Critical time step computation. @@ -126,8 +123,8 @@ def test_unit_cube_hex8_dt_matches_analytical(self) -> None: rho = 1.0 safety = 0.9 - c_d = math.sqrt((lam + 2.0 * mu) / rho) # sqrt(3) - expected = safety * 1.0 / c_d # 0.9 / sqrt(3) + c_d = math.sqrt((lam + 2.0 * mu) / rho) + expected = safety * 1.0 / c_d dt = critical_timestep(coords, conn, lam, mu, rho, ElementType.HEX8, safety=safety) @@ -161,12 +158,10 @@ def test_irregular_mesh_dt_below_element_min(self) -> None: # Characteristic length of the thin element (elem 0): V=0.1 L_min = (0.1) ** (1.0 / 3.0) - # The computed dt must be at most safety * L_min / c_d dt_upper = safety * L_min / c_d dt = critical_timestep(coords, conn, lam, mu, rho, ElementType.HEX8, safety=safety) - # dt must be positive assert dt > 0.0, f"dt must be positive; got {dt}" # dt must be controlled by the smallest element (within float tolerance) diff --git a/packages/mechdsl-core/tests/test_cross_backend.py b/packages/mechdsl-core/tests/test_cross_backend.py index d7374a7..48f6ca2 100644 --- a/packages/mechdsl-core/tests/test_cross_backend.py +++ b/packages/mechdsl-core/tests/test_cross_backend.py @@ -106,8 +106,8 @@ _CANTILEVER_TIP_FORCE_Z = 10.0 # Comparison gate — see the module docstring for the rescoring rationale. -# Absolute floor is the P8-3.json criterion (kept for traceability); the -# relative term is what actually carries the test when |u_ref| is small. +# The absolute floor is a fallback; the relative term is what actually +# carries the test when |u_ref| is small. _COMPARISON_ABS_TOL = 1e-8 _COMPARISON_REL_TOL = 1e-6 @@ -357,12 +357,12 @@ def _write_mfem_mesh(path: Path, coords: np.ndarray, conn: np.ndarray) -> None: # Face 0: z-low ; Face 1: z-high ; Face 2: y-low ; Face 3: x-high ; # Face 4: y-high ; Face 5: x-low. face_local = ( - (0, 1, 2, 3), # -z - (4, 5, 6, 7), # +z - (0, 1, 5, 4), # -y - (1, 2, 6, 5), # +x - (2, 3, 7, 6), # +y - (3, 0, 4, 7), # -x + (0, 1, 2, 3), + (4, 5, 6, 7), + (0, 1, 5, 4), + (1, 2, 6, 5), + (2, 3, 7, 6), + (3, 0, 4, 7), ) # Attribute id per cantilever external face. 1 = root (x=0), 2 = tip (x=Lx). @@ -372,9 +372,9 @@ def _face_attribute(face_nodes: tuple[int, int, int, int]) -> int | None: ys = coords[list(face_nodes), 1] zs = coords[list(face_nodes), 2] if np.allclose(xs, 0.0): - return 1 # root (Dirichlet) + return 1 if np.allclose(xs, _CANTILEVER_LX): - return 2 # tip (Neumann) + return 2 if np.allclose(ys, 0.0): return 3 if np.allclose(ys, _CANTILEVER_LY): @@ -426,16 +426,14 @@ def _face_attribute(face_nodes: tuple[int, int, int, int]) -> int | None: # C++ post-solve displacement dump. Generated *in addition to* the emitter -# output so we do not have to edit ``mfem_printer.py`` (Gate-B approved). -# -# Phase 8 Gate-B attempt 3 update: instead of compiling this as a separate -# translation unit (which would require a CMake edit the printer does not -# emit), we splice this free function definition into the printer's -# ``main.cpp`` *immediately before* ``int main(...)``. That keeps it in a -# single TU and avoids touching the Gate-B-approved CMakeLists.txt. The -# injected ``main`` body then calls it via the ``MECHDSL_DISP_OUT`` env -# var so we also do not need to teach the printer's OptionsParser a new -# ``--out`` flag. +# output so we do not have to edit ``mfem_printer.py``. +# Rather than compiling this as a separate translation unit (which would +# require a CMake edit the printer does not emit), we splice this free +# function definition into the printer's ``main.cpp`` *immediately before* +# ``int main(...)``. That keeps it in a single TU and avoids touching the +# emitted CMakeLists.txt. The injected ``main`` body then calls it via the +# ``MECHDSL_DISP_OUT`` env var so we also do not need to teach the printer's +# OptionsParser a new ``--out`` flag. _MFEM_DISP_DUMP_CPP = """\ // NOTE: Generated by tests/test_cross_backend.py, NOT by mfem_printer. // Spliced into the printer's main.cpp ahead of ``int main(...)`` so it diff --git a/packages/mechdsl-core/tests/test_documentation.py b/packages/mechdsl-core/tests/test_documentation.py index 803cb6f..6ca8b14 100644 --- a/packages/mechdsl-core/tests/test_documentation.py +++ b/packages/mechdsl-core/tests/test_documentation.py @@ -16,7 +16,7 @@ _ROOT = Path(__file__).resolve().parents[3] _README = _ROOT / "README.md" _CHANGELOG = _ROOT / "CHANGELOG.md" -_EXAMPLES = _ROOT / "dev" / "examples" +_EXAMPLES = _ROOT / "examples" def _read_text(path: Path) -> str: @@ -33,7 +33,7 @@ def _example_source(name: str) -> str: def _run_example(name: str) -> subprocess.CompletedProcess[str]: return subprocess.run( - ["uv", "run", "python", f"dev/examples/{name}.py"], + ["uv", "run", "python", f"examples/{name}.py"], cwd=_ROOT, capture_output=True, text=True, @@ -60,7 +60,9 @@ def test_readme_has_installation_section(self): """ text = _read_text(_README) assert "## Installation" in text - assert "git clone https://github.com/SOSOVSKI/MechDSL.git" in text + import re + + assert re.search(r"git clone https://github\.com/[\w-]+/MechDSL\.git", text) assert "uv sync" in text def test_readme_has_quickstart_section(self): @@ -85,8 +87,9 @@ def test_readme_has_architecture_overview_and_design_doc_links(self): assert "## Architecture" in text assert "Layer 1 Frontend" in text assert "Layer 6 Codegen" in text - assert "dev/design_docs/00-OVERVIEW.md" in text - assert "dev/design_docs/PLAN-B.md" in text + # Public-safe README: the design-doc set is referenced as the internal + # `dev/design_docs/` tree (plain text), not as repo-relative links. + assert "dev/design_docs" in text class TestTaskP5T2: @@ -94,7 +97,7 @@ class TestTaskP5T2: def test_elastic_cantilever_script_exists(self): """ - Verifies: dev/examples/elastic_cantilever.py exists. + Verifies: examples/elastic_cantilever.py exists. Acceptance criterion: all 5 scripts runnable with uv run python. Passes when: the elastic cantilever example file is present. """ @@ -115,7 +118,7 @@ def test_elastic_cantilever_script_runs(self): """ Verifies: elastic_cantilever example runs without error. Acceptance criterion: all 5 scripts runnable with uv run python. - Passes when: uv run python dev/examples/elastic_cantilever.py exits successfully. + Passes when: uv run python examples/elastic_cantilever.py exits successfully. """ result = _run_example("elastic_cantilever") assert result.returncode == 0, result.stderr @@ -123,7 +126,7 @@ def test_elastic_cantilever_script_runs(self): def test_plastic_uniaxial_script_exists(self): """ - Verifies: dev/examples/plastic_uniaxial.py exists. + Verifies: examples/plastic_uniaxial.py exists. Acceptance criterion: all 5 scripts runnable with uv run python. Passes when: the plastic uniaxial example file is present. """ @@ -144,7 +147,7 @@ def test_plastic_uniaxial_script_runs(self): """ Verifies: plastic_uniaxial example runs without error. Acceptance criterion: all 5 scripts runnable with uv run python. - Passes when: uv run python dev/examples/plastic_uniaxial.py exits successfully. + Passes when: uv run python examples/plastic_uniaxial.py exits successfully. """ result = _run_example("plastic_uniaxial") assert result.returncode == 0, result.stderr @@ -152,7 +155,7 @@ def test_plastic_uniaxial_script_runs(self): def test_cook_membrane_script_exists(self): """ - Verifies: dev/examples/cook_membrane.py exists. + Verifies: examples/cook_membrane.py exists. Acceptance criterion: all 5 scripts runnable with uv run python. Passes when: the Cook's membrane example file is present. """ @@ -173,7 +176,7 @@ def test_cook_membrane_script_runs(self): """ Verifies: cook_membrane example runs without error. Acceptance criterion: all 5 scripts runnable with uv run python. - Passes when: uv run python dev/examples/cook_membrane.py exits successfully. + Passes when: uv run python examples/cook_membrane.py exits successfully. """ result = _run_example("cook_membrane") assert result.returncode == 0, result.stderr @@ -181,7 +184,7 @@ def test_cook_membrane_script_runs(self): def test_necking_bar_script_exists(self): """ - Verifies: dev/examples/necking_bar.py exists. + Verifies: examples/necking_bar.py exists. Acceptance criterion: all 5 scripts runnable with uv run python. Passes when: the necking bar example file is present. """ @@ -202,7 +205,7 @@ def test_necking_bar_script_runs(self): """ Verifies: necking_bar example runs without error. Acceptance criterion: all 5 scripts runnable with uv run python. - Passes when: uv run python dev/examples/necking_bar.py exits successfully. + Passes when: uv run python examples/necking_bar.py exits successfully. """ result = _run_example("necking_bar") assert result.returncode == 0, result.stderr @@ -210,7 +213,7 @@ def test_necking_bar_script_runs(self): def test_patch_test_script_exists(self): """ - Verifies: dev/examples/patch_test.py exists. + Verifies: examples/patch_test.py exists. Acceptance criterion: all 5 scripts runnable with uv run python. Passes when: the patch_test example file is present. """ @@ -231,7 +234,7 @@ def test_patch_test_script_runs(self): """ Verifies: patch_test example runs without error. Acceptance criterion: all 5 scripts runnable with uv run python. - Passes when: uv run python dev/examples/patch_test.py exits successfully. + Passes when: uv run python examples/patch_test.py exits successfully. """ result = _run_example("patch_test") assert result.returncode == 0, result.stderr @@ -333,16 +336,11 @@ def test_plan_b_phase_references_are_correct_across_target_files(self): assert "Plan B phase B2" in frontend assert "Plan B phase B5" in frontend assert "B3 (viscoplasticity), B4 (advanced hyperelasticity), and B6 (damage)." in frontend - # mechanics_ir.py: subset guards for dim and cell type remain; B1.3 docstring - # pins the Configuration concept to Plan B phase B1. After P4-5, the - # hyperelastic families are in the allowlist, so the unknown-material - # message is scoped to "B6 (damage)" only. assert "Plan B phase B2" in mechanics_ir assert "Plan B §B1.3" in mechanics_ir assert "Plan B phase B5" in mechanics_ir assert "B6 (damage)" in mechanics_ir - # fe_localise still rejects Tet4/Tet10 and unknown materials; B1.3 threading - # is documented alongside. Same B3/B4 promotion applies. - assert "Plan B §B1.3" in localise + # fe_localise still rejects Tet4/Tet10 and unknown materials. + assert "Plan B phase B1" in localise assert "Plan B phase B5" in localise assert "B6 (damage)" in localise diff --git a/packages/mechdsl-core/tests/test_e2e.py b/packages/mechdsl-core/tests/test_e2e.py index ef2e2f8..0b301f6 100644 --- a/packages/mechdsl-core/tests/test_e2e.py +++ b/packages/mechdsl-core/tests/test_e2e.py @@ -90,7 +90,7 @@ def _run_full_pipeline(problem_ir: ProblemIR) -> tuple[ArtifactBundle, str]: # =========================================================================== -# P9.1: Full pipeline e2e test +# Full pipeline e2e test # =========================================================================== diff --git a/packages/mechdsl-core/tests/test_e2e_taichi.py b/packages/mechdsl-core/tests/test_e2e_taichi.py index 80b3577..56522f3 100644 --- a/packages/mechdsl-core/tests/test_e2e_taichi.py +++ b/packages/mechdsl-core/tests/test_e2e_taichi.py @@ -64,9 +64,8 @@ def _make_elastic_problem_ir() -> ProblemIR: ) -# post_recovery_plan Phase 6 (P6-1, P6-2): _import_generated_module is -# now sourced from the shared _e2e_helpers module so all e2e tests -# share a single helper definition. +# _import_generated_module is sourced from the shared _e2e_helpers module +# so all e2e tests share a single helper definition. from tests._e2e_helpers import _import_generated_module # noqa: E402 @@ -160,7 +159,7 @@ def matvec(v_flat: np.ndarray, _u_bound: np.ndarray = u) -> np.ndarray: # =========================================================================== -# P6-T1: End-to-end Taichi execution tests +# End-to-end Taichi execution tests # =========================================================================== @@ -207,7 +206,7 @@ def test_elastic_hex8_matches_reference(self, tmp_path: Path) -> None: f_ext = np.zeros((n_nodes, 3), dtype=np.float64) right_nodes = np.where(np.abs(coords[:, 0] - 1.0) < 1e-12)[0] for n_idx in right_nodes: - f_ext[n_idx, 0] = 1.0 # tension in x-direction + f_ext[n_idx, 0] = 1.0 # 5. Solve with generated Taichi kernels + BC enforcement u_gen, residuals_gen = _newton_with_bc(mod, coords, bc_mask, f_ext, LAM, MU) @@ -221,7 +220,6 @@ def test_elastic_hex8_matches_reference(self, tmp_path: Path) -> None: f"Generated vs reference displacement mismatch: max diff = {max_diff:.3e}" ) - # Sanity: solution is non-trivial assert float(np.max(np.abs(u_gen))) > 1e-10, "Solution is trivially zero" assert len(residuals_gen) >= 2, "Should take at least 1 Newton iteration" diff --git a/packages/mechdsl-core/tests/test_einsum.py b/packages/mechdsl-core/tests/test_einsum.py index 7fe0988..eb17418 100644 --- a/packages/mechdsl-core/tests/test_einsum.py +++ b/packages/mechdsl-core/tests/test_einsum.py @@ -59,7 +59,7 @@ def _make_mvp_problem(**overrides: object) -> ProblemIR: # ====================================================================== -# P5.2: Element IR <-> optimizer integration +# Element IR <-> optimizer integration # ====================================================================== @@ -284,7 +284,7 @@ def test_from_pipeline_roundtrip_json(self): # ====================================================================== -# P5.3: CI budget regression fixtures +# CI budget regression fixtures # ====================================================================== diff --git a/packages/mechdsl-core/tests/test_einsum_extract.py b/packages/mechdsl-core/tests/test_einsum_extract.py index 96fe00b..ff0a876 100644 --- a/packages/mechdsl-core/tests/test_einsum_extract.py +++ b/packages/mechdsl-core/tests/test_einsum_extract.py @@ -20,7 +20,7 @@ def hex8_ir() -> ElementIR: # ============================================================================ -# P2-T1: extract_einsum_specs implementation +# extract_einsum_specs implementation # ============================================================================ @@ -79,7 +79,7 @@ def test_rejects_non_hex8_element(self): # ============================================================================ -# P2-T3: Regression guards +# Regression guards # ============================================================================ diff --git a/packages/mechdsl-core/tests/test_element_ir.py b/packages/mechdsl-core/tests/test_element_ir.py index 4dbc7ca..c67eb4d 100644 --- a/packages/mechdsl-core/tests/test_element_ir.py +++ b/packages/mechdsl-core/tests/test_element_ir.py @@ -115,17 +115,14 @@ def test_gradient_finite_difference(self): # Finite difference for each direction grad_fd = np.empty((8, 3), dtype=np.float64) - # d/d(xi) vals_p = basis.evaluate(xi + h, eta, zeta) vals_m = basis.evaluate(xi - h, eta, zeta) grad_fd[:, 0] = (vals_p - vals_m) / (2.0 * h) - # d/d(eta) vals_p = basis.evaluate(xi, eta + h, zeta) vals_m = basis.evaluate(xi, eta - h, zeta) grad_fd[:, 1] = (vals_p - vals_m) / (2.0 * h) - # d/d(zeta) vals_p = basis.evaluate(xi, eta, zeta + h) vals_m = basis.evaluate(xi, eta, zeta - h) grad_fd[:, 2] = (vals_p - vals_m) / (2.0 * h) @@ -276,7 +273,7 @@ def test_frozen(self): # --------------------------------------------------------------------------- -# R3.5.3 — __post_init__ validation tests for QuadratureRule +# __post_init__ validation tests for QuadratureRule # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/test_emission_phase5.py b/packages/mechdsl-core/tests/test_emission_phase5.py deleted file mode 100644 index 1758924..0000000 --- a/packages/mechdsl-core/tests/test_emission_phase5.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Tests for Phase 5: TaichiPrinter upgrades (postprocess, main, golden files). - -Covers tasks P5-T1 (rename + postprocess), P5-T2 (main block), -P5-T3 (wiring + golden file regeneration). -""" - -from __future__ import annotations - -import pytest - -import mechdsl.codegen.taichi_printer as tp -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.codegen.taichi_printer import ( - EmissionContext, - emit, - emit_main, - emit_postprocess, -) -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import localise_and_optimize - -pytestmark = pytest.mark.stable_backend - - -def _make_svk_bundle() -> ArtifactBundle: - ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=( - BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), - ), - ) - loc, plans = localise_and_optimize(ir) - return ArtifactBundle.from_pipeline(ir, loc, plans) - - -# ============================================================================ -# P5-T1: Rename + emit_postprocess -# ============================================================================ - - -class TestEmitPostprocess: - def test_emit_postprocess_produces_save_results(self): - """emit_postprocess produces a save_results function.""" - ctx = EmissionContext() - emit_postprocess(ctx, _make_svk_bundle()) - source = ctx.get_source() - assert "def save_results" in source - - def test_emit_postprocess_vtk_has_hex_topology(self): - """VTK export includes hexahedron cell topology, not empty cells.""" - ctx = EmissionContext() - emit_postprocess(ctx, _make_svk_bundle()) - source = ctx.get_source() - assert "hexahedron" in source - assert "cells=[]" not in source - - def test_rename_no_more_stub(self): - """emit_newton_driver_stub no longer exists as a module attribute.""" - assert not hasattr(tp, "emit_newton_driver_stub") - assert hasattr(tp, "emit_newton_driver") - - -# ============================================================================ -# P5-T2: emit_main -# ============================================================================ - - -class TestEmitMain: - def test_emit_main_produces_name_block(self): - """emit_main produces if __name__ == '__main__' block.""" - ctx = EmissionContext() - emit_main(ctx, _make_svk_bundle()) - source = ctx.get_source() - assert 'if __name__ == "__main__":' in source - - def test_emit_main_references_newton_solve(self): - """Emitted main block references newton_solve.""" - ctx = EmissionContext() - emit_main(ctx, _make_svk_bundle()) - source = ctx.get_source() - assert "newton_solve" in source - - def test_emit_main_calls_allocate_fields(self): - """Emitted main block calls allocate_fields, not direct field assignment.""" - ctx = EmissionContext() - emit_main(ctx, _make_svk_bundle()) - source = ctx.get_source() - assert "allocate_fields(n_nodes_mesh, n_elem_mesh)" in source - # Must NOT treat n_nodes/n_elem as Taichi scalar fields - assert "n_nodes[None]" not in source - assert "n_elem[None]" not in source - - def test_emit_main_loads_mesh_into_fields(self): - """Emitted main block loads coords/conn into Taichi fields.""" - ctx = EmissionContext() - emit_main(ctx, _make_svk_bundle()) - source = ctx.get_source() - assert "x_ref.from_numpy(coords)" in source - assert "elem_nodes.from_numpy(conn)" in source - - def test_emit_main_loads_boundary_conditions(self): - """Emitted main block loads f_ext and bc_dofs from mesh file.""" - ctx = EmissionContext() - emit_main(ctx, _make_svk_bundle()) - source = ctx.get_source() - assert "f_ext.from_numpy(" in source - assert "bc_dofs" in source - - def test_emit_main_passes_bc_dofs_to_newton(self): - """Emitted main block passes bc_dofs to newton_solve.""" - ctx = EmissionContext() - emit_main(ctx, _make_svk_bundle()) - source = ctx.get_source() - assert "bc_dofs=bc_dofs" in source - - -# ============================================================================ -# Newton driver BC enforcement -# ============================================================================ - - -class TestNewtonDriverBC: - def test_newton_solve_accepts_bc_dofs(self): - """Emitted newton_solve has bc_dofs parameter.""" - source = emit(_make_svk_bundle()) - assert "bc_dofs: np.ndarray | None = None" in source - - def test_newton_solve_dual_tolerance(self): - """Emitted newton_solve uses dual abs/rel convergence tolerances.""" - source = emit(_make_svk_bundle()) - assert "tol_abs: float = 1.0e-10" in source - assert "tol_rel: float = 1.0e-8" in source - assert "conv_threshold = max(tol_abs, tol_rel * r0_norm)" in source - - def test_newton_solve_zeros_residual_at_bc(self): - """Emitted Newton loop zeros residual at constrained DOFs.""" - source = emit(_make_svk_bundle()) - assert "r_flat[bc_dofs] = 0.0" in source - - def test_newton_solve_zeros_du_at_bc(self): - """Emitted Newton loop zeros displacement update at constrained DOFs.""" - source = emit(_make_svk_bundle()) - assert "du_flat[bc_dofs] = 0.0" in source - - def test_newton_solve_modifies_matvec_for_bc(self): - """Emitted CG matvec enforces identity at constrained DOFs.""" - source = emit(_make_svk_bundle()) - assert "v_bc[bc_dofs] = 0.0" in source - assert "Kv[bc_dofs] = v[bc_dofs]" in source - - -# ============================================================================ -# P5-T3: Wire into emit() chain -# ============================================================================ - - -class TestEmitChainWiring: - def test_main_block_in_emitted_source(self): - """if __name__ appears in full emitted source.""" - source = emit(_make_svk_bundle()) - assert "__name__" in source - - def test_save_results_in_emitted_source(self): - """def save_results appears in full emitted source.""" - source = emit(_make_svk_bundle()) - assert "def save_results" in source diff --git a/packages/mechdsl-core/tests/test_emission_verification.py b/packages/mechdsl-core/tests/test_emission_verification.py index 822b735..90e587c 100644 --- a/packages/mechdsl-core/tests/test_emission_verification.py +++ b/packages/mechdsl-core/tests/test_emission_verification.py @@ -111,14 +111,14 @@ def _count_pattern(source: str, pattern: str) -> int: # =========================================================================== -# P6.3: Elastic constitutive emission tests +# Elastic constitutive emission tests # =========================================================================== class TestElasticConstitutiveEmission: """P6.3 — Verify the SVK constitutive function emission is mathematically correct.""" - # -- P6.3.1: SVK stress formula -- + # -- SVK stress formula -- def test_svk_stress_formula_complete(self, svk_source: str) -> None: """SVK stress: S = lam * tr_E * I + 2 * mu * E must be present.""" @@ -134,7 +134,7 @@ def test_svk_stress_factor_of_two(self, svk_source: str) -> None: """The mu term must have the factor 2.0 (not 1.0 or missing).""" assert "2.0 * mu * E" in svk_source - # -- P6.3.2: Deformation gradient -- + # -- Deformation gradient -- def test_deformation_gradient_identity_plus_grad_u(self, svk_source: str) -> None: """Constitutive receives F; the kernel computes F = I + grad_u.""" @@ -147,7 +147,7 @@ def test_deformation_gradient_comment(self, svk_source: str) -> None: """Emitted code documents F = I + grad_u semantics.""" assert "Deformation gradient F = I + grad_u" in svk_source - # -- P6.3.3: Green-Lagrange strain -- + # -- Green-Lagrange strain -- def test_green_lagrange_strain(self, svk_source: str) -> None: """E = 0.5 * (C - I) must be computed in the constitutive function.""" @@ -159,7 +159,7 @@ def test_green_lagrange_strain_half_factor(self, svk_source: str) -> None: func_src = _get_source_lines_for_function(svk_source, "constitutive_update") assert "0.5 * (C" in func_src - # -- P6.3.4: Right Cauchy-Green tensor -- + # -- Right Cauchy-Green tensor -- def test_right_cauchy_green(self, svk_source: str) -> None: """C = F^T @ F must be present in constitutive function.""" @@ -173,14 +173,14 @@ def test_cauchy_green_before_strain(self, svk_source: str) -> None: pos_e = func_src.find("E = 0.5 * (C - I3)") assert pos_c < pos_e, "C must be computed before E" - # -- P6.3.5: Identity matrix -- + # -- Identity matrix -- def test_identity_matrix_3x3(self, svk_source: str) -> None: """Constitutive uses a properly sized 3x3 identity.""" func_src = _get_source_lines_for_function(svk_source, "constitutive_update") assert "ti.Matrix.identity(ti.f64, 3)" in func_src - # -- P6.3.6: CSE opportunity — tr_E computed once and reused -- + # -- CSE opportunity — tr_E computed once and reused -- def test_trace_computed_once(self, svk_source: str) -> None: """tr_E should be computed exactly once (CSE opportunity).""" @@ -203,7 +203,7 @@ def test_trace_not_recomputed_in_stress(self, svk_source: str) -> None: # Should not have E.trace() — we use the precomputed tr_E assert "E.trace()" not in func_src - # -- P6.3.7: Constitutive function signature and decorator -- + # -- Constitutive function signature and decorator -- def test_constitutive_is_ti_func(self, svk_ast: ast.Module) -> None: """constitutive_update must be decorated with @ti.func.""" @@ -242,7 +242,7 @@ def test_constitutive_returns_S(self, svk_source: str) -> None: func_src = _get_source_lines_for_function(svk_source, "constitutive_update") assert "return S" in func_src - # -- P6.3.8: Cross-reference with symbolic model -- + # -- Cross-reference with symbolic model -- def test_svk_matches_symbolic_kinematics_chain(self, svk_source: str) -> None: """The constitutive function follows the kinematics chain F -> C -> E -> S. @@ -264,14 +264,14 @@ def test_svk_matches_symbolic_kinematics_chain(self, svk_source: str) -> None: # =========================================================================== -# P6.4: Internal force kernel emission tests +# Internal force kernel emission tests # =========================================================================== class TestInternalForceEmission: """P6.4 — Verify the internal force kernel emission is structurally correct.""" - # -- P6.4.1: Element loop is present and uses runtime range -- + # -- Element loop is present and uses runtime range -- def test_element_loop_runtime(self, svk_source: str) -> None: """Element loop must use runtime range (not ti.static).""" @@ -283,7 +283,7 @@ def test_element_loop_not_static(self, svk_source: str) -> None: kernel_src = _get_source_lines_for_function(svk_source, "compute_internal_force") assert "ti.static(range(n_elem))" not in kernel_src - # -- P6.4.2: Quadrature loop is present -- + # -- Quadrature loop is present -- def test_quadrature_loop_static(self, svk_source: str) -> None: """Quadrature loop uses ti.static (N_QP=8 is element-type constant).""" @@ -299,7 +299,7 @@ def test_quadrature_loop_inside_element_loop(self, svk_source: str) -> None: assert pos_q >= 0, "Quadrature loop missing" assert pos_e < pos_q, "Quadrature loop must be nested inside element loop" - # -- P6.4.3: Constitutive call present -- + # -- Constitutive call present -- def test_constitutive_call_inside_kernel(self, svk_source: str) -> None: """Internal force kernel must call constitutive_update.""" @@ -313,7 +313,7 @@ def test_constitutive_call_after_F_computation(self, svk_source: str) -> None: pos_s = kernel_src.find("S = constitutive_update(F, lam, mu)") assert pos_f < pos_s, "F must be computed before calling constitutive_update" - # -- P6.4.4: PK1 computation (P = F @ S) -- + # -- PK1 computation (P = F @ S) -- def test_pk1_stress(self, svk_source: str) -> None: """1st Piola-Kirchhoff stress P = F @ S must be computed.""" @@ -327,7 +327,7 @@ def test_pk1_after_constitutive(self, svk_source: str) -> None: pos_p = kernel_src.find("P = F @ S") assert pos_s < pos_p, "P must be computed after S" - # -- P6.4.5: Force scatter -- + # -- Force scatter -- def test_force_scatter_accumulation(self, svk_source: str) -> None: """Internal force kernel accumulates into f_int via atomic-safe pattern.""" @@ -346,7 +346,7 @@ def test_force_scatter_uses_detJ(self, svk_source: str) -> None: assert "detJ0 = J0.determinant()" in kernel_src assert "w_q * detJ0 * force_a[i]" in kernel_src - # -- P6.4.6: Nodal gathering -- + # -- Nodal gathering -- def test_reference_coords_gathered(self, svk_source: str) -> None: """Element gathers reference coordinates from x_ref.""" @@ -363,7 +363,7 @@ def test_connectivity_used(self, svk_source: str) -> None: kernel_src = _get_source_lines_for_function(svk_source, "compute_internal_force") assert "nid = elem_nodes[e, a]" in kernel_src - # -- P6.4.7: Kernel decorator -- + # -- Kernel decorator -- def test_internal_force_is_ti_kernel(self, svk_source: str) -> None: """compute_internal_force must be decorated with @ti.kernel.""" @@ -373,7 +373,7 @@ def test_internal_force_is_ti_kernel(self, svk_source: str) -> None: "compute_internal_force must be decorated with @ti.kernel" ) - # -- P6.4.8: Index partitioning correctness -- + # -- Index partitioning correctness -- def test_node_loops_use_runtime(self, svk_source: str) -> None: """Node loops (N_NODES=8 > 6) use runtime range per convention.""" @@ -392,7 +392,7 @@ def test_dim_loops_use_static(self, svk_source: str) -> None: assert "for i in ti.static(range(DIM)):" in kernel_src assert "for I in ti.static(range(DIM)):" in kernel_src - # -- P6.4.9: f_int zeroed before accumulation -- + # -- f_int zeroed before accumulation -- def test_f_int_zeroed(self, svk_source: str) -> None: """Internal force must be zeroed before the element loop.""" @@ -403,7 +403,7 @@ def test_f_int_zeroed(self, svk_source: str) -> None: assert pos_elem >= 0, "Element loop not found" assert pos_zero < pos_elem, "f_int must be zeroed before element loop" - # -- P6.4.10: Jacobian computation -- + # -- Jacobian computation -- def test_jacobian_computation(self, svk_source: str) -> None: """Reference Jacobian J0, its inverse, and determinant are computed.""" @@ -417,7 +417,7 @@ def test_shape_function_gradient_transform(self, svk_source: str) -> None: kernel_src = _get_source_lines_for_function(svk_source, "compute_internal_force") assert "dNdX = dN_dxi @ J0_inv" in kernel_src - # -- P6.4.11: AST structural checks -- + # -- AST structural checks -- def test_kernel_has_for_loops(self, svk_ast: ast.Module) -> None: """compute_internal_force must contain For loop nodes in its AST.""" @@ -431,7 +431,7 @@ def test_kernel_has_for_loops(self, svk_ast: ast.Module) -> None: # =========================================================================== -# P6.5: Tangent matvec emission tests +# Tangent matvec emission tests # =========================================================================== @@ -450,7 +450,7 @@ class TestTangentMatvecEmission: - the gather / scatter pattern via ``Kv_e`` accumulated per element """ - # -- P6.5.1: Analytical linearisation is element-local -- + # -- Analytical linearisation is element-local -- def test_loops_over_elements(self, svk_source: str) -> None: """Tangent matvec iterates over elements via a Python range loop.""" @@ -503,7 +503,7 @@ def test_element_scatter(self, svk_source: str) -> None: assert "Kv_e += w_q * detJ0 * (dN_dX @ dP.T)" in matvec_src assert "Kv[nodes[a]] += Kv_e[a]" in matvec_src - # -- P6.5.2: Non-mutating with respect to Taichi fields -- + # -- Non-mutating with respect to Taichi fields -- def test_reads_fields_via_to_numpy(self, svk_source: str) -> None: """Taichi fields are read once, never written.""" @@ -519,7 +519,7 @@ def test_does_not_call_internal_force(self, svk_source: str) -> None: matvec_src = _get_source_lines_for_function(svk_source, "tangent_matvec") assert "compute_internal_force(" not in matvec_src - # -- P6.5.3: Input/output shape handling -- + # -- Input/output shape handling -- def test_reshapes_input_vector(self, svk_source: str) -> None: """Input flat vector is reshaped to (n_nodes, 3).""" @@ -531,7 +531,7 @@ def test_returns_flat_vector(self, svk_source: str) -> None: matvec_src = _get_source_lines_for_function(svk_source, "tangent_matvec") assert ".ravel()" in matvec_src - # -- P6.5.4: Function signature -- + # -- Function signature -- def test_matvec_signature(self, svk_source: str) -> None: """tangent_matvec takes (v_flat, lam, mu) and returns ndarray.""" @@ -542,7 +542,7 @@ def test_matvec_signature(self, svk_source: str) -> None: assert "lam" in sig assert "mu" in sig - # -- P6.5.5: Not a ti.kernel (runs at Python level for numpy ops) -- + # -- Not a ti.kernel (runs at Python level for numpy ops) -- def test_matvec_is_python_function(self, svk_source: str) -> None: """tangent_matvec must NOT be a @ti.kernel (it uses numpy).""" @@ -554,14 +554,14 @@ def test_matvec_is_python_function(self, svk_source: str) -> None: # =========================================================================== -# P7.1: Newton driver emission tests +# Newton driver emission tests # =========================================================================== class TestNewtonDriverEmission: """P7.1 — Verify the Newton-Raphson driver emission.""" - # -- P7.1.1: Newton loop structure -- + # -- Newton loop structure -- def test_newton_iteration_loop(self, svk_source: str) -> None: """Newton driver has an iteration loop over max_iter.""" @@ -582,7 +582,7 @@ def test_tolerance_parameter(self, svk_source: str) -> None: sig = match.group(1) assert "tol" in sig - # -- P7.1.2: Residual computation -- + # -- Residual computation -- def test_residual_is_fint_minus_fext(self, svk_source: str) -> None: """Residual = f_int - f_ext (tension-positive convention).""" @@ -594,7 +594,7 @@ def test_residual_norm_computed(self, svk_source: str) -> None: driver_src = _get_source_lines_for_function(svk_source, "newton_solve") assert "np.linalg.norm(r_flat)" in driver_src - # -- P7.1.3: Convergence check -- + # -- Convergence check -- def test_convergence_check_against_tol(self, svk_source: str) -> None: """Convergence check: res_norm < conv_threshold (dual abs/rel tolerance).""" @@ -613,7 +613,7 @@ def test_convergence_check_before_solve(self, svk_source: str) -> None: pos_solve = driver_src.find("solver.solve(") assert pos_check < pos_solve, "Convergence check must precede linear solve" - # -- P7.1.4: Linear solve -- + # -- Linear solve -- def test_linear_solve_call(self, svk_source: str) -> None: """Newton driver calls the project's CGSolver for the linear system.""" @@ -636,7 +636,7 @@ def test_cg_convergence_warning(self, svk_source: str) -> None: driver_src = _get_source_lines_for_function(svk_source, "newton_solve") assert "cg_res" in driver_src - # -- P7.1.5: Displacement update -- + # -- Displacement update -- def test_displacement_update(self, svk_source: str) -> None: """u += du update must be present.""" @@ -648,7 +648,7 @@ def test_displacement_written_back(self, svk_source: str) -> None: driver_src = _get_source_lines_for_function(svk_source, "newton_solve") assert "u.from_numpy(u_arr + du_arr)" in driver_src - # -- P7.1.6: Newton loop ordering -- + # -- Newton loop ordering -- def test_newton_step_ordering(self, svk_source: str) -> None: """Newton steps must be in order: f_int, residual, check, solve, update.""" @@ -666,7 +666,7 @@ def test_newton_step_ordering(self, svk_source: str) -> None: "Newton steps out of order: expected f_int -> residual -> check -> solve -> update" ) - # -- P7.1.7: Non-convergence handling -- + # -- Non-convergence handling -- def test_raises_on_non_convergence(self, svk_source: str) -> None: """Driver raises RuntimeError if convergence is not reached.""" @@ -674,14 +674,14 @@ def test_raises_on_non_convergence(self, svk_source: str) -> None: assert "raise RuntimeError" in driver_src assert "did not converge" in driver_src - # -- P7.1.8: Internal force is called within Newton -- + # -- Internal force is called within Newton -- def test_newton_calls_internal_force(self, svk_source: str) -> None: """Newton driver calls compute_internal_force.""" driver_src = _get_source_lines_for_function(svk_source, "newton_solve") assert "compute_internal_force(lam, mu)" in driver_src - # -- P7.1.9: Solver import -- + # -- Solver import -- def test_project_solver_import(self, svk_source: str) -> None: """Newton driver imports the project's CGSolver, not scipy.""" @@ -689,14 +689,14 @@ def test_project_solver_import(self, svk_source: str) -> None: assert "from mechdsl.solver.import_adapter import CGSolver" in driver_src assert "scipy" not in driver_src - # -- P7.1.10: n_dof computation -- + # -- n_dof computation -- def test_n_dof_computation(self, svk_source: str) -> None: """Newton driver computes n_dof = n_nodes * DIM.""" driver_src = _get_source_lines_for_function(svk_source, "newton_solve") assert "n_dof = n_nodes * DIM" in driver_src - # -- P7.1.11: AST structural checks -- + # -- AST structural checks -- def test_newton_has_for_loop(self, svk_ast: ast.Module) -> None: """newton_solve must contain at least one For loop (the Newton iteration).""" @@ -756,9 +756,8 @@ def test_no_placeholder_todos_in_svk(self, svk_source: str) -> None: assert "TODO" not in func_src, ( "SVK constitutive should not have TODO placeholders" ) # intentional-cleanup-site - # post_recovery_plan Phase 6 (P6-4): the two `# intentional-cleanup-site` - # markers above are scanned by test_phase6_exit.py in place of the - # previously-hardcoded line-number whitelist `_INTENTIONAL_CLEANUP_MATCHES`. + # The two `# intentional-cleanup-site` markers above are scanned by + # test_phase6_exit.py in place of a hardcoded line-number whitelist. def test_consistent_lame_parameter_names(self, svk_source: str) -> None: """Lame parameters are consistently named 'lam' and 'mu' throughout.""" diff --git a/packages/mechdsl-core/tests/test_energy_codegen_svk.py b/packages/mechdsl-core/tests/test_energy_codegen_svk.py index a1e9c7c..e7b343a 100644 --- a/packages/mechdsl-core/tests/test_energy_codegen_svk.py +++ b/packages/mechdsl-core/tests/test_energy_codegen_svk.py @@ -102,8 +102,8 @@ def test_generated_svk_func_matches_oracle(tmp_path): def test_example_tex_file_round_trips(): - """The committed dev/examples/svk_energy.tex compiles through the slice.""" - tex = Path(__file__).resolve().parents[3] / "dev" / "examples" / "svk_energy.tex" + """The committed examples/svk_energy.tex compiles through the slice.""" + tex = Path(__file__).resolve().parents[3] / "examples" / "svk_energy.tex" model = derive_from_energy(tex.read_text()) assert {orig for orig in model.parameters.values()} == {"lambda"} src = emit_constitutive_func(model) diff --git a/packages/mechdsl-core/tests/test_explicit_dynamics_acceptance.py b/packages/mechdsl-core/tests/test_explicit_dynamics_acceptance.py index f57b680..96f5bfd 100644 --- a/packages/mechdsl-core/tests/test_explicit_dynamics_acceptance.py +++ b/packages/mechdsl-core/tests/test_explicit_dynamics_acceptance.py @@ -40,7 +40,7 @@ # --------------------------------------------------------------------------- -# Mesh / module helpers (inlined per P7-3 spec: self-contained test file) +# Mesh / module helpers (inlined: self-contained test file) # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/test_explicit_integrator.py b/packages/mechdsl-core/tests/test_explicit_integrator.py index d9fb272..d834735 100644 --- a/packages/mechdsl-core/tests/test_explicit_integrator.py +++ b/packages/mechdsl-core/tests/test_explicit_integrator.py @@ -69,15 +69,15 @@ def _consistent_mass_hex8(coords: np.ndarray, conn: np.ndarray, rho: float) -> n ndof = 3 * n_nodes M = np.zeros((ndof, ndof), dtype=np.float64) for e in range(conn.shape[0]): - X_elem = coords[conn[e]] # (8, 3) + X_elem = coords[conn[e]] for q in range(SHAPE_AT_QUAD.shape[0]): J0 = X_elem.T @ GRAD_AT_QUAD[q] detJ0 = float(np.linalg.det(J0)) w = float(HEX8_QUAD_WEIGHTS[q]) - N = SHAPE_AT_QUAD[q] # (8,) + N = SHAPE_AT_QUAD[q] # M_ab = integral rho * N_a * N_b dV -- scalar per node pair, # replicated across the 3 spatial DoFs (isotropic translational mass). - Me_scalar = rho * np.outer(N, N) * detJ0 * w # (8, 8) + Me_scalar = rho * np.outer(N, N) * detJ0 * w for a in range(8): for b in range(8): for i in range(3): @@ -193,7 +193,6 @@ def _emit(mode: DynamicsMode) -> str: static_src = _emit(DynamicsMode.STATIC) explicit_src = _emit(DynamicsMode.EXPLICIT) - # Sources must differ. assert static_src != explicit_src # STATIC: Newton driver present, explicit driver absent. diff --git a/packages/mechdsl-core/tests/test_formulation_switching.py b/packages/mechdsl-core/tests/test_formulation_switching.py index ebea862..cf1fe10 100644 --- a/packages/mechdsl-core/tests/test_formulation_switching.py +++ b/packages/mechdsl-core/tests/test_formulation_switching.py @@ -62,11 +62,6 @@ def _compile_from_context(ctx: dict[str, Any]) -> str: return emit(bundle) -# --------------------------------------------------------------------------- -# P1-6 Tests -# --------------------------------------------------------------------------- - - class TestTaskP1_6FormulationSwitching: """ Tests for Task P1-6: Formulation switching (directive + codegen dispatch) @@ -141,8 +136,8 @@ def test_tl_rejection_behaviour_unchanged_for_other_non_mvp_values(self) -> None boundaries=_MVP_BOUNDARIES, ) - # tet4 + reduced integration still rejected (Plan B phase B5). - # tet4 + full is now accepted after the P5-6 ElementFactory wiring. + # tet4 + reduced integration is still rejected; tet4 + full is accepted + # (ElementFactory wiring). with pytest.raises(UnsupportedError, match="Plan B"): build_context( dim=3, @@ -154,7 +149,7 @@ def test_tl_rejection_behaviour_unchanged_for_other_non_mvp_values(self) -> None integration="reduced", ) - # lemaitre_damage still rejected (damage lands in Plan B phase B6) + # lemaitre_damage (damage models) still rejected with pytest.raises(UnsupportedError, match="lemaitre_damage"): build_context( dim=3, diff --git a/packages/mechdsl-core/tests/test_frontend_build_context.py b/packages/mechdsl-core/tests/test_frontend_build_context.py index 3114311..fc0e8fc 100644 --- a/packages/mechdsl-core/tests/test_frontend_build_context.py +++ b/packages/mechdsl-core/tests/test_frontend_build_context.py @@ -74,9 +74,8 @@ def test_dict_contains_all_required_keys(self) -> None: "boundaries", "coord_system", "hourglass_coef", - # Plan B phase B5 (task P5-6) added the integration / hourglass - # selectors so the LaTeX `% mechanics cell` directive can carry - # element-discretisation choices end-to-end. + # The integration / hourglass selectors let the LaTeX `% mechanics cell` + # directive carry element-discretisation choices end-to-end. "integration", "hourglass", } @@ -247,8 +246,6 @@ def test_P1_valid_mvp_source_correct_dict_structure(self) -> None: "boundaries", "coord_system", "hourglass_coef", - # Plan B phase B5 (task P5-6) — see TestBuildContextBasics - # for the rationale. "integration", "hourglass", } diff --git a/packages/mechdsl-core/tests/test_hex20_basis.py b/packages/mechdsl-core/tests/test_hex20_basis.py index 88b9c74..bc17bee 100644 --- a/packages/mechdsl-core/tests/test_hex20_basis.py +++ b/packages/mechdsl-core/tests/test_hex20_basis.py @@ -110,7 +110,7 @@ def test_hex20_quadratic_field_exactness(self): + xi^2 + eta^2 + zeta^2 """ # Use the reference element (parametric = physical) - X = HEX20_NODE_COORDS # shape (20, 3) + X = HEX20_NODE_COORDS def u_exact(xyz: np.ndarray) -> float: x, y, z = float(xyz[0]), float(xyz[1]), float(xyz[2]) @@ -157,7 +157,7 @@ def test_hex20_jacobian_positive_on_regular_hex(self): On the reference element (X_elem = HEX20_NODE_COORDS), the map is the identity, so J = I and det(J) = 1.0 at every point. """ - X_elem = HEX20_NODE_COORDS # shape (20, 3) + X_elem = HEX20_NODE_COORDS det_vals = [] for q in range(27): diff --git a/packages/mechdsl-core/tests/test_hex8_tables.py b/packages/mechdsl-core/tests/test_hex8_tables.py index 8d97fc1..a89e3b8 100644 --- a/packages/mechdsl-core/tests/test_hex8_tables.py +++ b/packages/mechdsl-core/tests/test_hex8_tables.py @@ -140,15 +140,12 @@ def test_gradient_fd(self): grad_analytic = shape_gradients(xi, eta, zeta) grad_fd = np.empty((8, 3), dtype=np.float64) - # d/dxi grad_fd[:, 0] = ( shape_functions(xi + h, eta, zeta) - shape_functions(xi - h, eta, zeta) ) / (2.0 * h) - # d/deta grad_fd[:, 1] = ( shape_functions(xi, eta + h, zeta) - shape_functions(xi, eta - h, zeta) ) / (2.0 * h) - # d/dzeta grad_fd[:, 2] = ( shape_functions(xi, eta, zeta + h) - shape_functions(xi, eta, zeta - h) ) / (2.0 * h) @@ -216,16 +213,16 @@ def test_constant_strain_recovery(self): A = rng.uniform(-0.05, 0.05, size=(3, 3)) # Unit cube element - X_elem = (HEX8_NODE_COORDS + 1.0) / 2.0 # (8, 3) + X_elem = (HEX8_NODE_COORDS + 1.0) / 2.0 # Nodal displacements from linear field: u_a = A @ X_a - u_elem = X_elem @ A.T # (8, 3) + u_elem = X_elem @ A.T for q in range(8): dNdX, _detJ0 = reference_gradient_at_physical(X_elem, q) # Displacement gradient at quad point: du/dX = u^T @ dN/dX - grad_u = u_elem.T @ dNdX # (3, 3) + grad_u = u_elem.T @ dNdX np.testing.assert_allclose( grad_u, @@ -263,7 +260,7 @@ def test_grad_at_quad_shape(self): # --------------------------------------------------------------------------- -# R3.5.2 — T3: Degenerate element error path +# Degenerate element error path # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/test_hgo_benchmark.py b/packages/mechdsl-core/tests/test_hgo_benchmark.py index b6564aa..2168c01 100644 --- a/packages/mechdsl-core/tests/test_hgo_benchmark.py +++ b/packages/mechdsl-core/tests/test_hgo_benchmark.py @@ -78,7 +78,6 @@ # stays well-behaved on a 1-element mesh. _STRETCHES = (1.02, 1.05, 1.08, 1.12) -# 5% relative-error envelope from the task JSON. _REL_ERR_TOL = 0.05 diff --git a/packages/mechdsl-core/tests/test_hgo_solver_e2e.py b/packages/mechdsl-core/tests/test_hgo_solver_e2e.py index 4ff3156..84b61db 100644 --- a/packages/mechdsl-core/tests/test_hgo_solver_e2e.py +++ b/packages/mechdsl-core/tests/test_hgo_solver_e2e.py @@ -31,7 +31,7 @@ if TYPE_CHECKING: from types import ModuleType -_EXAMPLES_DIR = Path(__file__).resolve().parents[3] / "dev" / "examples" +_EXAMPLES_DIR = Path(__file__).resolve().parents[3] / "examples" _HGO_TEX = _EXAMPLES_DIR / "hgo_energy.tex" # Single-family HGO; fiber along x. The energy derives params {k1, k2, kappa, mu}. @@ -98,7 +98,7 @@ def test_emitted_solver_gathers_fiber_and_is_parameterised(tmp_path): # --------------------------------------------------------------------------- -# Slow e2e tests — the generated solver runs under Taichi JIT (the real gate) +# Slow e2e tests — the generated solver actually runs under Taichi JIT # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/test_hourglass_control.py b/packages/mechdsl-core/tests/test_hourglass_control.py index 288af6b..0e7b266 100644 --- a/packages/mechdsl-core/tests/test_hourglass_control.py +++ b/packages/mechdsl-core/tests/test_hourglass_control.py @@ -24,9 +24,8 @@ # --------------------------------------------------------------------------- # Helpers — direct single-element residual computation. # -# A full assembly pipeline for reduced-integration Hex8 is the job of P5-6 -# and P5-7. For the acceptance tests here we compute the residual of a -# single element using a 1-point (centroid) quadrature rule — this is the +# These tests bypass the full assembly pipeline: the residual of a single +# element is computed with a 1-point (centroid) quadrature rule — the # reduced-integration scheme that the hourglass control stabilises — plus # (optionally) the Flanagan-Belytschko hourglass correction. # --------------------------------------------------------------------------- @@ -39,13 +38,13 @@ def _one_point_svk_force( # Centroid (xi=eta=zeta=0) : dN/dxi = HEX8_NODE_COORDS / 8 from mechdsl.codegen.hex8_tables import shape_gradients - dN_dxi = shape_gradients(0.0, 0.0, 0.0) # (8, 3) + dN_dxi = shape_gradients(0.0, 0.0, 0.0) J0 = X_elem.T @ dN_dxi detJ0 = float(np.linalg.det(J0)) J0_inv = np.linalg.inv(J0) - dN_dX = dN_dxi @ J0_inv # (8, 3) + dN_dX = dN_dxi @ J0_inv - grad_u = u_elem.T @ dN_dX # (3, 3) + grad_u = u_elem.T @ dN_dX F = np.eye(3) + grad_u E = 0.5 * (F.T @ F - np.eye(3)) tr_E = np.trace(E) diff --git a/packages/mechdsl-core/tests/test_integration_surface.py b/packages/mechdsl-core/tests/test_integration_surface.py index 734d2ea..4fcbd7a 100644 --- a/packages/mechdsl-core/tests/test_integration_surface.py +++ b/packages/mechdsl-core/tests/test_integration_surface.py @@ -32,8 +32,8 @@ import pytest # --------------------------------------------------------------------------- -# Shared LaTeX fixtures (reused from P1-2 test; duplicated here to keep this -# file fully self-contained — it is the canonical surface test). +# Shared LaTeX fixtures (duplicated here to keep this file fully +# self-contained — it is the canonical surface test). # --------------------------------------------------------------------------- _PROBLEM_SOURCE_SVK_NAMED = """\ diff --git a/packages/mechdsl-core/tests/test_j2.py b/packages/mechdsl-core/tests/test_j2.py index 4755854..742462b 100644 --- a/packages/mechdsl-core/tests/test_j2.py +++ b/packages/mechdsl-core/tests/test_j2.py @@ -350,7 +350,7 @@ def test_uniaxial_tension_elastic() -> None: # --------------------------------------------------------------------------- -# R3.5.1 — Error path tests (T1, T2) +# Error path tests # --------------------------------------------------------------------------- @@ -373,7 +373,7 @@ def test_radial_return_stalled_newton(self) -> None: try: result = radial_return(mat, E_strain, alpha_old=0.0) assert result.delta_lambda >= 0.0 - assert result.is_plastic # must have entered plastic regime + assert result.is_plastic except RuntimeError: pass # Expected: stall or non-convergence @@ -388,7 +388,7 @@ def test_negative_delta_lambda_guard(self) -> None: # --------------------------------------------------------------------------- -# R3.5.3 — __post_init__ validation tests for J2PowerLawMaterial +# __post_init__ validation tests for J2PowerLawMaterial # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/test_johnson_cook.py b/packages/mechdsl-core/tests/test_johnson_cook.py index 819f058..de501ec 100644 --- a/packages/mechdsl-core/tests/test_johnson_cook.py +++ b/packages/mechdsl-core/tests/test_johnson_cook.py @@ -438,11 +438,6 @@ def test_elastic_step_returns_trial_stress(self): np.testing.assert_allclose(res.stress, S_expected, atol=1e-14) -# --------------------------------------------------------------------------- -# Task P3-3 stubs (do not modify — for Task P3-3) -# --------------------------------------------------------------------------- - - class TestTaskP3_3JohnsonCookTangent: """ Tests for Task P3-3: Consistent viscoplastic algorithmic tangent (JC). @@ -506,7 +501,7 @@ def test_jc_tangent_fd_check(self): E = 0.03 * 0.5 * (A + A.T) result = radial_return(mat, E, alpha_old, T_old, dt) - C_analytical = result.tangent # (3, 3, 3, 3) + C_analytical = result.tangent C_fd = self._fd_tangent(mat, E, alpha_old, T_old, dt) diff --git a/packages/mechdsl-core/tests/test_kinematics_ul.py b/packages/mechdsl-core/tests/test_kinematics_ul.py index 7a1265d..162f404 100644 --- a/packages/mechdsl-core/tests/test_kinematics_ul.py +++ b/packages/mechdsl-core/tests/test_kinematics_ul.py @@ -57,7 +57,7 @@ def test_current_jacobian_identity_at_f_eq_i(self) -> None: X_elem = _unit_cube_nodes() x_elem = X_elem # F = I => current coords = reference coords for q in range(8): - dN_dxi = GRAD_AT_QUAD[q] # (8, 3) + dN_dxi = GRAD_AT_QUAD[q] J0 = X_elem.T @ dN_dxi j = current_jacobian(dN_dxi, x_elem) assert np.allclose(j, J0, atol=1e-14), f"QP {q}: j != J0 at F = I" diff --git a/packages/mechdsl-core/tests/test_lemaitre_acceptance.py b/packages/mechdsl-core/tests/test_lemaitre_acceptance.py index 8d417b9..c92589c 100644 --- a/packages/mechdsl-core/tests/test_lemaitre_acceptance.py +++ b/packages/mechdsl-core/tests/test_lemaitre_acceptance.py @@ -603,7 +603,7 @@ def test_notched_bar_damage_localises_at_notch_root(self, tmp_path: Path) -> Non n_elem = conn.shape[0] # Element centroids for geometric queries - centroids = coords[conn].mean(axis=1) # (n_elem, 3) + centroids = coords[conn].mean(axis=1) # --- BCs: left face (x=0) clamped, right face (x=L) prescribed x-disp --- x_left = np.where(np.abs(coords[:, 0] - 0.0) < 1e-12)[0] @@ -613,8 +613,7 @@ def test_notched_bar_damage_localises_at_notch_root(self, tmp_path: Path) -> Non bc_mask[x_right, 0] = True # --- Loading: small strain increments (<=0.5%/step) so the undamaged - # J2 tangent (Option A, Gate B forward-warning #2) can still - # drive Newton to convergence under active damage. --- + # J2 tangent can still drive Newton to convergence under active damage. --- target_eng_strain = 0.02 # 2 % engineering strain total_disp = target_eng_strain * L n_steps = 8 # 0.25 % strain / step @@ -685,15 +684,14 @@ def test_notched_bar_damage_localises_at_notch_root(self, tmp_path: Path) -> Non nu_val=_NU, D_crit=D_crit, tol=1e-7, - max_iter=40, # >= 30 per Gate B guidance + max_iter=40, ) # --- Extract per-element damage (max over QPs) --- - damage_qp = mod.damage_D.to_numpy() # (n_elem, n_qp) + damage_qp = mod.damage_D.to_numpy() assert damage_qp.shape == (n_elem, 8) damage_elem = damage_qp.max(axis=1) - # Sanity: damage actually grew somewhere (otherwise the test is vacuous) D_max = float(damage_elem.max()) assert D_max > 0.0, f"No damage accumulated; test is vacuous (D_max={D_max:.3e})" @@ -808,7 +806,7 @@ def test_generated_lemaitre_newton_driver_committed_history_vs_ref( bundle = mechdsl_compile(lem_ir) source = bundle.emitted_source - # --- Structural guard (WI-A fix): the generated driver must snapshot AND + # --- Structural guard: the generated driver must snapshot AND # restore the damage history (damage_D + is_deleted), not just alpha. ns_start = source.find("def newton_solve(") assert ns_start >= 0, "generated Lemaitre module missing newton_solve driver" diff --git a/packages/mechdsl-core/tests/test_lemaitre_codegen.py b/packages/mechdsl-core/tests/test_lemaitre_codegen.py index 0f59b62..42287ad 100644 --- a/packages/mechdsl-core/tests/test_lemaitre_codegen.py +++ b/packages/mechdsl-core/tests/test_lemaitre_codegen.py @@ -71,7 +71,7 @@ def _make_j2_bundle() -> ArtifactBundle: # ============================================================================ -# P6-2: Lemaitre damage coupling + element deletion +# Lemaitre damage coupling + element deletion # ============================================================================ diff --git a/packages/mechdsl-core/tests/test_mechanics_ir.py b/packages/mechdsl-core/tests/test_mechanics_ir.py index 0dac97e..24f691f 100644 --- a/packages/mechdsl-core/tests/test_mechanics_ir.py +++ b/packages/mechdsl-core/tests/test_mechanics_ir.py @@ -152,7 +152,7 @@ def test_json_types_are_primitive(self): # ------------------------------------------------------------------ -# 4. Invalid dim (dim=2) raises ValueError with plan phase reference +# 4. Invalid dim (dim=2) raises ValueError # ------------------------------------------------------------------ @@ -185,7 +185,7 @@ def test_dim_1_rejected(self): # ------------------------------------------------------------------ -# 5. Invalid formulation raises with plan phase reference +# 5. Invalid formulation raises # ------------------------------------------------------------------ @@ -241,7 +241,7 @@ def test_formulation_configuration_consistency_guard(self) -> None: # ------------------------------------------------------------------ -# 6. Invalid element type raises with plan phase reference +# 6. Invalid element type raises # ------------------------------------------------------------------ diff --git a/packages/mechdsl-core/tests/test_mechanics_ir_configuration.py b/packages/mechdsl-core/tests/test_mechanics_ir_configuration.py index c885e74..e97d694 100644 --- a/packages/mechdsl-core/tests/test_mechanics_ir_configuration.py +++ b/packages/mechdsl-core/tests/test_mechanics_ir_configuration.py @@ -91,7 +91,7 @@ def test_problem_ir_reference_configuration_matches_plan_a_baseline(self) -> Non assert ir.configuration is Configuration.REFERENCE d = ir.to_dict() - # Pre-P1-1 tests may not know about this key, but it MUST round-trip. + # Older tests may not know about this key, but it MUST round-trip. assert d["configuration"] == "reference" ir_round = ProblemIR.from_dict(d) @@ -190,7 +190,6 @@ def test_supported_subset_rejection_still_fires_for_unrelated_guards(self) -> No Acceptance criterion: "Plan A rejection tests kept as a regression guard pinning reference configuration behaviour" — the other rejection surfaces remain in effect. """ - # dim=2 still rejected (Plan B phase B2) with pytest.raises(UnsupportedError, match="Plan B phase B2"): build_context( dim=2, @@ -200,8 +199,8 @@ def test_supported_subset_rejection_still_fires_for_unrelated_guards(self) -> No params={"E": 1.0, "nu": 0.3}, boundaries=[], ) - # tet4 + reduced integration still rejected (Plan B phase B5). - # tet4 with the default full integration is supported after P5-6. + # tet4 + reduced integration is rejected. + # tet4 with the default full integration is supported. with pytest.raises(UnsupportedError, match="Plan B phase B5"): build_context( dim=3, diff --git a/packages/mechdsl-core/tests/test_mesh_io.py b/packages/mechdsl-core/tests/test_mesh_io.py index 086ffc9..7849a2f 100644 --- a/packages/mechdsl-core/tests/test_mesh_io.py +++ b/packages/mechdsl-core/tests/test_mesh_io.py @@ -189,7 +189,7 @@ def test_all_elements_positive_volume(self, nx: int, ny: int, nz: int): """All elements have positive Jacobian determinant at center.""" mesh = generate_hex8_mesh(nx, ny, nz) for e in range(mesh.n_elem): - X_elem = mesh.coords[mesh.connectivity[e]] # (8, 3) + X_elem = mesh.coords[mesh.connectivity[e]] # Compute Jacobian at element center (xi=eta=zeta=0) # dN/dxi at center for Hex8 dN_dxi = ( @@ -208,7 +208,7 @@ def test_all_elements_positive_volume(self, nx: int, ny: int, nz: int): ) / 8.0 ) - J0 = X_elem.T @ dN_dxi # (3, 3) + J0 = X_elem.T @ dN_dxi detJ0 = np.linalg.det(J0) assert detJ0 > 0.0, f"Element {e} has non-positive Jacobian ({detJ0:.6e})" @@ -276,7 +276,7 @@ def test_matches_ref_coords(self): # --------------------------------------------------------------------------- -# R3.5.3 — __post_init__ validation tests for HexMesh +# __post_init__ validation tests for HexMesh # --------------------------------------------------------------------------- @@ -325,7 +325,7 @@ def test_n_elem_mismatch(self) -> None: # ------------------------------------------------------------------ -# Cook's membrane mesh (P2-1, P2-2) +# Cook's membrane mesh # ------------------------------------------------------------------ @@ -409,15 +409,9 @@ def test_positive_jacobians(self) -> None: for e in range(mesh.n_elem): nodes = conn[e] # 8 node indices - pts = coords[nodes] # (8, 3) - # Centroid of element in reference (xi=eta=zeta=0) - # Approximate Jacobian using corner differences - # dX/dxi ~ (x1-x0+x5-x4+x2-x3+x6-x7)/4 etc. - # Simpler: compute volume via cross products of edge vectors - # Use the standard tri-linear Jacobian at xi=eta=zeta=0 - # J = 1/8 * [sum of partial derivatives at center] - # For simplicity use the 8-node mean Jacobian approximation: - # det(J) at center ~ vol / 8 (must be > 0) + pts = coords[nodes] + # Positive-orientation check: det(J) at the element centre is approximated + # by the mean tri-linear Jacobian, det(J) ~ vol/8, and must be > 0. x = pts[:, 0] y = pts[:, 1] z = pts[:, 2] @@ -527,7 +521,7 @@ def test_corners_and_boundary_tags_multi_density(self, nx: int, ny: int, nz: int # --------------------------------------------------------------------------- -# Phase 3: Necking bar mesh geometry (P3-1 / P3-2) +# Necking bar mesh geometry # --------------------------------------------------------------------------- @@ -664,7 +658,7 @@ def test_positive_jacobians(self) -> None: for e in range(mesh.n_elem): nodes = conn[e] - pts = coords[nodes] # (8, 3) + pts = coords[nodes] x = pts[:, 0] y = pts[:, 1] z = pts[:, 2] diff --git a/packages/mechdsl-core/tests/test_metric_assign_directives.py b/packages/mechdsl-core/tests/test_metric_assign_directives.py index 985a62a..ac5f155 100644 --- a/packages/mechdsl-core/tests/test_metric_assign_directives.py +++ b/packages/mechdsl-core/tests/test_metric_assign_directives.py @@ -99,7 +99,7 @@ def test_both_flags_raises_parse_error(self): @pytest.mark.e2e @pytest.mark.xfail( - reason="e2e metric propagation requires build_context metric wiring — deferred to P10-1 (SOSOVSKI/MechDSL#79)", + reason="e2e metric propagation requires build_context metric wiring — deferred (tracked internally)", strict=False, ) def test_cylindrical_metric_propagates_to_element_ir(self): diff --git a/packages/mechdsl-core/tests/test_mfem_printer.py b/packages/mechdsl-core/tests/test_mfem_printer.py index 5fd395e..ef2f481 100644 --- a/packages/mechdsl-core/tests/test_mfem_printer.py +++ b/packages/mechdsl-core/tests/test_mfem_printer.py @@ -193,11 +193,6 @@ def _structural_cpp_checks(source: str) -> None: assert not stray_tokens, f"Unexpected placeholder tokens left behind: {stray_tokens}" -# --------------------------------------------------------------------------- -# Task P8-1 acceptance-criteria tests -# --------------------------------------------------------------------------- - - class TestTaskP8_1: """Tests for Task P8-1: MFEM printer (C++ NonlinearFormIntegrator).""" @@ -244,7 +239,7 @@ def test_cmakelists_template_present(self) -> None: # --------------------------------------------------------------------------- -# Failure-route tests (defensive coverage, Step 8 in the task brief) +# Failure-route tests (defensive coverage) # --------------------------------------------------------------------------- @@ -301,11 +296,6 @@ def test_cmakelists_emission_substitutes_name(self, svk_bundle: ArtifactBundle) assert "@MECHDSL_EXE_NAME@" in raw -# --------------------------------------------------------------------------- -# Gate B follow-up tests — correct tangent shape + deferral markers. -# --------------------------------------------------------------------------- - - class TestTangentAndDeferrals: """Ensure the fixed tangent keeps shear rows/cols and carries scope markers.""" diff --git a/packages/mechdsl-core/tests/test_newton.py b/packages/mechdsl-core/tests/test_newton.py index f4a9f1c..9b27422 100644 --- a/packages/mechdsl-core/tests/test_newton.py +++ b/packages/mechdsl-core/tests/test_newton.py @@ -99,7 +99,7 @@ def elastic_setup() -> dict: # ============================================================================ -# P3-T1: newton_solve import/smoke +# newton_solve import/smoke # ============================================================================ @@ -127,7 +127,7 @@ def test_newton_result_fields(self): # ============================================================================ -# P3-T2: Newton driver unit tests +# Newton driver unit tests # ============================================================================ @@ -159,7 +159,6 @@ def tangent_mv(u_: np.ndarray, v: np.ndarray) -> np.ndarray: assert result.converged assert result.n_iterations < 20 - # Residual should decrease monotonically (at least eventually) assert result.residual_history[-1] < result.residual_history[0] def test_divergence_returns_not_converged(self): @@ -319,7 +318,7 @@ def identity_matvec(u_: np.ndarray, v: np.ndarray) -> np.ndarray: # ============================================================================ -# P3-T3: Newton + load_stepping integration test +# Newton + load_stepping integration test # ============================================================================ diff --git a/packages/mechdsl-core/tests/test_notched_bar_benchmark.py b/packages/mechdsl-core/tests/test_notched_bar_benchmark.py index 5a7f3ae..68f80aa 100644 --- a/packages/mechdsl-core/tests/test_notched_bar_benchmark.py +++ b/packages/mechdsl-core/tests/test_notched_bar_benchmark.py @@ -67,7 +67,7 @@ # Reference (self-consistent) mesh + parameters + load schedule # --------------------------------------------------------------------------- -# Mesh: matches the P6-3 unit-test geometry exactly. +# Mesh: must match the notched-bar unit-test geometry exactly. _N_LEN = 6 _N_HEIGHT = 3 _N_THICK = 1 @@ -78,13 +78,13 @@ _NOTCH_HALFWIDTH = 1.0 # Material (steel-like, MPa/mm), mirrors _lemaitre_acceptance defaults plus -# the P6-3 notched-bar overrides (linear hardening + active damage). +# the notched-bar overrides (linear hardening + active damage). _MATERIAL = { "E": 200.0e3, # MPa "nu": 0.3, "sigma_y0": 200.0, # MPa "K": 100.0, # MPa, linear hardening modulus - "n": 1.0, # linear hardening (n=1) -- see P6-3 docstring + "n": 1.0, # linear hardening (n=1) "S_d": 2.0, # MPa, Lemaitre damage denominator "s_d": 1.0, # linear damage evolution "eps_D": 0.0, # damage active as soon as alpha > 0 @@ -92,9 +92,8 @@ } # Load schedule: 2% engineering strain in 8 equal steps (0.25% per step). -# Matches the P6-2 Gate B forward-warning that the undamaged-J2 tangent -# needs <=0.5 %/step for super-linear Newton convergence under active -# damage. +# The undamaged-J2 tangent needs <=0.5 %/step for super-linear Newton +# convergence under active damage. _TARGET_ENG_STRAIN = 0.02 _TOTAL_DISPLACEMENT = _TARGET_ENG_STRAIN * _L _N_STEPS = 8 @@ -136,7 +135,6 @@ dtype=np.float64, ) -# Per-sample tolerance. The task JSON specifies 10%. _LOAD_TOL_REL = 0.10 diff --git a/packages/mechdsl-core/tests/test_objective_rates.py b/packages/mechdsl-core/tests/test_objective_rates.py index a2dcb79..fc1f686 100644 --- a/packages/mechdsl-core/tests/test_objective_rates.py +++ b/packages/mechdsl-core/tests/test_objective_rates.py @@ -156,7 +156,7 @@ def test_truesdell_tangent_full_f_simple_shear_matches_b_formula(self) -> None: """ gamma = 0.5 F = np.eye(3) - F[0, 1] = gamma # F = [[1, gamma, 0], [0, 1, 0], [0, 0, 1]] + F[0, 1] = gamma assert abs(np.linalg.det(F) - 1.0) < 1e-14, "simple shear must be isochoric" # Isotropic shear-only tangent (lam = 0). diff --git a/packages/mechdsl-core/tests/test_ogden_solver_e2e.py b/packages/mechdsl-core/tests/test_ogden_solver_e2e.py index 876d3b0..ee029a5 100644 --- a/packages/mechdsl-core/tests/test_ogden_solver_e2e.py +++ b/packages/mechdsl-core/tests/test_ogden_solver_e2e.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: from types import ModuleType -_EXAMPLES_DIR = Path(__file__).resolve().parents[3] / "dev" / "examples" +_EXAMPLES_DIR = Path(__file__).resolve().parents[3] / "examples" _OGDEN_TEX = _EXAMPLES_DIR / "ogden_energy.tex" # Two-term compressible Ogden. Positive exponents keep the directive scalars free diff --git a/packages/mechdsl-core/tests/test_p9_1_family_spec_completeness.py b/packages/mechdsl-core/tests/test_p9_1_family_spec_completeness.py deleted file mode 100644 index f6bea27..0000000 --- a/packages/mechdsl-core/tests/test_p9_1_family_spec_completeness.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Task P9-1: spec-completeness tests for the contraction-family registry. - -These tests assert that the registry in ``mechdsl.codegen.family_registry`` -mirrors the authoritative spec at -``dev/design_docs/09-EINSUM-OPTIMISER.md §9`` and covers every contraction -that currently flows through the codegen pipeline. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from mechdsl.codegen.family_registry import ( - ELEMENT_BACKEND_COVERAGE, - EMISSION_SHAPES, - FAMILIES, - Family, - classify_einsum_string, -) - -_KNOWN_EINSUMS: tuple[tuple[str, list[tuple[int, ...]]], ...] = ( - ("qaI,ai->qiI", [(8, 8, 3), (8, 3)]), - ("qaI,qiI->qai", [(8, 8, 3), (8, 3, 3)]), - ("qaI,qiIjJ,qbJ->qaibj", [(8, 8, 3), (8, 3, 3, 3, 3), (8, 8, 3)]), - ("ij,kl->ijkl", [(3, 3), (3, 3)]), - ("ik,jl->ijkl", [(3, 3), (3, 3)]), - ("il,jk->ijkl", [(3, 3), (3, 3)]), - ("ijkl,kl->ij", [(3, 3, 3, 3), (3, 3)]), - ("iI,jJ,kK,lL,IJKL->ijkl", [(3, 3), (3, 3), (3, 3), (3, 3), (3, 3, 3, 3)]), -) - - -class TestTaskP9_1: - """Tests for Task P9-1: Design named contraction-family templates. - - Acceptance criteria covered: - 1. Every existing contraction in the codebase maps to a named family. - 2. Every (element x backend) combination has a defined emission shape - per family. - 3. The spec distinguishes the scheduling decision (tier) from the - realisation decision (family). - """ - - @pytest.mark.unit - def test_every_plan_contraction_has_named_family(self) -> None: - """Every known einsum classifies into a Family enum member.""" - for einsum_string, operand_shapes in _KNOWN_EINSUMS: - family = classify_einsum_string(einsum_string, operand_shapes) - assert isinstance(family, Family), ( - f"classify_einsum_string({einsum_string!r}) returned " - f"{family!r}, not a Family member" - ) - assert family in FAMILIES, f"Family {family!r} is not in FAMILIES registry tuple" - - @pytest.mark.unit - def test_element_backend_combinations_have_emission_shapes(self) -> None: - """Every required (family, backend) has an emission-shape entry.""" - assert ELEMENT_BACKEND_COVERAGE, "ELEMENT_BACKEND_COVERAGE is empty" - for (element_type, backend), required_families in ELEMENT_BACKEND_COVERAGE.items(): - assert required_families, f"({element_type}, {backend}) has an empty family set" - for family in required_families: - key = (family, backend) - assert key in EMISSION_SHAPES, ( - f"Missing emission shape for {family.name} on backend " - f"{backend!r} (required by element {element_type!r})" - ) - assert EMISSION_SHAPES[key], ( - f"Empty emission-shape string for {family.name} on backend {backend!r}" - ) - - @pytest.mark.unit - def test_tier_and_family_are_orthogonal(self) -> None: - """The spec explicitly separates the tier and family axes.""" - spec_path = ( - Path(__file__).resolve().parents[3] / "dev" / "design_docs" / "09-EINSUM-OPTIMISER.md" - ) - assert spec_path.is_file(), f"Spec not found at {spec_path}" - text = spec_path.read_text(encoding="utf-8") - - markers = ( - "## 9 Contraction-family templates", - "## 9 Evolution path: contraction-family templates", - ) - matched = next((m for m in markers if m in text), None) - assert matched is not None, f"No recognised Section 9 heading found; tried {markers!r}" - section_9 = text.split(matched, maxsplit=1)[1] - section_9 = section_9.split("\n## ", maxsplit=1)[0] - - lower = section_9.lower() - assert "tier" in lower, "Section 9 must discuss 'tier'" - assert "family" in lower or "families" in lower, ( - "Section 9 must discuss 'family' / 'families'" - ) - assert "orthogonal" in lower or "orthogonality" in lower or "complementary" in lower, ( - "Section 9 must establish the tier/family axes as independent " - "(orthogonal/complementary)" - ) - assert "scheduling" in lower, "Section 9 must name the scheduling decision" - assert "realisation" in lower or "realization" in lower, ( - "Section 9 must name the realisation decision" - ) diff --git a/packages/mechdsl-core/tests/test_p9_2_family_emitters.py b/packages/mechdsl-core/tests/test_p9_2_family_emitters.py deleted file mode 100644 index f9efba1..0000000 --- a/packages/mechdsl-core/tests/test_p9_2_family_emitters.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Test stubs for Task P9-2: Refactor einsum_optimizer to emit via template families. - -Regression-tier tests: the refactor must not change semantics. Emitted source -may differ in whitespace/helper structure but must produce identical numerical -results. Golden files and cross-backend equivalence tests are the guards. -""" - -from __future__ import annotations - -import os -import re -from unittest import mock - -import pytest - -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.codegen.einsum_optimizer import ( - ContractionResult, - family_emitters_enabled, - optimize_contraction, -) -from mechdsl.codegen.family_registry import Family -from mechdsl.codegen.mfem_printer import emit as mfem_emit -from mechdsl.codegen.moose_printer import emit as moose_emit -from mechdsl.codegen.taichi_printer import emit as taichi_emit -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import localise_and_optimize - - -def _make_svk_cantilever_bundle() -> ArtifactBundle: - """Construct the MVP SVK cantilever bundle used as the P9-2 baseline.""" - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec( - model="svk", - params={"E": 200e3, "nu": 0.3}, - ), - boundaries=(BoundaryCondition(name="fix_root", bc_type=BCType.DIRICHLET),), - ) - loc_result, plans = localise_and_optimize(problem_ir) - return ArtifactBundle.from_pipeline(problem_ir, loc_result, plans) - - -_WHITESPACE_RE = re.compile(r"\s+") - - -def _normalise(text: str) -> str: - """Collapse whitespace runs so equivalence tests tolerate formatting drift.""" - return _WHITESPACE_RE.sub(" ", text).strip() - - -class TestTaskP9_2: - """ - Tests for Task P9-2: Refactor einsum_optimizer to emit via template families. - - Acceptance criteria covered: - 1. All existing contractions classify into a family. - 2. Taichi printer produces equivalent (whitespace-different ok) source before/after. - 3. No semantic regressions (cross-backend equivalence from P8-3 still passes). - 4. Golden files updated and reviewed. - """ - - @pytest.mark.regression - def test_all_contractions_classified_into_family(self) -> None: - """ - Verifies: every call to `optimize_contraction` in the codegen pipeline returns - a ContractionResult whose `family` field is a valid Family enum value, and - the derived ContractionPlans round-trip the family name. - - Acceptance criterion: All existing contractions classify into a family. - """ - bundle = _make_svk_cantilever_bundle() - assert bundle.contraction_plans, "SVK bundle produced no contraction plans" - - valid_family_names = {f.name for f in Family} - for plan in bundle.contraction_plans: - assert isinstance(plan.family, str) - assert plan.family in valid_family_names, ( - f"plan.family {plan.family!r} is not a valid Family enum name; " - f"expected one of {sorted(valid_family_names)}" - ) - - # Direct smoke: optimize a minimal einsum and confirm Family instance. - result: ContractionResult = optimize_contraction("ij,jk->ik", [(3, 3), (3, 3)]) - assert isinstance(result.family, Family) - - @pytest.mark.regression - @pytest.mark.slow - def test_taichi_emission_numerically_equivalent_after_refactor(self) -> None: - """ - Verifies: Taichi emission via family_emitters produces source that is - byte-identical (or whitespace-equivalent) to the legacy tier-only path - on the MVP reference benchmark (Hex8 SVK cantilever). - - The P9-2 dispatch wraps the legacy inline bodies, so flag-ON and - flag-OFF should emit identical Taichi source. We assert that here and - rely on the broader golden-regression suite (``test_e2e_taichi.py`` - and friends) for numerical correctness under the default (flag-ON) - path. - """ - bundle = _make_svk_cantilever_bundle() - - with mock.patch.dict(os.environ, {"MECHDSL_FAMILY_EMITTERS": "1"}): - assert family_emitters_enabled() is True - source_on = taichi_emit(bundle) - - with mock.patch.dict(os.environ, {"MECHDSL_FAMILY_EMITTERS": "0"}): - assert family_emitters_enabled() is False - source_off = taichi_emit(bundle) - - assert _normalise(source_on) == _normalise(source_off), ( - "Taichi emission differs between family-emitter ON and OFF paths " - "beyond whitespace; the dispatch helpers must preserve legacy " - "byte-identical output." - ) - - @pytest.mark.regression - def test_mfem_emission_equivalent_after_refactor(self) -> None: - """ - Verifies: MFEM printer output under the family-emitter path is - whitespace-equivalent to the legacy path. - - Acceptance criterion: No semantic regressions. - """ - bundle = _make_svk_cantilever_bundle() - - with mock.patch.dict(os.environ, {"MECHDSL_FAMILY_EMITTERS": "1"}): - output_on = mfem_emit(bundle) - with mock.patch.dict(os.environ, {"MECHDSL_FAMILY_EMITTERS": "0"}): - output_off = mfem_emit(bundle) - - assert _normalise(output_on) == _normalise(output_off), ( - "MFEM emission differs between family-emitter ON and OFF paths; " - "dispatch helpers must preserve legacy output." - ) - - @pytest.mark.regression - def test_moose_emission_equivalent_after_refactor(self) -> None: - """ - Verifies: MOOSE printer output under the family-emitter path is - whitespace-equivalent to the legacy path on the SVK cantilever bundle. - """ - bundle = _make_svk_cantilever_bundle() - - with mock.patch.dict(os.environ, {"MECHDSL_FAMILY_EMITTERS": "1"}): - output_on = moose_emit(bundle) - with mock.patch.dict(os.environ, {"MECHDSL_FAMILY_EMITTERS": "0"}): - output_off = moose_emit(bundle) - - assert set(output_on.keys()) == set(output_off.keys()) - for key in output_on: - assert _normalise(output_on[key]) == _normalise(output_off[key]), ( - f"MOOSE emission for key {key!r} differs between family-emitter " - "ON and OFF paths; dispatch helpers must preserve legacy output." - ) diff --git a/packages/mechdsl-core/tests/test_patch_test.py b/packages/mechdsl-core/tests/test_patch_test.py index 2f99873..7e63167 100644 --- a/packages/mechdsl-core/tests/test_patch_test.py +++ b/packages/mechdsl-core/tests/test_patch_test.py @@ -245,7 +245,6 @@ def test_rigid_body_large_translation(self): coords, conn = generate_hex8_mesh(2, 2, 2, 1.0, 1.0, 1.0) rotation = np.eye(3, dtype=np.float64) - # Translation of 1000 units translation = np.array([1000.0, 2000.0, -3000.0], dtype=np.float64) result = run_rigid_body_test(coords, conn, LAM, MU, rotation, translation, tol=1e-12) @@ -305,7 +304,6 @@ def test_generate_irregular_mesh_shape(self): assert coords_irr.shape == coords_reg.shape assert conn_irr.shape == conn_reg.shape - # Connectivity is unchanged np.testing.assert_array_equal(conn_irr, conn_reg) # Boundary nodes are unchanged diff --git a/packages/mechdsl-core/tests/test_patch_test_all_elements.py b/packages/mechdsl-core/tests/test_patch_test_all_elements.py index 94fe9d2..7213ba8 100644 --- a/packages/mechdsl-core/tests/test_patch_test_all_elements.py +++ b/packages/mechdsl-core/tests/test_patch_test_all_elements.py @@ -20,8 +20,7 @@ from mechdsl.ir.element_factory import ElementFactory from mechdsl.verify.patch_test import PatchTestResult, run_patch_test_parametric -# SVK material: mild-stiffness steel, nu = 0.3 (well away from Tet4's volumetric -# locking regime at nu -> 0.5). See Plan B phase B5 §B5.1 risk note. +# SVK material: mild-stiffness steel, nu = 0.3 (well away from Tet4's volumetric locking regime at nu -> 0.5). _YOUNG = 200.0e9 _NU = 0.3 _LAM = _YOUNG * _NU / ((1.0 + _NU) * (1.0 - 2.0 * _NU)) diff --git a/packages/mechdsl-core/tests/test_perf_regression.py b/packages/mechdsl-core/tests/test_perf_regression.py index a20d21d..5c2d2a0 100644 --- a/packages/mechdsl-core/tests/test_perf_regression.py +++ b/packages/mechdsl-core/tests/test_perf_regression.py @@ -389,8 +389,6 @@ def test_baseline_failure_threshold_reporting(self) -> None: f"{task_id!r} unexpectedly failed under per-benchmark override; " "only the targeted task should regress." ) - # The reported tolerance on every metric of the targeted task must - # reflect the override (5.0), not the default (10.0). for delta in by_id_strict[target_task].deltas: assert delta.tolerance_pct == 5.0, ( f"per-benchmark override must propagate into MetricDelta.tolerance_pct; " diff --git a/packages/mechdsl-core/tests/test_perzyna.py b/packages/mechdsl-core/tests/test_perzyna.py index 00279bd..a96a5b8 100644 --- a/packages/mechdsl-core/tests/test_perzyna.py +++ b/packages/mechdsl-core/tests/test_perzyna.py @@ -243,7 +243,7 @@ def test_perzyna_tangent_fd_check(self): E = 0.03 * 0.5 * (A + A.T) result = radial_return(mat, E, alpha_old, dt) - C_analytical = result.tangent # (3, 3, 3, 3) + C_analytical = result.tangent C_fd = self._fd_tangent(mat, E, alpha_old, dt) diff --git a/packages/mechdsl-core/tests/test_phase10_benchmark_registry.py b/packages/mechdsl-core/tests/test_phase10_benchmark_registry.py index 22db043..d276e35 100644 --- a/packages/mechdsl-core/tests/test_phase10_benchmark_registry.py +++ b/packages/mechdsl-core/tests/test_phase10_benchmark_registry.py @@ -33,15 +33,15 @@ _EXPECTED_REGISTRY_TASKS = ( - "P10-1", # MMS convergence matrix (Phase 6) - "P10-2", # Cantilever (Phase 5) - "P10-3", # Cook membrane (PLAN-B + Phase 3 closure) - "P10-4", # Thick cylinder (PLAN-B) - "P10-5", # Plate with hole (PLAN-B) - "P10-6", # Necking bar (PLAN-B + Phase 3 closure) - "P10-7", # Taylor impact (Phase 8) - "P10-8", # Notched bar (PLAN-B) - "P10-9", # HGO uniaxial (PLAN-B) + "P10-1", # MMS convergence matrix + "P10-2", # Cantilever + "P10-3", # Cook membrane + "P10-4", # Thick cylinder + "P10-5", # Plate with hole + "P10-6", # Necking bar + "P10-7", # Taylor impact + "P10-8", # Notched bar + "P10-9", # HGO uniaxial ) @@ -178,7 +178,6 @@ def test_metric_delta_reporting(self) -> None: f"{delta.pct_delta}" ) assert delta.within_tolerance is True - # abs_delta sign: positive (current > baseline). assert delta.abs_delta > 0.0 # Sanity: abs_delta and pct_delta share sign. assert math.copysign(1.0, delta.abs_delta) == math.copysign(1.0, delta.pct_delta) diff --git a/packages/mechdsl-core/tests/test_phase10_taylor_runtime.py b/packages/mechdsl-core/tests/test_phase10_taylor_runtime.py index c20866f..4d88322 100644 --- a/packages/mechdsl-core/tests/test_phase10_taylor_runtime.py +++ b/packages/mechdsl-core/tests/test_phase10_taylor_runtime.py @@ -80,7 +80,7 @@ def test_explicit_update_smoke_step(self) -> None: # Initial velocity: small uniform v0 on top face into z (-z direction) v0 = np.zeros_like(mesh.coordinates) top_nodes = mesh.boundary_nodes["z_max"] - v0[top_nodes, 2] = -10.0 # m/s + v0[top_nodes, 2] = -10.0 state = init_taylor_runtime( mesh, diff --git a/packages/mechdsl-core/tests/test_phase1_codegen_fixes.py b/packages/mechdsl-core/tests/test_phase1_codegen_fixes.py deleted file mode 100644 index bf088a3..0000000 --- a/packages/mechdsl-core/tests/test_phase1_codegen_fixes.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Tests for Phase 1: Critical Taichi Codegen Fixes (R3.1.x). - -These tests verify the new behaviors introduced by Phase 1 of the -PR #3 review resolution plan (dev/plans/mvp_pr3_round3.md). -""" - -from __future__ import annotations - -import inspect - -import pytest - -from mechdsl.codegen.artifact import ArtifactBundle -from mechdsl.codegen.taichi_printer import emit, emit_constitutive_update -from mechdsl.ir.mechanics_ir import ( - BCType, - BoundaryCondition, - ElementType, - Formulation, - MaterialSpec, - ProblemIR, -) -from mechdsl.lowering.fe_localise import localise_and_optimize - - -def _make_svk_bundle() -> ArtifactBundle: - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec(model="svk", params={"E": 200e3, "nu": 0.3}), - boundaries=(BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET),), - ) - loc_result, plans = localise_and_optimize(problem_ir) - return ArtifactBundle.from_pipeline(problem_ir, loc_result, plans) - - -def _make_j2_bundle() -> ArtifactBundle: - problem_ir = ProblemIR( - dim=3, - formulation=Formulation.TOTAL_LAGRANGIAN, - element_type=ElementType.HEX8, - material=MaterialSpec( - model="j2_power_law", - params={"E": 200e3, "nu": 0.3, "sigma_y": 250.0, "n_exp": 10.0}, - ), - boundaries=(BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET),), - ) - loc_result, plans = localise_and_optimize(problem_ir) - return ArtifactBundle.from_pipeline(problem_ir, loc_result, plans) - - -@pytest.fixture -def svk_source() -> str: - return emit(_make_svk_bundle()) - - -@pytest.fixture -def j2_source() -> str: - return emit(_make_j2_bundle()) - - -class TestC4NewtonNonConvergence: - """R3.1.3: Emitted Newton driver raises RuntimeError on non-convergence.""" - - def test_emitted_newton_raises_on_non_convergence(self, svk_source: str) -> None: - assert "raise RuntimeError" in svk_source - assert "did not converge" in svk_source - assert "return max_iter" not in svk_source - - def test_emitted_newton_uses_res_norm_variable(self, svk_source: str) -> None: - # The RuntimeError f-string must use res_norm (the actual variable at line 662) - idx = svk_source.find("did not converge") - context = svk_source[max(0, idx - 200) : idx + 200] - assert "res_norm" in context - - -class TestC4bNaNGuard: - """R3.1.4: NaN/Inf guard in emitted Newton driver.""" - - def test_emitted_nan_guard_present(self, svk_source: str) -> None: - assert "np.isfinite(res_norm)" in svk_source - - def test_emitted_nan_guard_error_message(self, svk_source: str) -> None: - assert "NaN or Inf detected" in svk_source - - -class TestH9MaterialValidation: - """R3.1.6: Material model validation in emit().""" - - def test_emit_invalid_material_raises(self) -> None: - bundle = _make_svk_bundle() - # Corrupt the material model to trigger validation - bundle.problem_ir_dict["material"]["model"] = "unknown_model" - with pytest.raises(ValueError, match="Unsupported material model"): - emit(bundle) - - def test_emit_svk_passes_validation(self) -> None: - source = emit(_make_svk_bundle()) - assert len(source) > 0 - - def test_emit_j2_passes_validation(self) -> None: - source = emit(_make_j2_bundle()) - assert len(source) > 0 - - -class TestH1J2ConvergenceCheck: - """R3.1.7: Emitted J2 convergence check after Newton loop.""" - - def test_emitted_j2_convergence_check_present(self, j2_source: str) -> None: - # WI-C (PlanJune14 re-review): the return-map non-convergence guard is now - # an explicit `converged` flag set by the in-loop convergence check, not a - # post-loop f_final residual-magnitude test (which silently accepted - # results in the (effective_tol, 1e3*effective_tol] band). - assert "converged = 0" in j2_source - assert "converged = 1" in j2_source - assert "if converged == 0:" in j2_source - - def test_emitted_j2_nan_flag_on_non_convergence(self, j2_source: str) -> None: - assert "float('nan')" in j2_source - - -class TestH2DeltaLambdaClamp: - """R3.1.8: Emitted J2 negative delta_lambda guard.""" - - def test_emitted_j2_dl_clamp_present(self, j2_source: str) -> None: - assert "dl = ti.max(dl, 0.0)" in j2_source - # Clamp must appear before factor computation - pos_clamp = j2_source.find("dl = ti.max(dl, 0.0)") - pos_factor = j2_source.find("factor = 1.0 - 3.0 * mu * dl / sigma_eq") - assert pos_clamp < pos_factor - - -class TestCM3FunctionRename: - """R3.1.9: emit_constitutive_stub renamed to emit_constitutive_update.""" - - def test_emit_constitutive_update_exists(self) -> None: - assert callable(emit_constitutive_update) - - def test_emit_constitutive_stub_removed(self) -> None: - import mechdsl.codegen.taichi_printer as tp - - source = inspect.getsource(tp) - assert "emit_constitutive_stub" not in source diff --git a/packages/mechdsl-core/tests/test_phase2_error_handling.py b/packages/mechdsl-core/tests/test_phase2_error_handling.py deleted file mode 100644 index a43c2c6..0000000 --- a/packages/mechdsl-core/tests/test_phase2_error_handling.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Tests for Phase 2: Error Handling Fixes (R3.2.x). - -These tests verify error handling improvements introduced by Phase 2 of the -PR #3 review resolution plan (dev/plans/mvp_pr3_round3.md). -""" - -from __future__ import annotations - -import inspect -import warnings - -import numpy as np -import pytest - -from mechdsl.codegen.boundary_codegen import compile_neumann -from mechdsl.codegen.einsum_optimizer import _extract_flops -from mechdsl.codegen.taichi_printer import emit -from mechdsl.solver.import_adapter import CGSolver, PCGSolver -from mechdsl.solver.mesh_io import generate_hex8_mesh - -# Reuse bundle helpers from Phase 1 tests -from tests.test_phase1_codegen_fixes import _make_svk_bundle - - -class TestR321CGBreakdownWarning: - """R3.2.1: CG/PCG breakdown warning on non-SPD system.""" - - def test_cg_breakdown_emits_warning(self) -> None: - """CG breakdown on non-SPD system emits RuntimeWarning.""" - - # Non-SPD system: matvec returns zero (p^T A p = 0) - def zero_matvec(v: np.ndarray) -> np.ndarray: - return np.zeros_like(v) - - solver = CGSolver() - rhs = np.array([1.0, 2.0, 3.0]) - x0 = np.zeros(3) - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - solver.solve(zero_matvec, rhs, x0, tol=1e-10, max_iter=10) - cg_warnings = [x for x in w if issubclass(x.category, RuntimeWarning)] - assert len(cg_warnings) >= 1 - assert "CG breakdown" in str(cg_warnings[0].message) - - def test_pcg_breakdown_emits_warning(self) -> None: - """PCG breakdown on non-SPD system emits RuntimeWarning.""" - - def zero_matvec(v: np.ndarray) -> np.ndarray: - return np.zeros_like(v) - - solver = PCGSolver() - rhs = np.array([1.0, 2.0, 3.0]) - x0 = np.zeros(3) - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - solver.solve(zero_matvec, rhs, x0, tol=1e-10, max_iter=10) - pcg_warnings = [x for x in w if issubclass(x.category, RuntimeWarning)] - assert len(pcg_warnings) >= 1 - assert "PCG breakdown" in str(pcg_warnings[0].message) - - def test_cg_existing_convergence_still_works(self) -> None: - """CG still converges on SPD systems without warnings.""" - - # 3x3 identity: trivially SPD - def identity_matvec(v: np.ndarray) -> np.ndarray: - return v.copy() - - solver = CGSolver() - rhs = np.array([1.0, 2.0, 3.0]) - x0 = np.zeros(3) - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - x, _iters, _res = solver.solve(identity_matvec, rhs, x0, tol=1e-10, max_iter=100) - cg_warnings = [x for x in w if issubclass(x.category, RuntimeWarning)] - assert len(cg_warnings) == 0 - np.testing.assert_allclose(x, rhs, atol=1e-10) - - -class TestR322RadialReturnStallGuard: - """R3.2.2: J2 radial_return stall guard raises on small derivative.""" - - def test_no_pragma_no_cover_on_stall_path(self) -> None: - """The stall guard path has no pragma: no cover.""" - from mechdsl.symbolic.models import j2_power_law - - source = inspect.getsource(j2_power_law.radial_return) - # Find the stall guard - assert "abs(df) < 1e-30" in source - # Verify no pragma: no cover on that path - lines = source.split("\n") - for line in lines: - if "abs(df) < 1e-30" in line: - assert "pragma: no cover" not in line - - -class TestR323CGSolverConfig: - """R3.2.3: Emitted CG solver configuration in Newton driver.""" - - def test_emitted_cg_solver_used(self) -> None: - """Emitted code uses CGSolver for linear solve.""" - source = emit(_make_svk_bundle()) - assert "CGSolver()" in source - - def test_emitted_cg_tolerance_set(self) -> None: - """Emitted code configures CG tolerance.""" - source = emit(_make_svk_bundle()) - assert "solver.solve(" in source - assert "tol=1.0e-10" in source - - def test_emitted_newton_non_convergence_raises(self) -> None: - """Emitted code raises RuntimeError when Newton fails to converge.""" - source = emit(_make_svk_bundle()) - assert "Newton did not converge" in source - - -class TestR324FlopsSentinel: - """R3.2.4: Einsum FLOPS extraction returns -1.0 sentinel on failure.""" - - def test_flops_failure_returns_sentinel(self) -> None: - """FLOPS extraction failure returns -1.0 (not 0.0).""" - # Pass an object with no known FLOPS attributes - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - result = _extract_flops(object()) - assert result == -1.0 - flops_warnings = [x for x in w if issubclass(x.category, RuntimeWarning)] - assert len(flops_warnings) >= 1 - assert "sentinel" in str(flops_warnings[0].message).lower() - - -class TestR326BoundaryCodegenGuards: - """R3.2.6: Boundary codegen zero-area face and axis validation.""" - - def test_invalid_axis_raises(self) -> None: - """Face name not starting with x/y/z raises ValueError.""" - mesh = generate_hex8_mesh(2, 2, 2) - # Inject a fake boundary tag so it doesn't raise KeyError first - mesh.boundary_tags["w0"] = mesh.boundary_tags["x0"] - with pytest.raises(ValueError, match="Cannot determine face orientation"): - compile_neumann(mesh, "w0", np.array([1.0, 0.0, 0.0])) - - def test_docstring_notes_structured_mesh(self) -> None: - """compile_neumann docstring mentions structured mesh limitation.""" - doc = compile_neumann.__doc__ - assert doc is not None - assert "structured mesh" in doc.lower() diff --git a/packages/mechdsl-core/tests/test_phase6_exit.py b/packages/mechdsl-core/tests/test_phase6_exit.py deleted file mode 100644 index b0d959e..0000000 --- a/packages/mechdsl-core/tests/test_phase6_exit.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Phase 6 verification wrappers for Sprint 3 final cleanup and exit.""" - -from __future__ import annotations - -import json -import re -from pathlib import Path -from typing import Any - -_ROOT = Path(__file__).resolve().parents[3] -_TASKS_DIR = _ROOT / "dev" / "tasks" / "sprint3" -_TASK_JSON_DIR = _TASKS_DIR / "json" -_TRACKER = _ROOT / "dev" / "tracking" / "tasks-tracker_sprint3.md" -_EXIT_REPORT = _TASKS_DIR / "Phase_6_Exit_Report.md" -_HANDOFF = _TASKS_DIR / "Handoff_Phase_7.md" -_TEXT_SUFFIXES = {".md", ".py", ".toml", ".yml", ".yaml", ".json"} - -_TO_DO_PATTERN = re.compile(rf"{'TO' + 'DO'}|{'FIX' + 'ME'}") -_CLEANUP_SCAN_PATHS = ( - _ROOT / "packages" / "mechdsl-core", - _ROOT / "README.md", - _ROOT / "CHANGELOG.md", - _ROOT / "dev" / "examples", - _ROOT / ".github" / "workflows" / "ci.yml", -) -# Intentional cleanup markers — sites whitelisted from the Sprint 3 -# Phase 6 cleanup-marker scan because they legitimately mention the -# placeholder word they are guarding against. post_recovery_plan -# Phase 6 (P6-4) replaced the previous line-number whitelist with an -# in-source ``# intentional-cleanup-site`` marker scan: any line in -# the scanned files carrying that marker comment is excluded from the -# unexpected-match list. The marker text is greppable, drift-resistant -# against line-number changes, and lives next to the assertion it -# protects (see ``test_emission_verification.py`` for the two current -# whitelisted sites). -# -# Note: the earlier whitelist also protected a line in -# ``emit_tangent_matvec_kernel``'s docstring. PLAN-A §A7.5 and §A9.2 -# replaced that finite-difference implementation with the analytical -# consistent-tangent emission, the docstring was rewritten, and the -# whitelisted codegen line no longer carries any placeholder marker. -_INTENTIONAL_CLEANUP_MARKER = "intentional-cleanup-site" -_EXIT_CRITERION_SNIPPETS = ( - "Patch test: constant strain on irregular Hex8", - "Rigid body: zero internal force after 30-degree rotation + translation", - "Cantilever: tip displacement within 5% of Euler-Bernoulli", - "Cook's membrane: tip displacement within 2% of reference", - "Necking bar: load-displacement curve within 2% of reference", - "MMS convergence: L2 rate >= 2.0, H1 rate >= 1.0 on 4 mesh levels", - "Full pipeline test exercises all 6 compiler layers", - "CI runs 3 tiers: fast (commit), slow (PR), nightly (e2e benchmarks)", - "README, examples, CHANGELOG, docstrings complete", - "`ruff`, `mypy`, full `pytest` all pass cleanly", -) - - -def _read_text(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def _load_task(task_id: str) -> dict[str, Any]: - return json.loads(_read_text(_TASK_JSON_DIR / f"{task_id}.json")) - - -def _tracker_row(task_id: str) -> str: - for line in _read_text(_TRACKER).splitlines(): - if line.startswith(f"| {task_id} |"): - return line - raise AssertionError(f"Missing tracker row for {task_id}") - - -def _task_command(task_id: str, command: str) -> dict[str, Any]: - task = _load_task(task_id) - for result in task["test_completion"]["commands"]: - if result["command"] == command: - return result - raise AssertionError(f"Missing command result for {task_id}: {command}") - - -def _assert_task_done(task_id: str) -> dict[str, Any]: - task = _load_task(task_id) - assert task["status"] == "done" - assert task["review_status"] == "approved" - assert task["completion_date"] == "2026-04-12" - assert task["implementation_branch"] == "SOSOVSKI/phase6-exec" - row = _tracker_row(task_id) - assert "| done |" in row - assert "| 2026-04-12 |" in row - return task - - -_CLEANUP_MARKER_WINDOW = 3 -"""Number of lines to look forward and backward for the -``intentional-cleanup-site`` marker. Lets one marker comment whitelist -a multi-line statement (e.g. ``ruff``-formatted parenthesised assert) -without forcing the marker onto every constituent line.""" - - -def _iter_cleanup_matches() -> list[tuple[str, int, str]]: - """Scan the configured paths for cleanup-marker words. Lines that - carry — or are within ``_CLEANUP_MARKER_WINDOW`` of — the in-source - ``intentional-cleanup-site`` marker (Phase 6 P6-4) are excluded. - Those sites legitimately contain the placeholder word they are - guarding against. - """ - matches: list[tuple[str, int, str]] = [] - for path in _CLEANUP_SCAN_PATHS: - if path.is_dir(): - files = ( - candidate - for candidate in path.rglob("*") - if candidate.is_file() and candidate.suffix in _TEXT_SUFFIXES - ) - else: - files = (path,) - for candidate in files: - relative = candidate.relative_to(_ROOT).as_posix() - text = _read_text(candidate) - lines = text.splitlines() - marker_lines = { - idx for idx, line in enumerate(lines) if _INTENTIONAL_CLEANUP_MARKER in line - } - for idx, line in enumerate(lines): - if not _TO_DO_PATTERN.search(line): - continue - # intentional-cleanup-site - # Whitelist a TODO/FIXME hit if any nearby line carries - # the cleanup-site marker — survives line-shuffling - # caused by formatter passes. - if any( - abs(idx - marker_idx) <= _CLEANUP_MARKER_WINDOW for marker_idx in marker_lines - ): - continue - matches.append((relative, idx + 1, line.strip())) - return matches - - -class TestTaskP6T1: - """Tests for Task P6-1: Ruff lint and format pass.""" - - def test_ruff_check_packages_clean(self) -> None: - """Task JSON and tracker should record a clean ruff check run.""" - task = _assert_task_done("P6-1") - assert task["test_completion"]["pass_rate"] == 100 - result = _task_command("P6-1", "uv run ruff check packages/") - assert result["passed"] == 1 - assert result["total"] == 1 - - def test_ruff_format_check_packages_clean(self) -> None: - """Task JSON and tracker should record a clean formatter check run.""" - task = _assert_task_done("P6-1") - assert "ruff format" in " ".join(task["completion_notes"]) - result = _task_command("P6-1", "uv run ruff format --check packages/") - assert result["passed"] == 1 - assert result["total"] == 1 - - -class TestTaskP6T2: - """Tests for Task P6-2: Mypy type checking pass.""" - - def test_mypy_mechdsl_core_clean(self) -> None: - """Task JSON should record a successful mypy verification run.""" - task = _assert_task_done("P6-2") - assert task["test_completion"]["pass_rate"] == 100 - result = _task_command("P6-2", "uv run mypy packages/mechdsl-core/src/mechdsl/") - assert result["passed"] == 1 - assert result["total"] == 1 - - -class TestTaskP6T3: - """Tests for Task P6-3: Full test suite zero failures.""" - - def test_full_workspace_pytest_suite_passes(self) -> None: - """Task JSON should record a successful full-suite verification run.""" - task = _assert_task_done("P6-3") - assert task["test_completion"]["pass_rate"] == 100 - result = _task_command("P6-3", "uv run pytest --tb=short -q") - assert result["passed"] >= 1 - assert result["passed"] == result["total"] - - -class TestTaskP6T5: - """Tests for Task P6-5: Remove dead code, unused imports, resolved markers.""" - - def test_no_resolved_todos_or_fixmes_remain(self) -> None: - """Only the explicitly deferred cleanup markers should remain. - - post_recovery_plan Phase 6 (P6-4): the whitelist is now driven - by the ``# intentional-cleanup-site`` marker scan inside - :func:`_iter_cleanup_matches`. Any line carrying that marker - is excluded before this check sees it. - """ - _assert_task_done("P6-5") - unexpected = list(_iter_cleanup_matches()) - assert not unexpected, f"Unexpected cleanup markers remain: {unexpected}" - - def test_no_implemented_phase_stubs_remain(self) -> None: - """Phase scaffold stubs should be gone once execution is complete.""" - _assert_task_done("P6-5") - text = _read_text(_ROOT / "packages" / "mechdsl-core" / "tests" / "test_phase6_exit.py") - # Build the forbidden marker at runtime from tuple-joined pieces so - # neither the source text nor any formatter pass can inline it as a - # single literal. Earlier revisions used adjacent-string concatenation - # (``"stub -- " "implement"``) which ruff-format merged, causing the - # test to flag its own assertion. - stub_marker = " ".join(("stub", "--", "implement")) - assert stub_marker not in text - - -class TestTaskP6T6: - """Tests for Task P6-6: Verify all Sprint 3 exit criteria.""" - - def test_exit_criteria_matrix_records_all_ten_checks(self) -> None: - """The exit report should record all ten MVP criteria with checked status.""" - _assert_task_done("P6-6") - report = _read_text(_EXIT_REPORT) - assert _EXIT_REPORT.exists() - checked_lines = [line for line in report.splitlines() if line.startswith("- [x] ")] - assert len(checked_lines) == 10 - for snippet in _EXIT_CRITERION_SNIPPETS: - assert snippet in report - - def test_exit_report_cites_clean_toolchain_and_ci_evidence(self) -> None: - """The exit report should cite toolchain-cleanliness and CI evidence.""" - _assert_task_done("P6-6") - report = _read_text(_EXIT_REPORT) - assert "uv run ruff check packages/" in report - assert "uv run mypy packages/mechdsl-core/src/mechdsl/" in report - assert "uv run pytest --tb=short -q" in report - assert "uv run pytest packages/mechdsl-core/tests/test_ci_config.py -v" in report - assert "CI has 3 tiers" in report - - -class TestTaskP6T7: - """Tests for Task P6-7: Sprint 3 handoff document.""" - - def test_sprint3_handoff_document_covers_mvp_completion_and_plan_b_limits(self) -> None: - """The final handoff should summarize MVP completion and deferred Plan B scope.""" - _assert_task_done("P6-7") - handoff = _read_text(_HANDOFF) - assert _HANDOFF.exists() - assert "# Phase 6 Handoff" in handoff - assert "MVP DONE" in handoff - assert "Plan B" in handoff - assert "## Phase 6 Completion Summary" in handoff - assert "## Known Issues and Deferred Concerns" in handoff diff --git a/packages/mechdsl-core/tests/test_pj1_svk_spike.py b/packages/mechdsl-core/tests/test_pj1_svk_spike.py deleted file mode 100644 index 814e1b4..0000000 --- a/packages/mechdsl-core/tests/test_pj1_svk_spike.py +++ /dev/null @@ -1,201 +0,0 @@ -"""PlanJune14 **PJ-1** — SVK all-Taichi spike: the architecture gate. - -These tests are the gate described in ``dev/plans/PlanJune14.md``: - - The SVK patch solves end-to-end with the generated ``@ti.kernel`` operator + - injected PCG, **no ``.to_numpy()`` in operator/solve**, and - ``max|u_gen − u_ref| < 1e-10``. *If this composes, the rest is replication.* - -Coverage: - -1. Element-level convention parity — the Taichi internal-force / tangent kernels - reproduce the handwritten NumPy reference element routines (isolates the - kinematics/quadrature/Voigt conventions from the solver). -2. The architecture gate — a single Hex8 SVK uniaxial-stretch BVP solved fully - on-device (matrix-free operator injected into the ``ti_runtime`` seams + PCG + - thin Newton) matches ``ref_hex8_elastic.solve_elastic`` to < 1e-10. -3. No NumPy in the operator/solve hot path (the "all-Taichi" invariant). -""" - -import ast -import inspect -import textwrap - -import numpy as np -import pytest - -from tests.ref.ref_hex8_elastic import ( - element_internal_force, - element_tangent_matvec, - generate_hex8_mesh, - solve_elastic, -) -from tests.spike import svk_hex8_taichi as spike -from tests.spike.svk_hex8_taichi import ( - SVKProblem, - single_element_internal_force, - single_element_tangent_matvec, - solve_svk_hex8, -) - -pytestmark = pytest.mark.slow # every test JIT-compiles Taichi kernels - -# Steel-like SVK (matches tests/test_ref_elastic.py). -E_YOUNG = 200.0e3 -NU = 0.3 -LAM = E_YOUNG * NU / ((1 + NU) * (1 - 2 * NU)) -MU = E_YOUNG / (2 * (1 + NU)) - -# Gate tolerance from PlanJune14 / 07-CONVENTIONS §6 (generated vs reference). -GATE_TOL = 1e-10 - - -def _unit_cube() -> tuple[np.ndarray, np.ndarray]: - """Single unit-cube Hex8 element with the reference node ordering.""" - return generate_hex8_mesh(1, 1, 1, 1.0, 1.0, 1.0) - - -# =========================================================================== -# 1. Element-level convention parity (operator kinematics vs reference) -# =========================================================================== - - -def _local_element_coords() -> np.ndarray: - """Unit-cube nodes in hex8 *element-local* order (via the mesh connectivity). - - The element-level kernels gather with a trivial ``conn = arange(8)``, so they - expect the coordinates already in local node order. ``coords[conn[0]]`` maps - the grid-ordered mesh nodes into that local order — a degenerate (negative - Jacobian) element results if the grid ordering is used directly. - """ - coords, conn = _unit_cube() - return coords[conn[0]] - - -def test_element_internal_force_matches_ref(): - """Taichi SVK internal-force kernel == NumPy reference (finite displacement).""" - X = _local_element_coords() - u = np.zeros((8, 3)) - u[:, 0] = 0.05 * X[:, 0] # finite uniaxial-ish stretch - u[:, 1] = -0.012 * X[:, 1] - - f_ti = single_element_internal_force(u, X, LAM, MU) - f_ref = element_internal_force(u, X, LAM, MU) - - np.testing.assert_allclose(f_ti, f_ref, atol=1e-10, rtol=1e-12) - - -def test_element_tangent_matvec_matches_ref(): - """Taichi matrix-free tangent matvec == NumPy reference for a random direction.""" - X = _local_element_coords() - u = np.zeros((8, 3)) - u[:, 0] = 0.05 * X[:, 0] - v = np.random.default_rng(11).standard_normal((8, 3)) * 1e-2 - - Kv_ti = single_element_tangent_matvec(u, X, v, LAM, MU) - Kv_ref = element_tangent_matvec(u, X, v, LAM, MU) - - np.testing.assert_allclose(Kv_ti, Kv_ref, atol=1e-10, rtol=1e-12) - - -# =========================================================================== -# 2. The architecture gate — full on-device SVK solve vs reference -# =========================================================================== - - -def _uniaxial_stretch_problem(stretch: float = 0.1): - """Single Hex8 patch: left face fixed, right face stretched in x, lateral free.""" - coords, conn = _unit_cube() - n = coords.shape[0] - - bc_mask = np.zeros((n, 3), dtype=bool) - bc_values = np.zeros((n, 3), dtype=np.float64) - - left = np.abs(coords[:, 0]) < 1e-12 - right = np.abs(coords[:, 0] - 1.0) < 1e-12 - bc_mask[left, :] = True # fully fix the x=0 face (kills all rigid-body modes) - bc_mask[right, 0] = True # prescribe x-displacement on the x=1 face - bc_values[right, 0] = stretch # lateral (y,z) DOFs on the right face stay free - - f_ext = np.zeros((n, 3), dtype=np.float64) - return coords, conn, bc_mask, bc_values, f_ext - - -def test_pj1_svk_spike_matches_reference(): - """**Gate**: all-Taichi SVK solve matches the NumPy reference to < 1e-10.""" - coords, conn, bc_mask, bc_values, f_ext = _uniaxial_stretch_problem() - - # Ground truth: handwritten NumPy Newton + ScipyCG. - u_ref, res_ref = solve_elastic( - coords, - conn, - LAM, - MU, - bc_mask, - bc_values, - f_ext, - tol=1e-10, - cg_tol=1e-12, - ) - - # All-Taichi spike: matrix-free operator + injected PCG + thin Newton. - prob = SVKProblem( - coords=coords, - conn=conn, - lam=LAM, - mu=MU, - bc_mask=bc_mask, - bc_values=bc_values, - f_ext=f_ext, - ) - u_gen, _res_gen = solve_svk_hex8(prob, newton_tol=1e-10, cg_tol=1e-12) - - # Sanity: the problem is nontrivial (finite deformation, Newton iterated). - assert len(res_ref) >= 2, "expected a nonlinear solve (>=1 Newton step)" - assert np.max(np.abs(u_gen)) > 1e-3, "expected a nonzero converged displacement" - - max_diff = float(np.max(np.abs(u_gen - u_ref))) - assert max_diff < GATE_TOL, ( - f"PJ-1 gate failed: max|u_gen - u_ref| = {max_diff:.3e} >= {GATE_TOL:.0e}" - ) - - -def test_pj1_constrained_dofs_exact(): - """Prescribed/fixed DOFs are reproduced exactly by the on-device solve.""" - coords, conn, bc_mask, bc_values, f_ext = _uniaxial_stretch_problem() - prob = SVKProblem( - coords=coords, - conn=conn, - lam=LAM, - mu=MU, - bc_mask=bc_mask, - bc_values=bc_values, - f_ext=f_ext, - ) - u_gen, _ = solve_svk_hex8(prob, newton_tol=1e-10, cg_tol=1e-12) - - np.testing.assert_allclose(u_gen[bc_mask], bc_values[bc_mask], atol=1e-12) - - -# =========================================================================== -# 3. No NumPy in the operator / solve hot path -# =========================================================================== - - -def test_operator_and_solve_are_all_taichi(): - """The operator wrapper and the PCG solver contain no host NumPy / readback. - - ``apply_A`` (built by :func:`make_svk_operator`) and :func:`pcg` are the - operator/solve hot path. The ``@ti.kernel`` bodies they call are numpy-free by - construction — Taichi rejects host ``np`` calls in kernel scope, so the fact - that the gate test compiles and runs them already proves it. - """ - for fn in (spike.make_svk_operator, spike.pcg): - tree = ast.parse(textwrap.dedent(inspect.getsource(fn))) - # Walk the AST (not the raw text) so docstrings/comments that *mention* - # ``np`` / ``.to_numpy()`` don't trip the check — only real code does. - for node in ast.walk(tree): - if isinstance(node, ast.Attribute): - assert node.attr != "to_numpy", f"{fn.__name__} reads a field back to host" - if isinstance(node, ast.Name): - assert node.id != "np", f"{fn.__name__} uses NumPy in the hot path" diff --git a/packages/mechdsl-core/tests/test_plastic_emission.py b/packages/mechdsl-core/tests/test_plastic_emission.py index 3a6f420..91b99b4 100644 --- a/packages/mechdsl-core/tests/test_plastic_emission.py +++ b/packages/mechdsl-core/tests/test_plastic_emission.py @@ -405,11 +405,6 @@ def test_svk_tangent_elastic_params_only(self, svk_source: str) -> None: ) -# --------------------------------------------------------------------------- -# Phase 4 audit stubs -# --------------------------------------------------------------------------- - - class TestTaskP4T1Audit: """Task P4-T1: Audit J2 constitutive emission against symbolic model. diff --git a/packages/mechdsl-core/tests/test_ref_elastic.py b/packages/mechdsl-core/tests/test_ref_elastic.py index 81e776e..4187672 100644 --- a/packages/mechdsl-core/tests/test_ref_elastic.py +++ b/packages/mechdsl-core/tests/test_ref_elastic.py @@ -284,8 +284,8 @@ def test_symmetric_uniaxial(self): np.testing.assert_allclose(f_bot_z, -f_top_z, atol=1e-10) # By symmetry about y=0.5 plane, y-forces should be antisymmetric - front_nodes = [0, 1, 4, 5] # y=0 - back_nodes = [2, 3, 6, 7] # y=1 + front_nodes = [0, 1, 4, 5] + back_nodes = [2, 3, 6, 7] f_front_y = f_int[front_nodes, 1].sum() f_back_y = f_int[back_nodes, 1].sum() np.testing.assert_allclose(f_front_y, -f_back_y, atol=1e-10) @@ -465,7 +465,7 @@ def cantilever_setup(self) -> dict: & (np.abs(coords[:, 2] - Lz) < 1e-12) )[0] assert len(right_top) == 1, f"Expected 1 corner node, found {len(right_top)}" - f_ext[right_top[0], 2] = -10.0 # downward in z + f_ext[right_top[0], 2] = -10.0 return { "coords": coords, diff --git a/packages/mechdsl-core/tests/test_ref_plastic.py b/packages/mechdsl-core/tests/test_ref_plastic.py index 2cfb687..93e8f0b 100644 --- a/packages/mechdsl-core/tests/test_ref_plastic.py +++ b/packages/mechdsl-core/tests/test_ref_plastic.py @@ -180,7 +180,7 @@ def test_large_strain_yields(self): """Single element with large strain exceeds yield → alpha > 0.""" X_elem = _single_element_coords() # Large uniaxial strain to push well past yield - eps = 0.01 # 1% strain + eps = 0.01 u_elem = _apply_constant_strain(X_elem, eps_xx=eps) alpha_elem = np.zeros(8, dtype=np.float64) diff --git a/packages/mechdsl-core/tests/test_solver.py b/packages/mechdsl-core/tests/test_solver.py index d8af33d..7cf4bf0 100644 --- a/packages/mechdsl-core/tests/test_solver.py +++ b/packages/mechdsl-core/tests/test_solver.py @@ -35,7 +35,6 @@ def _random_spd(n: int, rng: np.random.Generator) -> np.ndarray: # Test data # --------------------------------------------------------------------------- -# Known 3x3 SPD system from the task spec. A_3x3 = np.array( [[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]], dtype=np.float64, diff --git a/packages/mechdsl-core/tests/test_spectral_eigensolver.py b/packages/mechdsl-core/tests/test_spectral_eigensolver.py index 6f7415b..d0d5706 100644 --- a/packages/mechdsl-core/tests/test_spectral_eigensolver.py +++ b/packages/mechdsl-core/tests/test_spectral_eigensolver.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: from pathlib import Path -# Two-term compressible Ogden, the committed example energy (dev/examples/ogden_energy.tex). +# Two-term compressible Ogden, the committed example energy (examples/ogden_energy.tex). _OGDEN_ENERGY = r""" % declare metric gDD --dim 3 % declare EDD --dim 3 diff --git a/packages/mechdsl-core/tests/test_svk.py b/packages/mechdsl-core/tests/test_svk.py index 42fc572..0d894a7 100644 --- a/packages/mechdsl-core/tests/test_svk.py +++ b/packages/mechdsl-core/tests/test_svk.py @@ -206,15 +206,11 @@ def test_voigt_tangent_known_values(self, simple_mat: SVKMaterial): """Spot-check known entries of isotropic tangent in Voigt form.""" C6 = material_tangent_voigt(simple_mat) lam, mu = simple_mat.lam, simple_mat.mu - # C6[0,0] = lam + 2*mu assert C6[0, 0] == pytest.approx(lam + 2 * mu) - # C6[0,1] = lam assert C6[0, 1] == pytest.approx(lam) # C6[3,3] = mu (shear modulus, unscaled) assert C6[3, 3] == pytest.approx(mu) - # C6[4,4] = mu assert C6[4, 4] == pytest.approx(mu) - # C6[5,5] = mu assert C6[5, 5] == pytest.approx(mu) # Off-diagonal shear-normal coupling = 0 assert C6[0, 3] == pytest.approx(0.0, abs=1e-15) @@ -233,7 +229,7 @@ def test_wrong_strain_shape(self, simple_mat: SVKMaterial): # --------------------------------------------------------------------------- -# R3.5.3 — __post_init__ validation tests for SVKMaterial +# __post_init__ validation tests for SVKMaterial # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/test_symbolic_ir_interface.py b/packages/mechdsl-core/tests/test_symbolic_ir_interface.py index dc5546a..b1e8e2c 100644 --- a/packages/mechdsl-core/tests/test_symbolic_ir_interface.py +++ b/packages/mechdsl-core/tests/test_symbolic_ir_interface.py @@ -113,18 +113,13 @@ def test_multiple_boundary_conditions(self) -> None: def test_declared_regions_matching_all_bcs(self) -> None: bcs = ( BoundaryCondition(name="fix", bc_type=BCType.DIRICHLET), - # Neumann BCs require a traction spec since post_recovery_plan P1-1. + # Neumann BCs require a traction spec. BoundaryCondition(name="load", bc_type=BCType.NEUMANN, traction="t_bar"), ) ir = _valid_ir(boundaries=bcs, declared_regions=frozenset({"fix", "load"})) assert ir.declared_regions == frozenset({"fix", "load"}) -# --------------------------------------------------------------------------- -# Invalid dimension — Plan B phase B2 (2-D support) -# --------------------------------------------------------------------------- - - class TestInvalidDimension: """dim != 3 is outside the MVP subset; Plan B phase B2 adds 2-D support.""" @@ -137,11 +132,6 @@ def test_dim_1_raises_with_plan_b2_message(self) -> None: _valid_ir(dim=1) -# --------------------------------------------------------------------------- -# Invalid formulation — Plan B phase B1 (Updated Lagrangian) -# --------------------------------------------------------------------------- - - class TestFormulationGuard: """Plan B §B1.3 promoted UPDATED_LAGRANGIAN into the supported subset. @@ -183,11 +173,6 @@ def test_formulation_configuration_mismatch_is_rejected(self) -> None: ) -# --------------------------------------------------------------------------- -# Invalid element type — Plan B phase B5 (Tet4/Tet10) -# --------------------------------------------------------------------------- - - class TestElementTypeGuard: """Element type guard message references Plan B phase B5.""" @@ -214,7 +199,7 @@ def test_non_hex8_guard_mentions_plan_b5(self) -> None: # --------------------------------------------------------------------------- -# Invalid material models — Plan B phases B3, B4, B6 +# Invalid material models # --------------------------------------------------------------------------- @@ -273,15 +258,15 @@ class TestCoordinateMetadata: def test_too_few_spatial_coords_raises(self) -> None: with pytest.raises(ValueError, match="spatial"): - _valid_ir(coord_spatial=("x", "y")) # 2, not 3 + _valid_ir(coord_spatial=("x", "y")) def test_too_many_spatial_coords_raises(self) -> None: with pytest.raises(ValueError, match="spatial"): - _valid_ir(coord_spatial=("x", "y", "z", "w")) # 4, not 3 + _valid_ir(coord_spatial=("x", "y", "z", "w")) def test_too_few_material_coords_raises(self) -> None: with pytest.raises(ValueError, match="material"): - _valid_ir(coord_material=("X",)) # 1, not 3 + _valid_ir(coord_material=("X",)) def test_correct_coord_lengths_pass(self) -> None: ir = _valid_ir(coord_spatial=("x1", "x2", "x3"), coord_material=("X1", "X2", "X3")) diff --git a/packages/mechdsl-core/tests/test_taichi_printer_ul.py b/packages/mechdsl-core/tests/test_taichi_printer_ul.py index 28b4e42..3385e7b 100644 --- a/packages/mechdsl-core/tests/test_taichi_printer_ul.py +++ b/packages/mechdsl-core/tests/test_taichi_printer_ul.py @@ -84,7 +84,7 @@ def _make_tl_elastic_bundle() -> tuple[ArtifactBundle, str]: # --------------------------------------------------------------------------- -# P1-3: UL residual emission +# UL residual emission # --------------------------------------------------------------------------- @@ -164,7 +164,6 @@ def test_ul_golden_snapshot_parses_as_valid_python(self) -> None: golden = golden_path.read_text(encoding="utf-8") # The golden must parse without SyntaxError. ast.parse(golden) - # And the current emission must match the golden. assert source == golden, ( f"UL emission differs from golden file {golden_path}.\n" "If the change is intentional, delete the golden file and rerun " @@ -184,7 +183,7 @@ def test_ul_source_differs_from_tl_source(self) -> None: # --------------------------------------------------------------------------- -# P1-4: UL tangent emission +# UL tangent emission # --------------------------------------------------------------------------- diff --git a/packages/mechdsl-core/tests/test_taylor_impact.py b/packages/mechdsl-core/tests/test_taylor_impact.py index 9acf26d..050f30d 100644 --- a/packages/mechdsl-core/tests/test_taylor_impact.py +++ b/packages/mechdsl-core/tests/test_taylor_impact.py @@ -95,11 +95,10 @@ # --- Frozen regression baseline (see module docstring) --------------------- # -# Computed from ``_taylor_impact_reference_params()`` below on commit -# ``4c89098`` (2026-04-26). NOT independently sourced from Johnson & Cook -# (1985). These guard against semantic regressions in the Phase E7 explicit -# Taylor runtime + Phase E8 public runner; they are intentionally tight -# (5 % / 5 % / 10 %) per the original test names. +# Computed from ``_taylor_impact_reference_params()`` below; NOT independently +# sourced from Johnson & Cook (1985). These guard against semantic regressions +# in the explicit Taylor runtime and public benchmark runner; they are +# intentionally tight (5 % / 5 % / 10 %) per the test names. _REFERENCE_FINAL_LENGTH: float = 0.02521 # m _REFERENCE_MUSHROOM_RADIUS: float = 0.005564278964663555 # m @@ -187,7 +186,6 @@ def test_taylor_impact_mushroom_diameter_within_5pct(self) -> None: actual = result.extras["mushroom_diameter"] assert math.isfinite(actual), f"mushroom_diameter is not finite: {actual!r}" - # Consistency: diameter must equal twice the radius (no schema drift). assert actual == pytest.approx(2.0 * result.extras["mushroom_radius"]), ( "mushroom_diameter is not 2 * mushroom_radius — extras schema drifted." ) diff --git a/packages/mechdsl-core/tests/test_tet10_basis.py b/packages/mechdsl-core/tests/test_tet10_basis.py index 216399b..9ea2d92 100644 --- a/packages/mechdsl-core/tests/test_tet10_basis.py +++ b/packages/mechdsl-core/tests/test_tet10_basis.py @@ -87,7 +87,7 @@ def test_tet10_quadratic_field_exactness(self): Field chosen: f(x, y, z) = 1 + 2x + 3y + 4z + x^2 + x*y + y*z """ # Node coordinates of the reference tet (parametric = physical here) - X = TET10_NODE_COORDS # shape (10, 3) + X = TET10_NODE_COORDS def u_exact(xyz: np.ndarray) -> float: x, y, z = xyz[0], xyz[1], xyz[2] @@ -141,8 +141,8 @@ def test_tet10_polynomial_integration(self): (3) ∫ L1^2 dV = ∫ xi^2 dV = 1/60 """ # Quadrature points in (xi, eta, zeta) = (L1, L2, L3) - pts = TET10_QUAD_POINTS # (4, 3) - wts = TET10_QUAD_WEIGHTS # (4,) + pts = TET10_QUAD_POINTS + wts = TET10_QUAD_WEIGHTS # Test 1: ∫ L0 * L1 dV (off-diagonal, a≠b) exact_ab = 1.0 / 120.0 diff --git a/packages/mechdsl-core/tests/test_tet4_basis.py b/packages/mechdsl-core/tests/test_tet4_basis.py index 8755a9d..83ec49b 100644 --- a/packages/mechdsl-core/tests/test_tet4_basis.py +++ b/packages/mechdsl-core/tests/test_tet4_basis.py @@ -75,7 +75,7 @@ def test_tet4_constant_field_exactness(self): """ # Node coordinates: vertices of the reference tet # N0=(0,0,0), N1=(1,0,0), N2=(0,1,0), N3=(0,0,1) - X = TET4_NODE_COORDS # shape (4, 3) + X = TET4_NODE_COORDS # Define a linear scalar field: u(x, y, z) = 3 + 2x - y + 4z # Nodal values @@ -116,7 +116,6 @@ def test_tet4_jacobian_positive_on_regular_tet(self): # so det(J0) = 1. The quadrature weight 1/6 encodes the reference volume. dNdX, detJ0 = reference_gradient_at_physical(X_elem, q=0) - # Jacobian must be positive assert detJ0 > 0.0, f"Jacobian non-positive: detJ0 = {detJ0}" # For the reference tet (node coords = standard basis vectors), @@ -188,8 +187,6 @@ def test_tet4_elementtype_in_ir(self): ) assert ir_hex.element_type == ElementType.HEX8 - # Unsupported type should still raise (TET10 not yet supported) - # We test by checking TET10 is NOT in the enum (it's a later task) assert not hasattr(ElementType, "TET10") or ElementType.TET10 is not None, ( "TET10 should not yet be in ElementType (it's planned for a later task)" ) diff --git a/packages/mechdsl-core/tests/test_verification_gaps_p5t2.py b/packages/mechdsl-core/tests/test_verification_gaps_p5t2.py index cd5243f..4dae2f7 100644 --- a/packages/mechdsl-core/tests/test_verification_gaps_p5t2.py +++ b/packages/mechdsl-core/tests/test_verification_gaps_p5t2.py @@ -243,12 +243,12 @@ def test_e2_constant_vector_field(self): u_vec = np.array([1.5, -2.3, 0.7]) # Nodal values: all nodes have the same vector - u_nodal = np.tile(u_vec, (8, 1)) # (8, 3) + u_nodal = np.tile(u_vec, (8, 1)) for q_idx, pt in enumerate(quad.points): xi, eta, zeta = pt - N = basis.evaluate(xi, eta, zeta) # (8,) - u_h = N @ u_nodal # (3,) + N = basis.evaluate(xi, eta, zeta) + u_h = N @ u_nodal np.testing.assert_allclose( u_h, u_vec, @@ -315,7 +315,7 @@ def test_e3_jacobian_determinant_unit_cube(self): # Jacobian J_{iI} = sum_a (dN_a/d(xi_I)) * X_{a,i} # shape: J = dN_dxi.T @ X_nodes → (3, 3) - J = dN_dxi.T @ X_nodes # (3, 3) + J = dN_dxi.T @ X_nodes detJ = float(np.linalg.det(J)) assert abs(detJ - expected_detJ) < 1e-14, ( @@ -357,8 +357,8 @@ def test_e3_jacobian_determinant_scaled_cube(self): for q_idx, pt in enumerate(quad.points): xi, eta, zeta = pt - dN_dxi = basis.gradient(xi, eta, zeta) # (8, 3) - J = dN_dxi.T @ X_nodes # (3, 3) + dN_dxi = basis.gradient(xi, eta, zeta) + J = dN_dxi.T @ X_nodes detJ = float(np.linalg.det(J)) assert abs(detJ - expected_detJ) < 1e-12, ( diff --git a/packages/ti-runtime/pyproject.toml b/packages/ti-runtime/pyproject.toml index a17ecdc..14aec0c 100644 --- a/packages/ti-runtime/pyproject.toml +++ b/packages/ti-runtime/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "ti-runtime" 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)" +description = "Neutral Taichi runtime: vector primitives, Tier-1 @ti.func helpers, and solver/operator injection seams for MechDSL-generated code" readme = "README.md" license = "MIT" requires-python = ">=3.11,<3.14" @@ -34,7 +34,7 @@ dependencies = [ exclude-newer = "2026-02-20T00:00:00Z" [project.urls] -Repository = "https://github.com/SOSOVSKI/MechDSL" +Repository = "https://github.com/CEmM2/MechDSL" [tool.hatch.build.targets.wheel] packages = ["src/ti_runtime"] diff --git a/packages/ti-runtime/src/ti_runtime/seams.py b/packages/ti-runtime/src/ti_runtime/seams.py index 5462d16..e95934c 100644 --- a/packages/ti-runtime/src/ti_runtime/seams.py +++ b/packages/ti-runtime/src/ti_runtime/seams.py @@ -130,10 +130,10 @@ def apply_preconditioner(self, z, r) -> None: self.preconditioner.apply(z, r) -# ── Time-integration seam (PlanJune14 P6-1) ────────────────────────────────── +# ── Time-integration seam ──────────────────────────────────────────────────── # # The temporal analogue of LinearSolveContext: a generated *time integrator* -# (e.g. the Newmark-beta step transpiled from dev/algorithms/newmark.tex) plugs +# (e.g. a Newmark-beta step transpiled from its LaTeX algorithm spec) plugs # into a stable wrapper here, exactly as a generated linear solver plugs into # set_solver. The wrapper is integrator-agnostic — it applies *whatever* step # body was injected (Newmark-beta, central difference, HHT, ...). @@ -148,7 +148,7 @@ def apply_preconditioner(self, z, r) -> None: IntegratorStep = Callable[..., object] # accel_solve(u_pred, v_pred, a_out): a_out = a_{n+1}, in place (out LAST). -# Returns an optional status (WI-3): ``None`` for a solve that cannot fail +# Returns an optional status: ``None`` for a solve that cannot fail # (e.g. an elementwise SDOF/diagonal-mass solve), or a convergence flag for an # iterative solve (a falsy flag == not converged). TimeIntegrationContext.step # consumes it to roll back and fail loud rather than advancing on a bad solve. @@ -196,7 +196,7 @@ def set_apply(self, fn: AccelSolveApply) -> "AccelSolve": def apply(self, u_pred, v_pred, a_out) -> object: if self._apply is None: raise RuntimeError("AccelSolve has no body injected; call set_accel_solve(...) first.") - # Forward the injected solve's return value (WI-3): a convergence status + # Forward the injected solve's return value: a convergence status # when the solve is iterative, else ``None``. The caller (step) decides. return self._apply(u_pred, v_pred, a_out) @@ -274,7 +274,7 @@ def accel_solve(u_pred, v_pred, a_out): try: result = self.integrator.step(u, v, a, accel_solve, self.dt, self.beta, self.gamma) except Exception: - # A raising solve (e.g. the WI-2 seam PCG) may have left state + # A raising solve (e.g. the seam-injected PCG) may have left state # half-advanced -- restore before propagating. _restore() raise diff --git a/packages/ti-runtime/tests/test_vector_ops.py b/packages/ti-runtime/tests/test_vector_ops.py index 700c83b..feef9fe 100644 --- a/packages/ti-runtime/tests/test_vector_ops.py +++ b/packages/ti-runtime/tests/test_vector_ops.py @@ -37,7 +37,7 @@ def test_xpay(): rng = np.random.default_rng(2) xv, yv = rng.standard_normal((4, 3)), rng.standard_normal((4, 3)) x, y = _vfield(xv), _vfield(yv) - v.xpay(x, -0.5, y) # x = -0.5*x + y + v.xpay(x, -0.5, y) np.testing.assert_allclose(x.to_numpy(), -0.5 * xv + yv, rtol=1e-12) diff --git a/pyproject.toml b/pyproject.toml index 75cc594..b1c76ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,8 @@ name = "mechdsl-workspace" version = "0.2.0" description = "MechDSL monorepo — LaTeX-to-FEM compiler + algorithm transpiler" +license = "MIT" +license-files = ["LICENSE"] requires-python = ">=3.11,<3.14" dependencies = [ "markdown>=3.10.2", diff --git a/uv.lock b/uv.lock index b0e2408..bbdfdd7 100644 --- a/uv.lock +++ b/uv.lock @@ -659,7 +659,7 @@ verify = [ [package.metadata] requires-dist = [ { name = "algo2code", marker = "extra == 'verify'", editable = "packages/algo2code" }, - { name = "nrpylatex", git = "https://github.com/SOSOVSKI/nrpylatex" }, + { name = "nrpylatex", specifier = ">=1.4.0,<2" }, { name = "numpy", specifier = ">=2.4.2" }, { name = "opt-einsum", specifier = ">=3.3" }, { name = "pyyaml", specifier = ">=6.0.3" }, @@ -864,10 +864,15 @@ wheels = [ [[package]] name = "nrpylatex" version = "1.4.0" -source = { git = "https://github.com/SOSOVSKI/nrpylatex#8745b2b1f62546066c55b2a96769bd40082936f3" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sympy" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/d5/7b/f374701590597a4b0a983599ba4db53dbfdc923b7ac48bc6f8492981ac04/nrpylatex-1.4.0.tar.gz", hash = "sha256:56685c58b62f90875bf7a75d0115f2b7b9154c2554f1e0968dee5786ac334b8c", size = 32828, upload-time = "2024-12-19T16:45:54.203Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/bc/ec27c8481714a73853d12fdde15b6a7321cec7a144e2df5e395f288ea491/nrpylatex-1.4.0-py2.py3-none-any.whl", hash = "sha256:a78943e579039c0371e90e3123afe2d35a4604c2c42fcdc857760f9e104b4a22", size = 36361, upload-time = "2024-12-19T16:45:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/46/72/7a5d62aa8a4bd964814ef65707319ab5dbc97fabb7b216f4bb9440f842e8/nrpylatex-1.4.0-py3-none-any.whl", hash = "sha256:786f1c8529df21ef0e6f2e3323fc8d1ef92e40151e70b5186a6052284756962d", size = 36327, upload-time = "2024-12-19T16:45:52.023Z" }, +] [[package]] name = "numpy"