A run is a Python file
A study in mbem is a Python module that declares one Run. It is Python rather
than TOML or YAML because geometry is code — but the module names a builder
rather than describing patches, so it cannot restate the fault sign convention
or the eps rule. Those are stated once, in the solver, and a config that could
contradict them would eventually contradict them.
# configs/fault_only.py
"""Fault-only mollified BEM: surface displacement and elastic surface stress."""
from mbem.config import Backend, Geometry, Model, Output, Run
GEOM = dict(half_x=200.0, z_bottom=-100.0, fault_half_len=50.0,
fault_depth=25.0, near_field_radius=120.0,
edge_fault=6.0, edge_near=15.0, edge_far=35.0, edge_side=35.0)
def run_spec(scale: float = 1.0, backend: str = "dense",
slip_mag: float = 0.01) -> Run:
return Run(
name="fault_only",
model=Model(geometry=Geometry("fault_box", scale=scale, params=GEOM),
builder="fault_box",
params=dict(slip_mag=slip_mag, mu=30.0, lam=30.0),
eps="auto"),
backend=Backend(kind=backend),
outputs=Output(save_meshes=True, figures=("fault_only",)),
notes="surface displacement + elastic surface stress, fault only",
tags=("figure", "fault_box"))
Then
python -m mbem run configs/fault_only.py
A module declares either a module-level RUN or a function
run_spec(**kwargs) -> Run, never both. The function form is what makes
--set and --sweep work: its signature is the command-line surface, so
python -m mbem run configs/fault_only.py --set backend=hmat --set scale=1.6
python -m mbem run configs/fault_only.py --sweep slip_mag=0.005,0.01,0.02
need no argument parsing of your own, and a misspelled name is rejected against the signature rather than silently ignored.
The pieces
Geometry(builder, params, scale) names a mesh builder — fault_box or
topo_inclusion — and passes it keywords. scale divides every target edge
length, so scale=2 is a uniformly finer mesh. Geometry changes the operator,
so two geometries are two runs, never two states.
Model(geometry, builder, params, eps) turns meshes into a region graph:
regions, their materials, which patches are free surfaces, which are interfaces,
where the fault is. eps is the mollification width and accepts a scalar, an
(N_src,) array, a per-patch dict, or "auto".
eps is per source element. “auto” is 0.1 h on a boundary
patch, and on a fault one single value, 0.07 min h, because per-element
widths would smear a uniform slip unequally. What governs the error is a point’s
clearance from element edges in units of eps, not h — so budget eps/h before
expecting refinement to pay.
Backend(kind=...) is dense, hmat or fmm. Everything else on it
defaults to None, meaning absent from the call, so the solver’s own default
stands. That is the only override mechanism: a config passes a keyword, it never
rebinds a default. The backends bind their defaults at definition time, so a
rebind would be a silent no-op — the kind of thing that looks like it worked.
Solve(rtol=..., ...) is the iterative solver, ignored by dense.
states=(State("het"), State("hom", {"inclusion": HOST})) is the one place
where several solves share one assembly. A state changes only materials, and
the operator is rebuilt for them rather than reassembled. Anything that changes
the mesh is a different run, not a state.
Output(slots, save_fields, save_meshes, figures) chooses what is written.
slots=() means every slot, which is what mbem sample needs — an interior
point is evaluated from the density on all of them, not just the ones a figure
draws.
What a run leaves behind
Each run writes a new directory under runs/, named for the UTC time, the run,
the git hash, and four random hex — the mkdir is the collision check.
runs/20261002T153045Z-fault_only-efc201e-a3d5/
resolved.json the spec, the keywords ACTUALLY passed, eps resolved to
numbers per patch, all 111 defaults, the environment
report.json iterations, residuals, phase timings, peak RSS
fields_base.npz the solution, one array per slot
meshes.npz vertices and triangles per patch
config.py a copy, so the run survives editing the original
STATUS one word: RUNNING | OK | FAIL
MANIFEST sha256 and size per file, written LAST
Two of those are deliberate. STATUS is a separate one-word file so an
interrupted run is identifiable without parsing JSON — a crashed run reads
RUNNING forever. And MANIFEST is written last, so its presence is itself the
statement that the run completed.
resolved.json records the effective call, not the request: eps="auto"
appears as numbers per patch, and every backend keyword the solver received is
listed. It is JSON rather than TOML because a field left unset is written
null, and “unset, so the default applied” is a distinction TOML cannot
express — it could only omit the key.
Sweeps
Repeat --sweep for a cartesian product. Each point is a child directory under
one study, and figures that span the sweep are drawn across them:
python -m mbem run configs/topo_inclusion.py --sweep surface=topo,flat
python -m mbem run configs/onfault_stress.py --sweep eps=4,2,1 --sweep order_top=0,1
A sweep is the right tool whenever the quantity you want is a difference between operators — a different mesh, eps or backend is a different operator, so it cannot be a state.
Reading it back
python -m mbem list # runs, newest first
python -m mbem show <run-dir> --section effective
python -m mbem run configs/fault_box.py --dry-run # validate, build nothing
python -m mbem sample <run-dir> --spacing 8 # a 3-D grid -> .vti
python -m mbem verify --fast # the gates
Checked, not trusted
tests/gates/mbem/verify_config.py proves the config layer cannot drift from
the code it drives: that a config builds the model the gates build entrywise,
fault Burgers vector bitwise included; that a run mutates no default; that
resolved.json round-trips with null surviving; that the dump covers every
backend keyword by introspection; and that gate-only acceptance criteria are not
reachable from a config at all — a run that could move its own pass threshold
could declare its own success.