Files
CelerisLab/tests/unit/test_jet_update_failure.py
ClaudeandClaude Opus 5.5 7afb5addde lifecycle: Simulation jet update failure fails the instance like FixedPatchStepper
Simulation.set_jet_amplitude validated before upload and committed host
state after it, but after an uncertain device write nothing stopped reuse.
Record the failure in Simulation._failed and refuse every public device
call except close() (FixedPatchStepper already uses status 'failed').
Invalid input stays side-effect free and retryable. Adds host fault tests
for both owners and states the rule in docs/lifecycle.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:02:56 +00:00

125 lines
5.1 KiB
Python

"""Jet amplitude update failure contract (host doubles only, no CUDA numerics).
Both owners validate before any device write, commit host state only after the
upload returns, and refuse every later call except close() after an uncertain
device write.
"""
from types import SimpleNamespace
import numpy as np
import pytest
pytest.importorskip("pycuda.driver")
from CelerisLab.body.jets import Jet, Radial, Sector
from tests.unit.test_pinned_body_remap import ready
from tests.unit.test_simulation_lifecycle import make_simulation
BAD = (float("nan"), float("inf"), 1.)
class Curved:
"""Records uploads; ``fail`` raises once the write was attempted."""
def __init__(self):
self.writes, self.fail = [], False
def set_jet_amplitude(self, index, amplitude, stream):
self.writes.append((index, amplitude))
if self.fail:
raise RuntimeError("upload failed")
def simulation(monkeypatch):
sim = make_simulation(monkeypatch)
jet = Jet(Sector((6., 5.), 0., .5, 4.), Radial((6., 5.)), "uniform", 1e-3)
sim._failed, sim._initialized = None, True
sim.bodies, sim.field = SimpleNamespace(jets=[jet]), SimpleNamespace(curved=Curved())
return sim, jet
def test_simulation_invalid_input_writes_nothing_and_stays_usable(monkeypatch):
sim, jet = simulation(monkeypatch)
for amplitude in BAD:
with pytest.raises(ValueError):
sim.set_jet_amplitude(0, amplitude)
with pytest.raises(IndexError):
sim.set_jet_amplitude(1, 2e-3)
assert sim.field.curved.writes == [] and sim.bodies.jets[0] is jet and sim._failed is None
sim.set_jet_amplitude(0, 2e-3) # retry after a rejected input
assert sim.field.curved.writes == [(0, 2e-3)] and sim.bodies.jets[0].amplitude == 2e-3
sim.close()
def test_simulation_upload_failure_keeps_host_and_blocks_reuse(monkeypatch):
sim, jet = simulation(monkeypatch)
sim.field.curved.fail = True
with pytest.raises(RuntimeError, match="upload failed"):
sim.set_jet_amplitude(0, 2e-3)
assert sim.bodies.jets[0] is jet and sim._failed is not None
sim.field.curved.fail = False
for call in (lambda: sim.set_jet_amplitude(0, 2e-3), lambda: sim.run(1), sim.step,
sim.sync_bodies, sim.initialize, sim.get_ddf, sim.get_macroscopic,
lambda: sim.read_surface_exchange(0), lambda: sim.read_body(0),
sim.read_bodies, sim.snapshot, sim.restore, sim.save_checkpoint,
lambda: sim.load_checkpoint("x.h5"), lambda: sim.update_runtime_params(omega=1.)):
with pytest.raises(RuntimeError, match="Simulation failed"):
call()
assert sim.field.curved.writes == [(0, 2e-3)]
sim.close() # cleanup still works
assert sim._closed and sim.ctx.is_closed
def test_simulation_before_initialize_commits_host_only(monkeypatch):
sim, _ = simulation(monkeypatch)
sim._initialized = False
sim.set_jet_amplitude(0, -2e-3)
assert sim.field.curved.writes == [] and sim.bodies.jets[0].amplitude == -2e-3
sim.close()
def stepper_state(run):
boundary = run._level_boundaries[2]
return boundary.tables["surface_velocity"].copy(), boundary.jets, run._level_jets[2]
def test_stepper_invalid_input_writes_nothing_and_stays_ready():
run, module, _, _, _ = ready()
try:
velocity, jets, level_jets = stepper_state(run)
module.cuda.memcpy_htod.reset_mock()
for args, kw, error in [((0, a), {"level": 2}, ValueError) for a in BAD] + [
((1, .01), {"level": 2}, IndexError), ((0, .01), {"level": 3}, ValueError)]:
with pytest.raises(error):
run.set_jet_amplitude(*args, **kw)
module.cuda.memcpy_htod.assert_not_called()
np.testing.assert_array_equal(run._level_boundaries[2].tables["surface_velocity"], velocity)
assert (run._level_boundaries[2].jets, run._level_jets[2]) == (jets, level_jets)
assert run._status == "ready"
run.set_jet_amplitude(0, .017, level=2)
assert run._level_jets[2][0].amplitude == .017
finally:
run.close()
@pytest.mark.parametrize("where", ["upload", "synchronize"])
def test_stepper_device_failure_keeps_host_and_blocks_reuse(where):
run, module, _, _, _ = ready()
try:
velocity, jets, level_jets = stepper_state(run)
if where == "upload":
module.cuda.memcpy_htod.side_effect = RuntimeError("upload failed")
else:
run._stream.synchronize.side_effect = RuntimeError("upload failed")
with pytest.raises(RuntimeError, match="upload failed"):
run.set_jet_amplitude(0, .017, level=2)
np.testing.assert_array_equal(run._level_boundaries[2].tables["surface_velocity"], velocity)
assert (run._level_boundaries[2].jets, run._level_jets[2]) == (jets, level_jets)
assert run._status == "failed"
module.cuda.memcpy_htod.side_effect = None
run._stream.synchronize.side_effect = None
for call in (lambda: run.set_jet_amplitude(0, .017, level=2), run.step, run.download):
with pytest.raises(RuntimeError, match="failed"):
call()
finally:
run.close()
assert run._status == "closed"