From b05d5c509e3d75f125713c8d962db7d82e7e8e63 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Fri, 25 Sep 2026 18:06:39 -0700 Subject: [PATCH] uw.Transcript: one query object over a run's record, and the decisions it lacked The transcript said what a run did; asking it anything meant walking the JSON by hand, and the digest, a notebook and a test each did that their own way. uw.Transcript reads a file, a live model or read_transcript's list and answers the questions a debugging session asks: the steps that were abandoned and by what, the rewinds and why, the solves that failed or ran capped, the run collapsed to its distinct step patterns, what a part was solving at a given step, and what changed between two steps. Every answer is the record's own data, with the outcome of a solve read by the rule the figure and the table use, and describe() gives the run in the same shape as every other object. The renderers take the query object as a source. The record gains what the questions needed. A step stopped by an exception carries abandoned_by, the exception's class and message. A rewind takes a reason and detail from the caller, since the acceptance test lives in the caller's loop and the transcript cannot infer it. A part record carries the solver's run-time constants, the clock excluded, and is written again when they change: before this a parameter changed between solves left the record quoting the old value, since nothing was rebuilt. Two things found on the way. After a rewind the run numbers its steps again from where it went back to, so an index can name several attempts; the query object keys its patterns by position and exposes the attempts. And the mesh view tested uw.is_notebook as a flag when it is a function, so every serial run tried to plot. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- docs/developer/index.md | 1 + docs/developer/subsystems/transcript-query.md | 59 ++++ src/underworld3/__init__.py | 1 + .../cython/petsc_generic_snes_solvers.pyx | 18 +- .../discretisation/discretisation_mesh.py | 2 +- src/underworld3/model.py | 34 +- src/underworld3/utilities/transcript_query.py | 312 ++++++++++++++++++ .../utilities/transcript_report.py | 5 +- tests/test_0018_transcript_query.py | 132 ++++++++ 9 files changed, 555 insertions(+), 9 deletions(-) create mode 100644 docs/developer/subsystems/transcript-query.md create mode 100644 src/underworld3/utilities/transcript_query.py create mode 100644 tests/test_0018_transcript_query.py diff --git a/docs/developer/index.md b/docs/developer/index.md index 3449410c..2462b1f1 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -209,6 +209,7 @@ subsystems/containers subsystems/checkpointing-system subsystems/model-orchestration subsystems/describe-and-view +subsystems/transcript-query subsystems/jit-cache ``` diff --git a/docs/developer/subsystems/transcript-query.md b/docs/developer/subsystems/transcript-query.md new file mode 100644 index 00000000..a250257b --- /dev/null +++ b/docs/developer/subsystems/transcript-query.md @@ -0,0 +1,59 @@ +# Querying a run's transcript + +A run writes a transcript: a header with the scales, one line per step with +the operators it applied and how each went, the description of each part +when it first acts, and notes for what was not a step, a rewind above all. +`uw.read_transcript` reads it back as data. `uw.Transcript` puts one query +object over that data, so a notebook, a test, the digest and a tool ask the +same questions of the same interpretation. + +```python +t = uw.Transcript("transcripts/latest/transcript.jsonl") # a path, a live model, or read_transcript's list +t.view() # the summary, rendered for the session +t.view(format="yaml") # the same as data +``` + +## The questions + +| call | answers | +|---|---| +| `t.abandoned()` | steps that did not commit, each with `abandoned_by`: the exception's class and message | +| `t.backtracks()` | rewinds and restores, with where they happened, the step they went back to, and the `reason` and `detail` the caller gave | +| `t.failed()`, `t.capped()` | solves that diverged; solves the SNES called converged while an inner block hit its cap or its deadline | +| `t.solves(part=...)`, `t.events(kind=..., outcome=..., step=...)` | events as `(step_index, event)`, filtered | +| `t.patterns()` | the run collapsed to its distinct step patterns: same operators, outcomes, label and completion, with nothing recorded between | +| `t.step(i)`, `t.sequence(i)` | one step as recorded; its operators in order | +| `t.compare(a, b)` | operators in one step and not the other, outcomes that changed, and the interval, wall time and completion of each | +| `t.part(name, at_step=i)` | what a part was solving at step `i`: the description recorded at or before it | +| `t.changes()` | parts whose form changed during the run, and when | +| `t.between(t0, t1)` | steps starting in an interval of the run's own time | +| `t.adjoint_segments()` | the run partitioned by adjoint support | + +Every answer is plain data, the dicts the record holds. The outcome of a +solve is read by the same rule the figure and the table use, so a solve the +digest marks amber is the one `capped()` returns. + +## Recording decisions + +The transcript cannot infer why a run went back, since the acceptance test +lives in the caller's loop. Say so when rewinding: + +```python +if displacement > limit: + model.rewind(1, reason="free surface displacement over the limit", + observed=displacement, threshold=limit, action="halve dt") + dt = dt / 2 +``` + +The note then carries `reason` and `detail`, and `t.backtracks()` returns +them. A step abandoned by an exception records the exception's class and +message as `abandoned_by` without anything from the caller. + +## The same tree as everything else + +`t.describe()` is a record in the shape every object uses (see +[Descriptions and views](describe-and-view.md)): facts for the header and +the counts, the step patterns, and the parts as children. `uw.render(...)` +turns it into Markdown, text, LaTeX, YAML or JSON, and the renderers +`uw.transcript_table`, `uw.transcript_figure` and `uw.transcript_key` take +the query object as their source. diff --git a/src/underworld3/__init__.py b/src/underworld3/__init__.py index 3d238559..68969932 100644 --- a/src/underworld3/__init__.py +++ b/src/underworld3/__init__.py @@ -223,6 +223,7 @@ def view(): create_thermal_convection_model, ) from .utilities.describe import render +from .utilities.transcript_query import Transcript from .utilities.transcript_report import ( transcript_diagram, transcript_flowchart, diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index c5813306..10841d93 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -2571,8 +2571,22 @@ class SolverBaseClass(uw_object): model = uw.get_default_model() # What it solves, not only that it solved: the residual is # SymPy, so the weak form can be written into the transcript - # exactly as implemented. - model._describe_part(self, part, label) + # exactly as implemented. The run-time constants go with it: + # a parameter changed between solves does not rebuild the + # kernel, so its new value is what tells the record the + # equation is not the one it holds. The clock is left out, + # or a time-dependent run would re-record every step. + constants = None + try: + from underworld3.utilities._jitextension import _pack_constants + clock = getattr(self.mesh, "_t", None) + packed = _pack_constants(self.constants_manifest) + constants = {str(getattr(expr, "name", index)): float(packed[index]) + for index, expr in self.constants_manifest + if expr is not clock} + except Exception: + constants = None + model._describe_part(self, part, label, constants=constants) model._record_step_event("solve", label, part=part) except Exception: pass diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index 2c91a999..e9a58eb6 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -1823,7 +1823,7 @@ def view(self, level=0, format=None): from underworld3.utilities.describe import view as _view if uw.mpi.rank == 0: _view(self, format=format) - if uw.is_notebook and uw.mpi.size == 1: + if uw.is_notebook() and uw.mpi.size == 1: uw.visualisation.plot_mesh(self, window_size=(600, 400)) elif level == 1: if uw.mpi.rank == 0: diff --git a/src/underworld3/model.py b/src/underworld3/model.py index c3e51d05..0bc8adb9 100644 --- a/src/underworld3/model.py +++ b/src/underworld3/model.py @@ -164,7 +164,7 @@ class ModelStep: """ __slots__ = ("index", "t0", "dt", "label", "events", "completed", "snapshot", - "wall") + "wall", "abandoned_by") def __init__(self, index, t0, dt, label=None): self.index = index @@ -177,6 +177,10 @@ def __init__(self, index, t0, dt, label=None): # want when watching a run: a step that suddenly takes ten times as # long is the first sign of a solver in trouble. self.wall = None + # What stopped a step that did not commit: the exception's class and + # message, so the record says why a step was abandoned and not only + # that it was. + self.abandoned_by = None # The state this step STARTED from, when the recording policy kept one. # Taken before the operators ran, which is the only correct point: a # DDt shifts its history in its post-solve hook, so a snapshot taken @@ -223,6 +227,7 @@ def as_dict(self): "restorable": bool(self.restorable), "wall": None if self.wall is None else float(self.wall), "events": [dict(e) for e in self.events], + **({"abandoned_by": dict(self.abandoned_by)} if self.abandoned_by else {}), } def __repr__(self): @@ -1512,9 +1517,16 @@ def _trim_records(self): for entry in restorable[: max(0, len(restorable) - limit)]: entry.snapshot = None - def rewind(self, steps: int = 1): + def rewind(self, steps: int = 1, reason=None, **detail): """Go back to the state at the start of a completed step. + ``reason`` says why, in a word or a sentence — ``"timestep rejected"``, + ``"free surface displacement over the limit"`` — and ``detail`` carries + the numbers behind it (``observed=0.18, threshold=0.10, + action="halve dt"``). The transcript cannot infer either, since the + acceptance test lives in the caller's loop; recorded here, a reader + of the run sees the decision and not only the backtrack. + ``steps=1`` returns to the beginning of the most recent completed step, undoing it. Fields, histories and the clock all come back together, because the clock lives on the tracker and the tracker is captured with @@ -1553,10 +1565,13 @@ def rewind(self, steps: int = 1): to_step=int(target.index), steps_undone=int(dropped), t=_jsonable_quantity(self.tracker.time), + **({"reason": str(reason)} if reason is not None else {}), + **({"detail": {str(k): _jsonable_quantity(v) if hasattr(v, "magnitude") else v + for k, v in detail.items()}} if detail else {}), ) return target - def _describe_part(self, owner, part: str, label: str) -> None: + def _describe_part(self, owner, part: str, label: str, constants=None) -> None: """Record what a part SOLVES, not just that it ran. Underworld3's residuals are SymPy, so the weak form a solver assembles @@ -1576,7 +1591,11 @@ def _describe_part(self, owner, part: str, label: str) -> None: self._part_objects[part] = owner known = self._parts.get(part) rebuilding = not getattr(owner, "is_setup", True) - if known is not None and not rebuilding: + # a parameter's value is part of the equation as solved: a change + # re-reads the form even though nothing was rebuilt + changed = (known is not None and constants is not None + and known.get("constants") != constants) + if known is not None and not rebuilding and not changed: return described = None @@ -1598,6 +1617,8 @@ def _describe_part(self, owner, part: str, label: str) -> None: described["forms"][f].get("text", "") for f in sorted(described["forms"]) ) + if constants: + fingerprint += json.dumps(constants, sort_keys=True) if known is not None and known.get("fingerprint") == fingerprint: return @@ -1608,6 +1629,8 @@ def _describe_part(self, owner, part: str, label: str) -> None: "at_step": self._open_step.index, "fingerprint": fingerprint, } + if constants is not None: + record["constants"] = constants # the description's own kind and its contained objects stay out of # the record: a part record IS a kind, and the children are recorded # as parts of their own when they act @@ -1829,12 +1852,13 @@ def _restore(): _warnings.showwarning = _record_and_show try: yield record - except BaseException: + except BaseException as exc: _restore() record.wall = _time.monotonic() - wall0 # Abandon: put the clock back and do not commit. self.tracker.time = t0 record.completed = False + record.abandoned_by = {"type": type(exc).__name__, "message": str(exc)[:300]} self._open_step = None # The abandoned record never joins the transcript, so the state it # captured is unreachable — drop it rather than hold a field- diff --git a/src/underworld3/utilities/transcript_query.py b/src/underworld3/utilities/transcript_query.py new file mode 100644 index 00000000..86456d78 --- /dev/null +++ b/src/underworld3/utilities/transcript_query.py @@ -0,0 +1,312 @@ +r"""A run's transcript, queryable. + +The transcript on disk is a record of what a run did: a header with the +scales, one line per step with the operators it applied and how each went, +the description of each part when it first acted, and notes for the things +that were not steps, a rewind above all. :func:`underworld3.read_transcript` +reads it back as data; this module puts one query object over that data so +a notebook, a test, the digest and a tool all ask the same questions of the +same interpretation. + + t = uw.Transcript("transcripts/latest/transcript.jsonl") + t.view() # the summary, in the form the session wants + t.failed(), t.capped() # solves that diverged, solves with a block at its cap + t.abandoned(), t.backtracks() # rejected steps, and the rewinds with their reasons + t.patterns() # the run collapsed to its distinct step patterns + t.part("SNES_Stokes#3", at_step=40) # the equation that was being solved at step 40 + t.compare(12, 13) # what changed between two steps + +Every method returns plain data, the same dicts the record holds, with the +outcome of a solve read by the same rule the figure and the table use. +""" + +from .describe import record, view as _view + + +def _operator_text(event): + """A short name for what an event did: the solver's own name for a + solve, ``history shift T`` for a shift, ``advect swarm`` for a push.""" + kind, name = event.get("kind"), event.get("name") + if kind == "solve": + return str(name) + if kind == "adjoint_solve": + return f"adjoint {name}" + if kind == "history_shift": + return f"history shift {name}" + if kind == "swarm_advect": + return f"advect {name}" + return f"{kind} {name}" + + +class Transcript: + """One run, from a transcript file, the list :func:`read_transcript` + returns, or a live model. ``run`` picks a run when the file holds + several, the last by default.""" + + def __init__(self, source, run=-1): + from .transcript_report import _as_runs + if isinstance(source, Transcript): + runs = source.runs + else: + runs = _as_runs(source) + if not runs: + raise ValueError("this transcript holds no run") + self.runs = runs + self.entry = runs[run] + self.header = self.entry.get("run") or {} + self.steps = list(self.entry.get("steps") or []) + self.notes = list(self.entry.get("notes") or []) + self.parts = list(self.entry.get("parts") or []) + self.ended = self.entry.get("ended") + self.live = bool(self.entry.get("live")) + + # --- the shape of the run ------------------------------------------- + + def __len__(self): + return len(self.steps) + + def __iter__(self): + return iter(self.steps) + + @property + def name(self): + from .transcript_report import _run_title + return _run_title(self.header, fallback="") + + def step(self, index, attempt=-1): + """The step with this index, as recorded. After a rewind the run + numbers its steps again from where it went back to, so an index can + name several attempts: ``attempt`` picks one, the last by default, + which is the one that stands. :meth:`attempts` lists them all.""" + found = self.attempts(index) + if not found: + raise KeyError(f"no step {index} in this run") + return found[attempt] + + def attempts(self, index): + """Every recorded step with this index, in the order they happened: + the rejected ones first, the one that stands last.""" + return [s for s in self.steps if int(s.get("index", -1)) == int(index)] + + def position(self, step): + """Where a step record sits in the run's sequence.""" + for i, s in enumerate(self.steps): + if s is step: + return i + raise ValueError("this step is not in the run") + + def sequence(self, step): + """The operators a step applied, in order, as short names.""" + step = self.step(step) if not isinstance(step, dict) else step + return [_operator_text(e) for e in step.get("events", []) if e.get("kind") != "warning"] + + def outcome(self, event): + """``"ok"``, ``"capped"``, ``"diverged"`` or ``None`` for an event: the + rule the figure and the table apply.""" + from .transcript_report import _outcome + return _outcome(event) + + # --- events --------------------------------------------------------- + + def events(self, kind=None, part=None, outcome=None, step=None): + """Events across the run, each as ``(step_index, event)``, filtered + by kind (``"solve"``, ``"adjoint_solve"``, ``"history_shift"``, + ``"swarm_advect"``, ``"warning"``), by part, by outcome, or to one + step.""" + out = [] + for s in self.steps: + if step is not None and int(s.get("index", -1)) != int(step): + continue + for e in s.get("events", []): + if kind is not None and e.get("kind") != kind: + continue + if part is not None and e.get("part") != part and e.get("name") != part: + continue + if outcome is not None and self.outcome(e) != outcome: + continue + out.append((int(s.get("index", -1)), e)) + return out + + def solves(self, outcome=None, part=None): + return self.events(kind="solve", part=part, outcome=outcome) + + def failed(self): + """Solves that did not converge.""" + return self.solves(outcome="diverged") + + def capped(self): + """Solves the SNES called converged while an inner block hit its + iteration cap or its deadline: the amber mark.""" + return self.solves(outcome="capped") + + def warnings(self): + return self.events(kind="warning") + + def abandoned(self): + """Steps that did not commit: an exception inside the block, or a + step the caller rejected. Each carries ``abandoned_by`` when the + run recorded what stopped it.""" + return [s for s in self.steps if not s.get("completed")] + + def backtracks(self): + """The rewinds and restores, each with where in the sequence it + happened (``after_position``), the step it went back to + (``to_step``), and the ``reason`` and ``detail`` the caller gave.""" + return [n for n in self.notes if n.get("kind") in ("rewind", "restore")] + + # --- parts ----------------------------------------------------------- + + def part_names(self): + seen = [] + for p in self.parts: + if p.get("part") not in seen: + seen.append(p.get("part")) + return seen + + def part(self, name, at_step=None): + """The description of a part as recorded: what it solved. With + ``at_step``, the description in force at that step, which is the + latest recorded at or before it — a part records itself again when + its form changes.""" + records = [p for p in self.parts if p.get("part") == name or p.get("label") == name] + if not records: + raise KeyError(f"no part {name!r} recorded in this run") + if at_step is None: + return records[-1] + before = [p for p in records if int(p.get("at_step", -1)) <= int(at_step)] + return before[-1] if before else records[0] + + def changes(self): + """Parts whose form changed during the run: ``(part, at_step)`` for + each re-recording after the first.""" + seen, out = {}, [] + for p in self.parts: + key = p.get("part") + if key in seen and seen[key] != p.get("fingerprint"): + out.append((key, p.get("at_step"))) + seen[key] = p.get("fingerprint") + return out + + # --- structure ------------------------------------------------------- + + def patterns(self): + """The run collapsed to its distinct step patterns: consecutive + steps that applied the same operators in the same order with the + same outcomes, the same label and the same completion are one + pattern. Each is ``{"from", "to", "count", "sequence", "outcomes", + "label", "completed"}``; a run that never changes has one.""" + out = [] + for position, s in enumerate(self.steps): + signature = (tuple(self.sequence(s)), + tuple(self.outcome(e) for e in s.get("events", []) if e.get("kind") != "warning"), + s.get("label"), bool(s.get("completed"))) + index = int(s.get("index", -1)) + if (out and out[-1]["_signature"] == signature + and not self._note_between(out[-1]["positions"][1], position)): + out[-1]["to"] = index + out[-1]["positions"] = (out[-1]["positions"][0], position) + out[-1]["count"] += 1 + continue + out.append({"from": index, "to": index, "positions": (position, position), "count": 1, + "sequence": list(signature[0]), "outcomes": list(signature[1]), + "label": signature[2], "completed": signature[3], "_signature": signature}) + for p in out: + p.pop("_signature") + return out + + def _note_between(self, position_a, position_b): + """Whether a note (a rewind, a restore) sits between two positions.""" + return any(position_a <= int(n.get("after_position", -1)) < position_b for n in self.notes) + + def compare(self, a, b): + """What differs between two steps, given by index (the attempt that + stands) or as records from :meth:`step`: operators in one and not + the other, outcomes that changed for the same operator, and the + interval, wall time and completion of each.""" + sa = a if isinstance(a, dict) else self.step(a) + sb = b if isinstance(b, dict) else self.step(b) + seq_a, seq_b = self.sequence(sa), self.sequence(sb) + oa = {self._event_key(e): self.outcome(e) for e in sa.get("events", [])} + ob = {self._event_key(e): self.outcome(e) for e in sb.get("events", [])} + return { + "only_in_a": [x for x in seq_a if x not in seq_b], + "only_in_b": [x for x in seq_b if x not in seq_a], + "order_differs": seq_a != seq_b and sorted(seq_a) == sorted(seq_b), + "outcome_changes": {k: (oa[k], ob[k]) for k in oa if k in ob and oa[k] != ob[k]}, + "dt": (sa.get("dt"), sb.get("dt")), + "wall": (sa.get("wall"), sb.get("wall")), + "completed": (bool(sa.get("completed")), bool(sb.get("completed"))), + } + + @staticmethod + def _event_key(event): + return (event.get("kind"), event.get("part") or event.get("name")) + + def between(self, t0, t1): + """Steps whose interval starts in ``[t0, t1]``, in the run's own + time unit.""" + from .transcript_report import _magnitude + return [s for s in self.steps if t0 <= _magnitude(s.get("t0")) <= t1] + + def adjoint_segments(self): + """The run partitioned by adjoint support: see + :func:`underworld3.transcript_adjoint_segments`.""" + from .transcript_report import transcript_adjoint_segments + return transcript_adjoint_segments(self.runs, run=self.runs.index(self.entry)) + + # --- description --------------------------------------------------- + + def describe(self, depth=1): + """The run as data, in the shape every other object uses: facts for + the header and the counts, and the parts as children.""" + facts = {} + if self.header.get("started"): + facts["started"] = self.header["started"] + if self.ended: + facts["ended"] = self.ended.get("ended") or self.ended.get("at") or "yes" + elif self.live: + facts["state"] = "in progress" + else: + facts["state"] = "no terminator: still running, or interrupted" + scales = self.header.get("reference") or self.header.get("scales") or {} + if scales: + facts["scales"] = " | ".join(f"{k} {v['magnitude']:.4g} {v['units']}" + for k, v in scales.items() if isinstance(v, dict)) + facts["steps"] = len(self.steps) + abandoned = self.abandoned() + if abandoned: + facts["abandoned"] = [int(s.get("index", -1)) for s in abandoned] + backtracks = self.backtracks() + if backtracks: + facts["backtracks"] = len(backtracks) + failed, capped = self.failed(), self.capped() + if failed: + facts["failed solves"] = [f"{i}: {e.get('name')}" for i, e in failed] + if capped: + facts["capped solves"] = len(capped) + patterns = self.patterns() + facts["patterns"] = [f"{p['from']}-{p['to']}: " + " > ".join(p["sequence"]) if p["count"] > 1 + else f"{p['from']}: " + " > ".join(p["sequence"]) for p in patterns[:12]] + if len(patterns) > 12: + facts["patterns"].append(f"... {len(patterns) - 12} more") + children = [] + if depth > 0: + for p in self.parts: + child = dict(p) + child.setdefault("kind", "part") + child.setdefault("name", p.get("label") or p.get("part")) + child.setdefault("summary", f"recorded at step {p.get('at_step')}") + children.append(child) + summary = f"{len(self.steps)} step(s)" + if abandoned: + summary += f", {len(abandoned)} abandoned" + if backtracks: + summary += f", {len(backtracks)} backtrack(s)" + if failed: + summary += f", {len(failed)} failed solve(s)" + return record("transcript", self.name, summary, facts=facts, children=children) + + def view(self, format=None, depth=None): + """Show the run: :meth:`describe` rendered for the session, or in + the ``format`` named.""" + _view(self, format=format, depth=depth) diff --git a/src/underworld3/utilities/transcript_report.py b/src/underworld3/utilities/transcript_report.py index 60152ed3..4612bc59 100644 --- a/src/underworld3/utilities/transcript_report.py +++ b/src/underworld3/utilities/transcript_report.py @@ -63,7 +63,10 @@ # --------------------------------------------------------------------------- def _as_runs(source): - """Accept a path, the list ``read_transcript`` returns, or a live model.""" + """Accept a path, the list ``read_transcript`` returns, a live model, or + a :class:`~underworld3.utilities.transcript_query.Transcript`.""" + if hasattr(source, "runs") and hasattr(source, "steps") and hasattr(source, "parts"): + return source.runs if isinstance(source, (str, os.PathLike)): import underworld3 as uw diff --git a/tests/test_0018_transcript_query.py b/tests/test_0018_transcript_query.py new file mode 100644 index 00000000..2f403b08 --- /dev/null +++ b/tests/test_0018_transcript_query.py @@ -0,0 +1,132 @@ +"""One query object over a run's record. + +``uw.Transcript`` reads a transcript back and answers the questions a +debugging session asks: which steps were abandoned and by what, where the +run went back and why, which solves failed or ran capped, what the run's +step patterns were, what a part was solving at a given step, and what +changed between two steps. The digest, a notebook and a tool all read the +same interpretation. The record gains two things here: the exception that +abandoned a step, and the reason and detail a caller gives a rewind. +""" +import json + +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +def _run(tmp_path): + uw.reset_default_model() + model = uw.get_default_model() + path = tmp_path / "run.jsonl" + model.transcript_file = str(path) + model.record_every = 1 + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), + cellSize=1 / 4, qdegree=2) + u = uw.discretisation.MeshVariable("u", mesh, 1, degree=1) + kappa = uw.expression(r"\kappa", 1.0, "diffusivity") + poisson = uw.systems.Poisson(mesh, u_Field=u) + poisson.constitutive_model = uw.constitutive_models.DiffusionModel + poisson.constitutive_model.Parameters.diffusivity = kappa + poisson.f = 1.0 + poisson.add_essential_bc(0.0, "Bottom") + poisson.add_essential_bc(1.0, "Top") + poisson.petsc_options.delValue("ksp_monitor") + + for _ in range(3): + with model.step(0.1, label="march"): + poisson.solve() + model._record_step_event("history_shift", "T_history") + # a step rejected by the caller's own test, with the reason recorded + with pytest.raises(RuntimeError): + with model.step(0.1, label="too far"): + poisson.solve() + raise RuntimeError("displacement 0.18 over the limit 0.10") + model.rewind(1, reason="displacement over the limit", observed=0.18, threshold=0.10, + action="halve dt") + poisson.constitutive_model.Parameters.diffusivity = 2 * kappa # the form changes: the part records itself again + with model.step(0.05, label="retry"): + poisson.solve() + model._record_step_event("history_shift", "T_history") + with model.step(0.05, label="retry"): + poisson.solve() + model._record_step_event("history_shift", "T_history") + return model, path, poisson + + +def test_the_record_carries_why(tmp_path): + model, path, poisson = _run(tmp_path) + lines = [json.loads(l) for l in path.read_text().splitlines()] + abandoned = [l for l in lines if l.get("kind") == "step" and not l.get("completed")] + assert abandoned and abandoned[0]["abandoned_by"]["type"] == "RuntimeError" + assert "over the limit" in abandoned[0]["abandoned_by"]["message"] + rewinds = [l for l in lines if l.get("kind") == "rewind"] + assert rewinds[0]["reason"] == "displacement over the limit" + assert rewinds[0]["detail"] == {"observed": 0.18, "threshold": 0.10, "action": "halve dt"} + + +def test_the_queries_answer_from_the_file(tmp_path): + model, path, poisson = _run(tmp_path) + t = uw.Transcript(str(path)) + assert len(t) == 6 # 3 marches, 1 abandoned, 2 retries + assert [s["index"] for s in t.abandoned()] == [3] + back = t.backtracks() + assert len(back) == 1 and back[0]["reason"] == "displacement over the limit" + assert back[0]["to_step"] == 2 and back[0]["after_position"] == 3 + assert t.failed() == [] and t.capped() == [] + assert len(t.solves()) == 6 + assert t.sequence(0) == ["Poisson(u)", "history shift T_history"] or len(t.sequence(0)) == 2 + patterns = t.patterns() + # marches collapse, the abandoned step stands alone, the retries collapse + assert [p["count"] for p in patterns] == [3, 1, 2], patterns + assert patterns[0]["from"] == 0 and patterns[0]["to"] == 2 and patterns[2]["label"] == "retry" + # after the rewind the run numbered its retries 2 and 3 again: the + # rejected step 3 and the retry that stands are two attempts + assert len(t.attempts(3)) == 2 and t.step(3)["completed"] and not t.step(3, attempt=0)["completed"] + diff = t.compare(t.step(2, attempt=0), t.step(3, attempt=0)) + assert diff["completed"] == (True, False) + # the rejected step stopped before its history shift + assert diff["only_in_a"] == ["history shift T_history"] and diff["only_in_b"] == [] + diff = t.compare(0, 2) # the march at 0 against the retry that stands at 2 + dt = [v["magnitude"] if isinstance(v, dict) else v for v in diff["dt"]] # a dimensional run holds {magnitude, units} + assert dt == [pytest.approx(0.1), pytest.approx(0.05)] + + +def test_a_part_is_read_at_a_step(tmp_path): + model, path, poisson = _run(tmp_path) + t = uw.Transcript(str(path)) + names = t.part_names() + assert len(names) == 1 + first = t.part(names[0], at_step=0) + last = t.part(names[0], at_step=5) + assert first["fingerprint"] != last["fingerprint"], "the flux changed before the retry" + assert [c[0] for c in t.changes()] == [names[0]] + assert "F1" in first["forms"] + + +def test_the_transcript_describes_and_renders(tmp_path, capsys): + model, path, poisson = _run(tmp_path) + t = uw.Transcript(str(path)) + d = t.describe() + assert d["kind"] == "transcript" and d["facts"]["steps"] == 6 + assert d["facts"]["abandoned"] == [3] and d["facts"]["backtracks"] == 1 + assert d["children"] and d["children"][0]["kind"] == "part" + for fmt in ("markdown", "text", "yaml", "json"): + assert uw.render(d, fmt).strip() + t.view(format="text") + assert "transcript" in capsys.readouterr().out + # the renderers take the query object as a source + assert "Poisson" in uw.transcript_key(t, format="text") + assert uw.transcript_table(t) + + +def test_a_live_model_is_a_source_too(tmp_path): + model, path, poisson = _run(tmp_path) + t = uw.Transcript(model) + assert t.live + # the file keeps the abandoned step and the one the rewind undid; the + # live list holds only what stands + assert len(t) == 6 and len(model.transcript) == 4