Source code for shinobi.steps.loops
"""Short-circuit semantics for an unrolled loop (see `Recipe.add_loop`).
A loop is declared, not interpreted: `add_loop` flattens its body into the
recipe `max_iter` times and chains the copies with real wiring, so the graph
is fully inspectable before anything runs. What stays a run-time decision is
narrow -- whether an already-declared step does any work. This module is that
decision, and nothing else.
The rule lives here rather than in the scheduler because **two tiers evaluate
it**: `_run_recipe` calls `should_skip` in-process, and the Slurm offload
compiler emits the same predicate as a shell `[ -f ... ]` test at the top of
each iteration's script. A convergence signal that is a *path* is what makes
that possible -- a bool would have no way to cross a node boundary, and the
two tiers would need separate definitions that could drift into running a
different number of cycles for the same recipe.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from shinobi.results import StepResult
from shinobi.steps.schema import StepRef
[docs]
def sentinel_exists(value: Any) -> bool:
"""Whether a convergence sentinel has actually been produced.
Args:
value: The sentinel output's value -- a path, or None if the
producing step never set it.
Returns:
True if `value` names an existing file or directory.
"""
return value is not None and Path(value).exists()
[docs]
def sentinel_value(ref: StepRef, results: dict[str, StepResult]) -> Any:
"""The sentinel value this step's skip decision reads, or None if it
cannot skip (the first iteration, or a step outside any loop).
Args:
ref: The step about to be scheduled.
results: Completed steps by name. The sentinel producer is
guaranteed to be present: `add_loop` gives every iteration an
edge to it, so the scheduler cannot reach this step first.
Returns:
The previous iteration's sentinel output value, or None.
"""
spec = ref.loop
if spec is None or spec.sentinel_step is None:
return None
producer = results.get(spec.sentinel_step)
if producer is None:
return None
return getattr(producer.outputs, spec.sentinel_field, None)
[docs]
def should_skip(ref: StepRef, results: dict[str, StepResult]) -> bool:
"""Whether `ref` should pass its predecessor's outputs through instead
of running, because an earlier iteration already converged.
Args:
ref: The step about to be scheduled.
results: Completed steps by name.
Returns:
True if the previous iteration's sentinel exists on disk.
"""
return sentinel_exists(sentinel_value(ref, results))
[docs]
def passthrough_result(ref: StepRef, prev: StepResult, inputs: Any) -> StepResult:
"""The result of a step that was skipped: the *same body step's* outputs
from one iteration earlier, handed on unchanged.
Passing the previous `outputs` object through (rather than re-deriving
field by field) is what makes convergence sticky without extra
bookkeeping: the sentinel is itself one of those outputs, so every later
iteration sees it and skips in turn.
`cache_key`/`output_keys` are carried over too. A skipped step produced
no new data, so contributing nothing would drop the upstream term from
every downstream cache key (see `shinobi.cache`) and needlessly
invalidate work that is genuinely unchanged.
`kind` deliberately keeps the scope's real kind -- `skipped` is a
separate flag. `shinobi.provenance.apply_manifest_pins` asserts that a
record's `kind` still matches the scope's type, so inventing a
"skipped" kind would make any early-converging run unreplayable.
Args:
ref: The skipped step.
prev: The corresponding step's result from the previous iteration.
inputs: The validated inputs this step would have run with.
Returns:
A successful `StepResult` marked `skipped`.
"""
return StepResult(
name=ref.name,
returncode=0,
outputs=prev.outputs,
inputs=inputs,
kind=prev.kind,
skipped=True,
cache_key=prev.cache_key,
output_keys=prev.output_keys,
)