From 1030bb66403f013d3911a336c2b9f6c4f90588d8 Mon Sep 17 00:00:00 2001 From: cekees <613654+cekees@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:53:32 +0100 Subject: [PATCH 1/3] Tests skip, not error, without an extra; CI legs with one extra each The extras were tested only all-or-nothing: the core smoke test, then every extra at once. Installing one extra alone shows what that hid: | install | before | after | |------------------|-------------------------------------|------------------------| | ymf[spec] | 427 passed, 20 skipped | 428 passed, 19 skipped | | ymf[units] | 7 collection errors (run aborted) | 331 passed, 48 skipped | | | then 18 failed under them | | | ymf[symbolic] | 1 failed (run from tests/) | 525 passed, 14 skipped | | every extra | 596 passed, 2 skipped | 596 passed, 2 skipped | - Seven files imported ymf.schema (strictyaml) above their importorskip, or had none: test_compose, test_domain, test_examples, test_references, test_removal, test_schema, test_values. The guard now comes first. - Fifteen tests needed strictyaml or sympy and did not say so: the closure tests (one guard in their spec helper), the old-private-name alias test, the notebook round trip, three units tests. The Fourier number is in the spec notation, so without ymf[symbolic] it is asserted absent, not skipped. - Two tests opened examples/ relative to the working directory, so they passed only from the repo root; they use their own path like the rest. - A one-extra job, linux-64 / py3.12, for spec, units and symbolic: install the sdist with that extra alone, import its modules, run the suite. No module changed. Measured on macOS arm64 with python 3.13 venvs holding one extra each, and the petsc-dev env for every extra. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 50 +++++++++++++++++++++++++++++ tests/test_closure.py | 1 + tests/test_compose.py | 3 +- tests/test_core_without_frontend.py | 3 +- tests/test_domain.py | 7 ++-- tests/test_examples.py | 1 + tests/test_references.py | 3 +- tests/test_removal.py | 3 +- tests/test_schema.py | 3 +- tests/test_units.py | 19 ++++++++--- tests/test_values.py | 3 +- tests/test_xdmf.py | 1 + 12 files changed, 85 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 24f4fd3..897b91b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -85,3 +85,53 @@ jobs: if [ -n "$unexpected" ]; then echo "unexpected skips:"; echo "$unexpected"; exit 1 fi + + one-extra: + # The leg above installs the core alone and then everything. These install + # one extra each, so the extras' boundaries are tested in both directions: + # the extra's own modules import without the others, and every test that + # needs another extra skips rather than erroring at collection. + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - {extra: spec, modules: "ymf.schema, ymf.compose, ymf.generated"} + - {extra: units, modules: "ymf.units"} + - {extra: symbolic, modules: "ymf.symbolic, ymf.schema"} + name: linux-64 / py3.12 / ymf[${{ matrix.extra }}] alone + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build sdist + run: | + python -m pip install --upgrade pip build + python -m build --sdist + + - name: Install ymf[${{ matrix.extra }}] alone + shell: bash + run: | + python -m venv /tmp/one + /tmp/one/bin/pip install "$(ls dist/ymf-*.tar.gz)[${{ matrix.extra }}]" pytest + + - name: Its modules import without the other extras + shell: bash + run: | + cd /tmp + /tmp/one/bin/python -c "import ymf, ${{ matrix.modules }}; print('ymf', ymf.__version__, 'imports ${{ matrix.modules }}')" + + - name: Run test suite + shell: bash + run: | + # The tests that need another extra skip themselves; a collection + # error or a failure here is a test, or a module, that assumes an + # extra it does not declare. + export PATH=/tmp/one/bin:$PATH + python -c "import ymf; print(ymf.__file__)" | grep -v "/src/ymf/" + python -m pytest tests -q -rs -p no:cacheprovider diff --git a/tests/test_closure.py b/tests/test_closure.py index 576597a..e8805fe 100644 --- a/tests/test_closure.py +++ b/tests/test_closure.py @@ -15,6 +15,7 @@ def spec_with_mesh(tmp_path, mesh=MESH, name="poisson.ymf"): """examples/poisson.ymf with this mesh on P1_linear, and none on P2.""" + pytest.importorskip("strictyaml") # closure.load composes the spec: ymf[spec] text = "".join(line for line in (EXAMPLES / "poisson.ymf").read_text(encoding="utf-8").splitlines(True) if not line.lstrip().startswith("mesh:")) diff --git a/tests/test_compose.py b/tests/test_compose.py index db3eb94..f898059 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -3,7 +3,8 @@ from pathlib import Path import pytest -import strictyaml + +strictyaml = pytest.importorskip("strictyaml") from ymf.compose import YmfCompositionError from ymf.schema import load_ymf diff --git a/tests/test_core_without_frontend.py b/tests/test_core_without_frontend.py index e9f3be6..3d71a30 100644 --- a/tests/test_core_without_frontend.py +++ b/tests/test_core_without_frontend.py @@ -81,7 +81,8 @@ def test_which_loader_a_file_needs_is_a_core_question(): def test_compose_still_offers_the_old_private_name(): - """Proteus imports ymf.compose._is_archive_text; it must keep working.""" + """Proteus imported ymf.compose._is_archive_text; it must keep working.""" + pytest.importorskip("strictyaml") # ymf.compose is ymf[spec] from ymf.archive import is_archive_text from ymf.compose import _is_archive_text assert _is_archive_text is is_archive_text diff --git a/tests/test_domain.py b/tests/test_domain.py index f27f37e..5e4a65e 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -13,12 +13,15 @@ state of whatever object happens to hold it. """ +from pathlib import Path + import pytest +strictyaml = pytest.importorskip("strictyaml") + from ymf import load_ymf from ymf.domain import DOMAIN_KINDS, YmfDomainError, box_bounds, validate_domain_block -strictyaml = pytest.importorskip("strictyaml") def spec(domain): @@ -152,7 +155,7 @@ def test_the_two_box_forms_give_the_same_problem(): from ymf.symbolic.problem import adr_problem - doc = load_ymf("examples/poisson.ymf") + doc = load_ymf(Path(__file__).parent.parent / "examples" / "poisson.ymf") mapped = copy.deepcopy(doc) mapped["model"]["strong_form"]["domain"] = { "kind": "box", "lower": [0.0, 0.0], "upper": [1.0, 1.0]} diff --git a/tests/test_examples.py b/tests/test_examples.py index 483c526..0d76b57 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -8,6 +8,7 @@ import pytest # the example scripts walk the unit checks, so they need the units extra +pytest.importorskip("strictyaml") pytest.importorskip("pint") from ymf.schema import load_ymf diff --git a/tests/test_references.py b/tests/test_references.py index 5e886cd..b3cd657 100644 --- a/tests/test_references.py +++ b/tests/test_references.py @@ -15,10 +15,11 @@ import pytest +strictyaml = pytest.importorskip("strictyaml") + from ymf import load_ymf from ymf.references import YmfReferenceError -strictyaml = pytest.importorskip("strictyaml") def spec(weak_forms, discretizations, analytical="", vvuq="", extra=""): diff --git a/tests/test_removal.py b/tests/test_removal.py index 18618a4..4fe5806 100644 --- a/tests/test_removal.py +++ b/tests/test_removal.py @@ -16,10 +16,11 @@ import pytest +strictyaml = pytest.importorskip("strictyaml") + from ymf import load_ymf from ymf.compose import YmfRemovalError -strictyaml = pytest.importorskip("strictyaml") MODEL = """\ diff --git a/tests/test_schema.py b/tests/test_schema.py index 1cf3afd..fa14c18 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,7 +1,8 @@ from pathlib import Path import pytest -import strictyaml + +strictyaml = pytest.importorskip("strictyaml") from ymf.schema import load_ymf diff --git a/tests/test_units.py b/tests/test_units.py index f68ee2f..e5112b3 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -1,4 +1,7 @@ +import importlib.util import math +from pathlib import Path + import pytest # The YMF core deliberately depends on pyyaml alone, so a solver writing @@ -209,13 +212,18 @@ def test_compute_dimensionless_numbers_uses_validated_coefficients(coefficients_ # Regression: coefficients are MapPattern(Str(), Any()) in the schema, so # strictyaml returns '1.0e-3' (a string) and formulas referencing kappa # used to be silently dropped. + pytest.importorskip("strictyaml") from ymf import validate_ymf doc = validate_ymf(_heat_doc_with_numbers(coefficients_yaml)) numbers = compute_dimensionless_numbers(doc["model"]) assert numbers["Peclet"] == pytest.approx(0.5 * 1.0 / 1.0e-3) - # the time scale is derived, 1/(2π²κ), so κ·time/L² is 1/(2π²) exactly - assert numbers["Fourier"] == pytest.approx(1.0 / (2.0 * math.pi ** 2)) + # the time scale is derived, 1/(2π²κ), so κ·time/L² is 1/(2π²) exactly; + # that formula is in the spec notation, which only ymf[symbolic] reads + if importlib.util.find_spec("sympy") is None: + assert "Fourier" not in numbers + else: + assert numbers["Fourier"] == pytest.approx(1.0 / (2.0 * math.pi ** 2)) def test_an_expression_coefficient_resolves_but_a_non_number_still_does_not(): @@ -225,6 +233,7 @@ def test_an_expression_coefficient_resolves_but_a_non_number_still_does_not(): expression-valued coefficient went silently missing while adr_problem resolved the same coefficient without difficulty. """ + pytest.importorskip("sympy") # evaluating "π²" is ymf.symbolic's job problem = { "characteristic_scales": {"length": {"value": 1.0, "units": "m"}}, "strong_form": {"coefficients": {"Q": "π²", "k": "nan", "flag": True}}, @@ -240,12 +249,13 @@ def test_an_expression_coefficient_resolves_but_a_non_number_still_does_not(): def test_units_and_the_solver_agree_on_a_coefficient(): """One spec, one answer -- the reason sympy is in the frontend extra.""" + pytest.importorskip("strictyaml") + pytest.importorskip("sympy") from ymf import load_ymf from ymf.symbolic.problem import coefficient_numbers from ymf.units import _coefficient_values - pytest.importorskip("sympy") - problem = load_ymf("examples/kovasznay_flow.ymf")["model"] + problem = load_ymf(Path(__file__).parent.parent / "examples" / "kovasznay_flow.ymf")["model"] by_units = _coefficient_values(problem) by_solver = coefficient_numbers(problem["strong_form"]["coefficients"]) assert by_units == by_solver @@ -573,3 +583,4 @@ def test_checking_twice_reuses_the_stand_in_units(): doc = _conduction({"type": "neumann", "value": 1.0}, unknown_units=None) first = [str(i) for i in check(doc, missing_units="warn")] assert first == [str(i) for i in check(doc, missing_units="warn")] + diff --git a/tests/test_values.py b/tests/test_values.py index b11fa43..ebacef5 100644 --- a/tests/test_values.py +++ b/tests/test_values.py @@ -15,9 +15,10 @@ import pytest +strictyaml = pytest.importorskip("strictyaml") + from ymf import load_ymf -strictyaml = pytest.importorskip("strictyaml") SPEC = """\ diff --git a/tests/test_xdmf.py b/tests/test_xdmf.py index f25aa05..014f771 100644 --- a/tests/test_xdmf.py +++ b/tests/test_xdmf.py @@ -224,6 +224,7 @@ def test_ymf_extra_with_special_xml_chars_survives_round_trip(tmp_path): def test_full_notebook_example_round_trips(tmp_path): """End-to-end: the same Problem doc used in YMF-updated.ipynb, plus the mesh archive, written and read back through write_xdmf/read_xdmf.""" + pytest.importorskip("strictyaml") from ymf import validate_ymf poisson_problem_yaml = """ From 3ac4e633bbbb47d79721581d633b66af87405144 Mon Sep 17 00:00:00 2001 From: cekees <613654+cekees@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:53:45 +0100 Subject: [PATCH 2/3] Tests for three gaps: units without symbolic, a composed domain, classify The cleanup inventory listed four gaps to guard before any code moves; the previous commit is the first (one-extra CI legs). These are the other three. - units without ymf[symbolic] (test_units, 2 tests). problem-specification promises that a bare ymf[units] install still sees the coefficients that are already numbers. Nothing tested it. The tests hide sympy and ymf.symbolic, so the fallback is checked in every environment, and the units-only leg runs them for real: numbers and quoted numbers resolve, expression and notation formulas are left out, and a neumann condition is reported "not checked ... ymf[symbolic]" under missing_units="error" rather than failed. - a domain composed across files (test_domain, 3 tests). Changing the kind in a child is refused, naming the parent's keys; replacing the region through remove: works and is logged. Adding boundary_tags in a child to the parent's region is refused today ("required key(s) 'kind' not found"): _partial does not relax a Str() | Map() validator. That test is xfail(strict=True) until the fix, a separate PR. - classify on a coupled system (test_symbolic, 4 tests): cross-diffusion is keyed by the other unknown, a flux that depends on another unknown is advection, |grad phi|^2 is Hamiltonian, hamiltonian_gradients moves grad p out of the flux, and an undeclared name is refused. The two-phase model family builds on this step. | install | before (previous commit) | after | |---------------|--------------------------|------------------------------------| | every extra | 596 passed, 2 skipped | 604 passed, 2 skipped, 1 xfailed | | ymf[spec] | 428 passed, 19 skipped | 430 passed, 19 skipped, 1 xfailed | | ymf[units] | 331 passed, 48 skipped | 333 passed, 48 skipped | | ymf[symbolic] | 525 passed, 14 skipped | 531 passed, 14 skipped, 1 xfailed | Co-Authored-By: Claude Opus 5.5 --- tests/test_domain.py | 61 ++++++++++++++++++++++++++++++++++++ tests/test_symbolic.py | 71 ++++++++++++++++++++++++++++++++++++++++++ tests/test_units.py | 52 +++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+) diff --git a/tests/test_domain.py b/tests/test_domain.py index 5e4a65e..3c10f84 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -174,6 +174,67 @@ def test_a_kind_nothing_solves_yet_says_so(tmp_path): adr_problem(doc) +# -------------------------------------------------------------------------- +# a domain composed across files +# -------------------------------------------------------------------------- + +PSLG_MODEL = spec(""" + kind: piecewise_linear + dimension: 2 + vertices: [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] + segments: [[0, 1], [1, 2], [2, 3], [3, 0]]""") + + +def compose(tmp_path, child): + (tmp_path / "model.ymf").write_text(PSLG_MODEL, encoding="utf-8") + (tmp_path / "child.ymf").write_text( + "ymf_state: abstract\nextends: model.ymf\n" + child, encoding="utf-8") + return load_ymf(tmp_path / "child.ymf") + + +@pytest.mark.xfail(strict=True, reason=( + "the partial schema does not relax a Str() | Map() validator, so every " + "file in a chain must restate the domain's kind")) +def test_a_child_may_tag_the_region_its_parent_states(tmp_path): + """The region in the model, the names of its boundaries in the problem.""" + doc = compose(tmp_path, """\ +model: + strong_form: + domain: + boundary_tags: {bottom: 1, right: 2, top: 3, left: 4} +""") + domain = doc["model"]["strong_form"]["domain"] + assert domain["kind"] == "piecewise_linear" and len(domain["vertices"]) == 4 + assert domain["boundary_tags"]["left"] == 4 + + +def test_a_child_that_changes_the_kind_keeps_the_parents_keys_and_is_refused(tmp_path): + """Mappings merge key by key, so a box over a PSLG still has vertices. + The composed domain is checked against its kind, which names them.""" + with pytest.raises(YmfDomainError, match="kind 'box' does not take segments, vertices"): + compose(tmp_path, """\ +model: + strong_form: + domain: {kind: box, lower: [0.0, 0.0], upper: [1.0, 2.0]} +""") + + +def test_a_child_replaces_the_region_by_removing_what_it_does_not_keep(tmp_path): + doc = compose(tmp_path, """\ +remove: + - "model.strong_form.domain.vertices" + - "model.strong_form.domain.segments" +model: + strong_form: + domain: {kind: box, lower: [0.0, 0.0], upper: [1.0, 2.0]} +""") + domain = doc["model"]["strong_form"]["domain"] + assert box_bounds(domain) == [[0.0, 0.0], [1.0, 2.0]] + assert "vertices" not in domain and "segments" not in domain + removed = [r["path"] for r in doc["composition"]["removals"]] + assert removed == ["model.strong_form.domain.vertices", "model.strong_form.domain.segments"] + + def test_every_kind_is_covered_by_the_tests_above(): """So a kind added later is not silently untested.""" assert set(DOMAIN_KINDS) == {"box", "piecewise_linear", "discrete", "file"} diff --git a/tests/test_symbolic.py b/tests/test_symbolic.py index 5f62e9d..bb0386a 100644 --- a/tests/test_symbolic.py +++ b/tests/test_symbolic.py @@ -331,3 +331,74 @@ def test_strong_form_equations_are_what_adr_problem_classifies(): assert len(residuals) == 3 assert [e["component"] for e in adr_problem(doc)["adr"]["equations"]] == space.components assert to_notation(residuals[2]) == "∂v_0/∂x + ∂v_1/∂y" + + +# --- classify, directly, on a coupled system --------------------------------- +# +# adr_form exports plain data; classify is the step before it, still in sympy, +# and the one a model family (a level set advected by a flow, a density that +# depends on the level set) builds on. These pin down where each kind of +# coupling term lands, one scalar residual at a time. + + +def _classified(lines, unknowns, coefficients=None, dim=2, **kwargs): + from ymf.symbolic.adr import classify as classify_residual + space = Space(dim, unknowns, coefficients or {}) + residuals = [] + for line in lines: + residuals.extend(parse_equation(line, space)) + return space, [classify_residual(r, space, **kwargs) for r in residuals] + + +def _symbols(space): + """The space's own symbols, by component name (they carry assumptions, + so a bare sympy.Symbol of the same name is a different symbol).""" + return dict(zip(space.components, space.component_symbols)) + + +def test_classify_keys_cross_diffusion_by_the_other_unknown(): + """-div(phi grad phi) in u's equation is diffusion of phi, not of u.""" + space, (eq_u, eq_phi) = _classified( + ["dt(u) - div(k*grad(u)) - div(phi*grad(phi)) + u*phi = 0", + "dt(phi) + div(phi*(u, 0)) = 0"], + {"u": 0, "phi": 0}, {"k": 2.0}) + u, phi = _symbols(space)["u"], _symbols(space)["phi"] + assert eq_u.mass == u + assert set(eq_u.diffusion) == {"u", "phi"} + assert eq_u.diffusion["u"] == 2.0 * sympy.eye(2) + assert eq_u.diffusion["phi"] == phi * sympy.eye(2) + assert sympy.simplify(eq_u.reaction - u * phi) == 0 + assert eq_u.advection == [0, 0] and eq_u.hamiltonian == 0 + # phi advected by a flux that depends on u: advection, nothing else + assert eq_phi.mass == phi + assert eq_phi.advection == [phi * u, 0] + assert eq_phi.diffusion == {} and eq_phi.reaction == 0 and eq_phi.hamiltonian == 0 + + +def test_classify_puts_a_nonlinear_gradient_term_in_the_hamiltonian(): + """|grad phi|^2 is not a divergence: it cannot be a flux.""" + space, (eq,) = _classified(["dt(phi) + dx(phi)**2 + dy(phi)**2 = 1"], {"phi": 0}) + assert eq.mass == _symbols(space)["phi"] + assert eq.advection == [0, 0] and eq.diffusion == {} + assert eq.reaction == -1 + assert eq.hamiltonian != 0 + + +def test_classify_moves_a_named_gradient_from_the_flux_to_the_hamiltonian(): + """grad(p) is div(p I) or H = dp/dx_j: one strong form, two weak forms.""" + lines = ["rho*dt(v) + div(rho*outer(v, v)) - div(mu*grad(v)) + grad(p) = 0"] + unknowns, coefficients = {"v": 1, "p": 0}, {"rho": 1.0, "mu": 1.0} + space, as_flux = _classified(lines, unknowns, coefficients) + _, as_h = _classified(lines, unknowns, coefficients, hamiltonian_gradients=("p",)) + p = _symbols(space)["p"] + for axis, (flux, h) in enumerate(zip(as_flux, as_h)): + assert flux.advection[axis].has(p) and flux.hamiltonian == 0 + assert not any(f.has(p) for f in h.advection) + assert h.hamiltonian.has(p) + + +def test_classify_refuses_a_name_that_is_not_declared(): + from ymf.symbolic.adr import classify as classify_residual + space = Space(2, {"u": 0}, {}) + with pytest.raises(SymbolicError, match="undefined names q"): + classify_residual(sympy.Symbol("q") * _symbols(space)["u"], space) diff --git a/tests/test_units.py b/tests/test_units.py index e5112b3..1a0ce50 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -1,5 +1,6 @@ import importlib.util import math +import sys from pathlib import Path import pytest @@ -584,3 +585,54 @@ def test_checking_twice_reuses_the_stand_in_units(): first = [str(i) for i in check(doc, missing_units="warn")] assert first == [str(i) for i in check(doc, missing_units="warn")] + +# --------------------------------------------------------------------------- +# a bare ymf[units] install, without ymf[symbolic] +# --------------------------------------------------------------------------- + + +def _without_symbolic(monkeypatch): + """Make sympy and ymf.symbolic unimportable for the rest of the test. + + The units-only CI leg runs this for real; here it runs in the full + environment too, so the fallback is guarded wherever the suite runs. + """ + for name in list(sys.modules): + if name in ("sympy", "ymf.symbolic") or name.startswith(("sympy.", "ymf.symbolic.")): + monkeypatch.delitem(sys.modules, name) + monkeypatch.setitem(sys.modules, "sympy", None) + monkeypatch.setitem(sys.modules, "ymf.symbolic", None) + + +def test_without_symbolic_numbers_resolve_and_expressions_are_left_out(monkeypatch): + """What problem-specification.md promises a bare ymf[units] install: + it still sees the coefficients that are already numbers.""" + _without_symbolic(monkeypatch) + problem = { + "characteristic_scales": {"length": {"value": 2.0, "units": "m"}}, + "strong_form": {"coefficients": {"k": 3.0, "c": {"value": "0.5"}, "Q": "π²"}}, + "dimensionless_numbers": { + "A": {"formula": "k * L"}, # a number: resolves + "B": {"formula": "c * L"}, # a quoted number: resolves + "C": {"formula": "Q * L"}, # an expression coefficient: left out + "D": {"formula": "2πL"}, # the spec notation: left out + }, + } + assert compute_dimensionless_numbers(problem) == { + "A": pytest.approx(6.0), "B": pytest.approx(1.0)} + + +def test_without_symbolic_a_flux_condition_is_undecided_not_wrong(monkeypatch): + """A neumann g is compared with [k][T]/[L], which needs the equation read: + without ymf[symbolic] the check says it did not check, and does not fail + a condition that may well be right.""" + _without_symbolic(monkeypatch) + bc = {"type": "neumann", "value": 5.0, "units": "W/m**2"} + assert check(_conduction(bc)).ok # skip: nothing to report + strict = check(_conduction(bc), missing_units="error") + assert not strict.ok + assert all("not checked" in e for e in _errors(strict)) + assert any("ymf[symbolic]" in e for e in _errors(strict)) + # a dirichlet condition needs no equation, so it is still decided + assert check(_conduction({"type": "dirichlet", "value": 300.0, "units": "K"}), + missing_units="error").ok From 2254a3ad75903a2a77ea0ba4dd4e2ddf46a7b2dd Mon Sep 17 00:00:00 2001 From: cekees <613654+cekees@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:55:36 +0100 Subject: [PATCH 3/3] A file in an extends chain may tag its parent's region without its kind A child that only adds boundary_tags to the region its model states was refused: "required key(s) 'kind' not found". _partial, which relaxes the schema for the files of a chain, descended into mappings and lists of mappings but not into an alternative, and the domain is Str() | Map(...), so its mapping kept `kind` required in every file. Both sides of an alternative are now relaxed; the composed domain is still checked against its kind by ymf.domain.validate_domain_block, as before. Blast radius, walked over YMF_SCHEMA: three alternatives hold a mapping (unknowns[], strong_form.domain, coefficients.*), and _partial reaches only the domain (it does not descend a Seq of alternatives or a MapPattern). So this changes one site. | suite | before | after | |-------------------------------|-----------------------------------|------------------------| | every extra (petsc-dev) | 604 passed, 2 skipped, 1 xfailed | 605 passed, 2 skipped | | ymf[spec] alone | 430 passed, 19 skipped, 1 xfailed | 431 passed, 19 skipped | | ymf[symbolic] alone | 531 passed, 14 skipped, 1 xfailed | 532 passed, 14 skipped | The xfail(strict=True) on test_a_child_may_tag_the_region_its_parent_states comes off. Co-Authored-By: Claude Opus 5.5 --- src/ymf/schema.py | 9 +++++++++ tests/test_domain.py | 3 --- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ymf/schema.py b/src/ymf/schema.py index 92a126c..f28e473 100644 --- a/src/ymf/schema.py +++ b/src/ymf/schema.py @@ -27,6 +27,7 @@ Seq, Str, ) +from strictyaml.validators import OrValidator from ymf.discretizations import METHODS from ymf.state import STATES @@ -506,7 +507,15 @@ def _partial(validator, keep=()): an inherited discretization by naming it and giving only that field. The composed document is validated in full, so an entry that is new and incomplete is still caught there. + + Both sides of an alternative are relaxed too: the domain is the notation + string or a mapping, and a file that tags an inherited region writes + ``boundary_tags`` without restating its ``kind``. The composed domain + is checked against its kind by ymf.domain. """ + if isinstance(validator, OrValidator): + return OrValidator(_partial(validator._validator_a, keep), + _partial(validator._validator_b, keep)) if isinstance(validator, Map): return Map({ (key if isinstance(key, Optional) or key in keep else Optional(key)): diff --git a/tests/test_domain.py b/tests/test_domain.py index 3c10f84..e7ddc9a 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -192,9 +192,6 @@ def compose(tmp_path, child): return load_ymf(tmp_path / "child.ymf") -@pytest.mark.xfail(strict=True, reason=( - "the partial schema does not relax a Str() | Map() validator, so every " - "file in a chain must restate the domain's kind")) def test_a_child_may_tag_the_region_its_parent_states(tmp_path): """The region in the model, the names of its boundaries in the problem.""" doc = compose(tmp_path, """\