Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/developer/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ subsystems/containers
subsystems/checkpointing-system
subsystems/model-orchestration
subsystems/describe-and-view
subsystems/transcript-query
subsystems/jit-cache
```

Expand Down
59 changes: 59 additions & 0 deletions docs/developer/subsystems/transcript-query.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/underworld3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions src/underworld3/cython/petsc_generic_snes_solvers.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/underworld3/discretisation/discretisation_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 29 additions & 5 deletions src/underworld3/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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-
Expand Down
Loading
Loading