Skip to content
15 changes: 15 additions & 0 deletions docs/archive-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ provenance:
tool: a mesh converter # free text: the archive names no tools
version: '0.1'
files: [{name: square.msh, sha256: 3f5a...}]
fingerprint: {method: ymf-mesh-1, sha256: 4631...} # of the mesh itself
extra: {...} # anything else the writer keeps
```

Expand All @@ -443,6 +444,20 @@ each source file's name and SHA-256 (`ymf.mesh.file_provenance(tool, paths)`
computes them), so a reader can tell whether the mesh still matches the
files beside it. It takes no other keys; `extra` holds the rest.

**The fingerprint** identifies the mesh itself: `ymf.mesh.fingerprint(nodes,
cells)` is the SHA-256 of its coordinates (float64, three columns) and its
cells' node numbers (int64), in the input order, with the shapes and a tag
naming the method (`ymf-mesh-1`, recorded beside the digest so a later
definition can be told apart). A writer that stores the rows in another
order -- a partitioned run writing each rank's rows contiguously -- records
the digest of the input order, so it does not depend on the rank count. A
reader that is about to read data on a mesh (a restart, a gauge) recomputes
it: `ymf.mesh.check_fingerprint(grid, base_dir, digest=None)` returns the
recorded digest, None when there is none, and raises when the grid holds
another mesh; `digest` is the input-order digest the caller computed when the
rows are permuted. Coordinates are hashed exactly: the digest identifies
these numbers, not a mesh up to round-off.

**Geometry order is the cell type's.** `Tri_6` is a quadratic triangle and
`Tet_10` a quadratic tetrahedron; there is no separate order field to
disagree with the type (`geometry_order(type)` reads it off). A quadratic
Expand Down
44 changes: 44 additions & 0 deletions docs/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,50 @@ rediscover why a thing is the way it is. Open gaps are in

## 2026-11

- **2026-11-05: a mesh records its fingerprint; a discrete domain loads from
a spec file.** cekees, 2026-11-05 (C7.1 round 2): store the mesh's
fingerprint in the archive so a restart or a gauge can check the mesh
before reading. `provenance(..., fingerprint=)` records `{method:
ymf-mesh-1, sha256}`; `ymf.mesh.fingerprint`, `grid_fingerprint` and
`check_fingerprint` define and check it (bytes in `archive-format.md`).
Two gaps proteus found posing a `discrete` domain from a spec file: the
strict schema now takes `attributes` and `provenance` on it (`ymf.domain`
already did), and its inline arrays, which strictyaml reads as text, are
typed by each item's `data_type` on load (`ymf.domain.type_discrete_block`);
the archive itself still refuses text where it expects a number. An
inline mesh stored with three coordinates (XYZ) is posed in its
`dimension`; it was posed in 3D, which proteus solved with a nan error.
- **2026-11-05: the wet/dry cutoff (C42 round 2).** The last
regularization the model-family note lists (§ 3.1 item 5, `u = q/h` at
`h = 0`): `regularize: {inverse: {by: wet_dry_cutoff, of: h, width: hε}}`
reads each negative power of `h` through SW2DCV's `one_over_hReg`. The
shallow-water numerics fixture binds `hε` (`meaning: dry_depth`, the
solver's `1e-5 max(h0)`) and uses it; exact above `hε`, so Torino's drag
and porosity still match the deck to 1e-12.
- **2026-11-05: the plain data keeps a coefficient of the state by name
(C42 round 2).** § 7 of the model-family note: substitution is enough for
a runner, but the generator (C19) and the dispatcher want `ρ` as one
inline function of the state. `adr_problem` and `split_problem` now give
each coefficient of the state its `inline` code and `uses`, and `named`
(`order` and the slot values over the names) beside the substituted
`adr`. Additive, and absent without coefficients of the state; an archive
Proteus wrote for such a spec before this reports "the ADR form differs"
under `--check`, because `ymf_run` records the whole `adr_problem`.
- **2026-11-05: closure writes out the split and names the coefficients of
the state (C42 round 2).** The same reasoning as the alias expansion: a
closed dam break said `φ: previous_step` and left the sub-problems'
solvers, regularizations and carriers to `split_problem`'s defaults, and
said nothing of which coefficients follow the state. `ymf.explicit`
(closure.write, closure.input_slice) now writes each sub-problem's
`for`, merged `solver` and `regularize`, and its lags as `{of, via, at,
carried_by}`, and gives every coefficient whose formula names an unknown
`depends_on`. The names are read without sympy (`ymf.names`, the
notation's tokenizer moved out of `ymf.symbolic.notation`, with the
parser's rule for a run of letters), so the core closes a spec as the
front end does. `depends_on` is checked on load and left out of the key;
split specs re-key (the dam break and shallow-water fixtures; no solver
ran them), every other spec keeps its keys and, without coefficients of
the state, closes to the same document.
- **2026-11-04: aliases expand into the abstract forms, and closure records
the expansion.** cekees's review note: `slip`, `open`, `inflow`,
`characteristic` and condition sets had their meaning only in code
Expand Down
56 changes: 48 additions & 8 deletions docs/problem-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,25 @@ with an unknown.
under `coefficients` -- its code, what it depends on, and its derivative
in each unknown (`sharp: true` and no derivative for `heaviside` or `sign`
of an unknown) -- so a code generator can emit `ρ` once as a function of
the state rather than its expansion in every term. A problem with no such
coefficient has no `coefficients` key and reads exactly as before.
the state rather than its expansion in every term. Each also has its
`inline` code -- its formula over the state, constants substituted, other
coefficients of the state kept by name (`uses`) -- and the problem gains
`named`: the `order` to evaluate those coefficients in (each names only
earlier ones) and the ADR form's slot values over the names (`m: v_0*ρ`,
`a: [2*μ, μ]`). The equations are read a second time for it, with each
coefficient of the state a known field, so a gradient of one is kept (as
`grad_ρ_0`) rather than lost as a constant's. The derivatives and
`depends_on` are only in the substituted form, which is exact; in `named` a
name is held fixed. A problem with no such coefficient has neither key and
reads exactly as before.

A closed spec (an archive) names them too: closure writes each coefficient
of the state in the map form with `depends_on`, the unknowns it follows,
directly or through another coefficient (`ρ: {formula: "ρw (1 − χ) + ρa χ",
depends_on: [φ]}`). It is read from the formulas' text (`ymf.names`, no
sympy, so every environment closes a spec alike), checked on load (a
`depends_on` that is not what the formula says is refused), and does not
count towards an output's key: the formula it comes from already does.

Tensors follow the continuum-mechanics convention `grad(v)[i, j] =
∂v_j/∂x_i`, so `v·∇v` is (v·∇)v and `∇·(μ∇v)` is the vector Laplacian.
Expand Down Expand Up @@ -770,7 +787,14 @@ previous step left it, or as an earlier sub-problem of this step left it
`solver` refines the entry's, which stays required as the default.
`binds` names the symbols the discretization supplies and the model may not
use (the element size, a smoothing width), and `regularize` the sharp
functions it replaces with smooth ones (`heaviside`, `sign`).
functions it replaces with smooth ones (`heaviside`, `sign`), and the
reciprocal of an unknown that may vanish: `inverse: {by: wet_dry_cutoff,
of: h, width: hε}` reads every negative power of the depth `h` (`q⊗q/h`,
Manning's `h^(-7/3)`) through Proteus SW2DCV's `one_over_hReg`,
`2h/(h² + max(h, hε)²)`, which is `1/h` wherever `h ≥ hε` and 0 at `h = 0`.
It is applied before anything is differentiated, so the derivatives are the
regularized expression's; `of` must name a scalar unknown the sub-problem
solves for.

Checked on load: sub-problem names are unique; every model equation is
solved by exactly one sub-problem; every name a sub-problem uses (an
Expand All @@ -783,8 +807,21 @@ A sub-problem may give an `equation` of its own, `for` the unknown it
determines (by default the one it introduces); a lag with `of` reads a
copy of a field under a name of its own (redistancing reads `φs`, the
level set before it changes `φ`); and a sub-problem's own `regularize`
replaces the entry's (redistancing smooths its sign over `0.75 h`, the
rest their Heaviside over `1.5 h`).
replaces the entry's for the functions it names (redistancing smooths its
sign over `0.75 h`, the rest their Heaviside over `1.5 h`).

**A closed spec writes the split out.** What the rules above leave implicit
is written into each sub-problem when a spec is closed (`ymf.explicit`,
for the reason the boundary-condition aliases are expanded: two readers
must not close one spec differently): its `for` (the unknown it
introduces, when it gives an equation and no `for`); its `solver`, the
entry's refined by its own; its `regularize`, the entry's with its own
replacing function by function; and every lag as `{of, via, at,
carried_by}`, where `carried_by` names the introduced unknown a lagged
coefficient is read as (`χ: {via: vof, at: previous_step, carried_by:
χh}`). A `carried_by` that is not the carrier is refused on load. Written
out or not, the split classifies the same (`split_problem` gives equal
results for both) and keys its outputs alike.

**Each sub-problem is classified on its own.** `adr_problem` reads a model
as one system, which this one is not (`v·∇φ` is a Hamiltonian in another
Expand All @@ -798,7 +835,9 @@ says so), which the plain data names (`v_0`) and whose gradient it names
substituted, and the sharp functions are smoothed as Proteus smooths them:
`heaviside` becomes `gf.H` of `equivalent_polynomials.h`, whose derivative
is its `gf.D`, and `sign` becomes `2 H_ε − 1`. Each sub-problem also
carries what it reads and when, its named coefficients, the
carries what it reads and when, its coefficients of the state by name
(with `inline` code and its ADR form over them in `named`, as for
`adr_problem`), the
regularizations it used, an introduced unknown's initial condition, its
effective solver, and its **boundary conditions**: those on the unknowns it
solves for, and those on a coefficient its introduced unknown carries
Expand Down Expand Up @@ -1158,8 +1197,9 @@ nobody has to rediscover them. Gaps that have been closed, and how, are in
[changes.md](changes.md).

- **A solver may not read every domain kind yet.** ymf poses all four;
Proteus reads `discrete` domains once cekees/proteus#186 lands, and takes
the bounds of a bounds-free file only from tetgen, 2dm and 3dm files.
Proteus reads `discrete` domains of triangles or tetrahedra (in memory,
cekees/proteus#200), and takes the bounds of a bounds-free file only from
tetgen, 2dm and 3dm files.
- **Periodic conditions only along the axes.** A periodic region names
two faces of the domain's bounding box, on a box or on a region whose two
faces match; a rotation, or a shift that is not along an axis, is not
Expand Down
32 changes: 28 additions & 4 deletions src/ymf/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -667,25 +667,38 @@ def facets(
return {"topology": topo, "tags": {"data_item": tags}, "view": view}


#: The method name of :func:`ymf.mesh.fingerprint`'s digest, recorded beside
#: it so that a later definition can be told apart from this one.
FINGERPRINT_METHOD = "ymf-mesh-1"


def provenance(
tool: str,
*,
version: Optional[str] = None,
files: Iterable[Dict[str, str]] = (),
extra: Optional[Dict[str, Any]] = None,
fingerprint: Optional[str] = None,
) -> Dict[str, Any]:
"""Describe where a grid came from: the tool that made it, its version,
and the files it was made from with their SHA-256 checksums.
the files it was made from with their SHA-256 checksums, and the mesh's
own fingerprint.

``files`` is a list of ``{"name": ..., "sha256": ...}``;
:func:`ymf.mesh.file_provenance` computes the checksums from paths. The
tool is free text -- the archive names no tools -- and ``extra`` is a
mapping the writer may fill with anything else it wants kept.
``fingerprint`` is :func:`ymf.mesh.fingerprint` of the mesh in its input
order (a SHA-256 hex digest), recorded as ``{method, sha256}``; a reader
recomputes it to check it holds the mesh it expects before reading data
on it.
"""
block: Dict[str, Any] = {"tool": str(tool)}
if version is not None:
block["version"] = str(version)
block["files"] = [dict(f) for f in files]
if fingerprint is not None:
block["fingerprint"] = {"method": FINGERPRINT_METHOD, "sha256": str(fingerprint).lower()}
if extra:
block["extra"] = dict(extra)
return block
Expand Down Expand Up @@ -1075,11 +1088,11 @@ def _canonicalize_facets(f: Dict[str, Any], where: str) -> Dict[str, Any]:
def _canonicalize_provenance(p: Dict[str, Any], where: str) -> Dict[str, Any]:
if not isinstance(p, dict) or "tool" not in p:
raise YmfArchiveError(f"{where}: provenance has no 'tool'")
unknown = sorted(set(p) - {"tool", "version", "files", "extra"})
unknown = sorted(set(p) - {"tool", "version", "files", "fingerprint", "extra"})
if unknown:
raise YmfArchiveError(
f"{where}: provenance takes tool, version, files and extra, not "
f"{', '.join(unknown)}; put anything else under extra")
f"{where}: provenance takes tool, version, files, fingerprint and extra, "
f"not {', '.join(unknown)}; put anything else under extra")
canonical: Dict[str, Any] = {"tool": str(p["tool"])}
if p.get("version") is not None:
canonical["version"] = str(p["version"])
Expand All @@ -1090,6 +1103,12 @@ def _canonicalize_provenance(p: Dict[str, Any], where: str) -> Dict[str, Any]:
f"{where}.files[{i}]: a file is {{name, sha256}}, got {entry!r}")
files.append({"name": str(entry["name"]), "sha256": str(entry["sha256"]).lower()})
canonical["files"] = files
if p.get("fingerprint") is not None:
f = p["fingerprint"]
if not isinstance(f, dict) or set(f) != {"method", "sha256"}:
raise YmfArchiveError(
f"{where}.fingerprint: a fingerprint is {{method, sha256}}, got {f!r}")
canonical["fingerprint"] = {"method": str(f["method"]), "sha256": str(f["sha256"]).lower()}
if p.get("extra"):
if not isinstance(p["extra"], dict):
raise YmfArchiveError(f"{where}.extra: must be a mapping")
Expand Down Expand Up @@ -1355,6 +1374,11 @@ def _validate_grid_like(g: Dict[str, Any], where: str) -> None:
raise YmfArchiveError(
f"{where}.provenance.files[{i}].sha256: {entry['sha256']!r} is "
"not a SHA-256 digest (64 hexadecimal digits)")
f = g["provenance"].get("fingerprint")
if f is not None and not _SHA256.match(f["sha256"]):
raise YmfArchiveError(
f"{where}.provenance.fingerprint.sha256: {f['sha256']!r} is not a "
"SHA-256 digest (64 hexadecimal digits)")


def _validate_facets(g: Dict[str, Any], where: str) -> None:
Expand Down
28 changes: 16 additions & 12 deletions src/ymf/closure.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,15 @@ def input_slice(doc: Dict[str, Any], discretization: str,
realization: Dict[str, Any]) -> Dict[str, Any]:
"""The part of the input one output depends on (see the module docstring).

Taken from the document with its boundary-condition aliases and condition
sets expanded (ymf.conditions): what is solved is the expansion, so a
spec and the archive it closes into, which holds the expansion, key
their outputs alike; a spec with no shorthand is unchanged by it.
Taken from the document as a closed spec records it (ymf.explicit: the
boundary-condition aliases and condition sets expanded, the sub-problems
written out), so a spec and the archive it closes into key their outputs
alike; a spec with no shorthand is unchanged by it. What closure derives
from the input alone (a coefficient's ``depends_on``) is left out: the
formula it is read from is in the slice already.
"""
from ymf.conditions import expand
doc = expand(doc)
from ymf.explicit import explicit, without_derived
doc = without_derived(explicit(doc))
paths = doc.get("solution_paths") or {}
entry = _discretization(doc, discretization)
return plain({
Expand Down Expand Up @@ -320,13 +322,15 @@ def archive_stem(path: str | Path) -> str:
def write(path: str | Path, spec: Dict[str, Any], outputs: Dict[str, Any]) -> None:
"""Write an archive: ``ymf:``, its ``ymf_state``, the realized spec, then
``outputs``. The state is the one the archive reaches (see ymf.state),
whatever its input declared. The realized spec has its boundary-condition
aliases and condition sets expanded (ymf.conditions)."""
from ymf.conditions import expand
whatever its input declared. The realized spec is written out
(ymf.explicit): boundary-condition aliases and condition sets expanded,
the coefficients of the state named with what they follow, every
sub-problem with its solver, regularization and lags in full."""
from ymf.explicit import explicit
from ymf.state import reached
# the closure records what was solved: the conditions expanded, each
# with the alias and the set it came from (ymf.conditions)
document = dict(plain(expand(spec)))
# the closure records what was solved, with nothing left to a reader's
# defaults (ymf.explicit)
document = dict(plain(explicit(spec)))
document.pop("outputs", None)
document.pop("ymf_state", None)
document.pop("generated", None) # it describes the input file, not this one
Expand Down
3 changes: 2 additions & 1 deletion src/ymf/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
import yaml

from ymf.archive import is_archive_text
from ymf.domain import validate_domain_block
from ymf.domain import type_discrete_block, validate_domain_block
from ymf.references import apply_defaults, resolve
from ymf.schema import _ALLOW_FLOW_STYLE, PARTIAL_YMF_SCHEMA, YMF_SCHEMA
from ymf.state import check as check_state_of
Expand Down Expand Up @@ -476,6 +476,7 @@ def _check_document(doc: Dict[str, Any]) -> None:
strong = (doc.get("model") or {}).get("strong_form") or {}
if "domain" in strong:
validate_domain_block(strong["domain"], "model.strong_form.domain")
type_discrete_block(strong["domain"], "model.strong_form.domain")


def load_composed(path: str | Path, check_state: bool = True) -> Dict[str, Any]:
Expand Down
Loading
Loading