From 120d4de999fff4358a2bf46020b3679b74221c67 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Mon, 12 Oct 2026 15:52:16 -0500 Subject: [PATCH] material-train-native: expose libcvc#596's sampled L_multi starts; fix the native-parity docstrings libcvc#596 gave nav_material_trainer_create five trailing positional args after use_cuda: ms_count, frac_lo, frac_hi, seed and ms_semi_implicit. Their defaults are the legacy single explicit-Euler start. MaterialTrainer now takes them as ms_count=1, frac_range=(0.9, 0.9), seed=0 and semi_implicit=False. The seed keeps its low 32 bits, as torch's manual_seed does. Step k draws with seed + k, and loss() draws with the base seed. An older pycvc rejects the extra args with TypeError. hasattr cannot see new trailing args, so HAS_MATERIAL_TRAINER_MULTISTART probes for them: a create with an inverted frac range. The new binding range-checks before it opens the model, so it raises RuntimeError without a .cvcnm or a trainer. An old one raises TypeError on the argument count. The probe's types must match the C signature exactly, because SWIG's overload dispatch also answers a type mismatch with TypeError. A test cross-checks the probe against the proxy's Python signature. The legacy start keeps calling the old signature. That covers the defaults, and also any ms_count >= 1 or seed with a (0.9, 0.9) range and explicit Euler, since a degenerate range draws nothing and runs one rollout. So the material fork's ms_count=10 config still builds on an old pycvc. Any other configuration raises a RuntimeError naming libcvc#596 when the binding lacks the args. A bad frac_range raises ValueError on any binding, with multi_start_penalty's rule. The docstrings overclaimed native parity. surrogate_robust's multi_start_penalty said the native port "mirrors that single start", and the audit test said (0.9, 0.9) is libcvc's native port "exactly". The start matches, but native's default rollout is EXPLICIT Euler, the material fork's order that its train_material.py imports. GRL-SNAM's integrate_surrogate_v2 is semi-implicit. They match only with semi_implicit=True. The cvc-nav guide called the integrator split "not yet reconciled"; it now says how to close it. Tests: - test_material_train_native mirrors libcvc's test_sampled_multi_start_args. It checks that a degenerate range is the legacy loss, that a sampled range and the integrator switch change the loss, that seed + 2**32 matches seed, and that step k uses seed + k at lr=0. - A randomized cross-language parity test isolates native L_multi as loss(w_multi=1) - loss(w_multi=0) with semi_implicit=True. It compares that to multi_start_penalty, fed nav_matnet_forward's coefficients and a manual_seed generator. Native agrees to ~1e-7 relative; the explicit default is 5-30% off. These skip unless the pycvc carries the args. - test_material_train_native_args checks the signature routing against a fake binding, so it runs without pycvc. --- docs/cvc-nav-and-grl-snam-guide.md | 14 ++- grl_snam/material_train_native.py | 80 +++++++++++++- grl_snam/surrogate_robust.py | 10 +- tests/test_material_train_native.py | 117 +++++++++++++++++++++ tests/test_material_train_native_args.py | 127 +++++++++++++++++++++++ tests/test_training_loss_audit.py | 7 +- 6 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 tests/test_material_train_native_args.py diff --git a/docs/cvc-nav-and-grl-snam-guide.md b/docs/cvc-nav-and-grl-snam-guide.md index 731e3aa..bf83a11 100644 --- a/docs/cvc-nav-and-grl-snam-guide.md +++ b/docs/cvc-nav-and-grl-snam-guide.md @@ -145,11 +145,15 @@ Two concrete consequences worth carrying, not just vocabulary: That's fixed and republished now, but it's the load-bearing example of why "not actually Hamiltonian" matters operationally and not just semantically: a force field that isn't conservative can't be reasoned about the way the vocabulary suggests. -- **A second, smaller divergence, still open:** `cvc::nav`'s `integrate_surrogate_v2` - (`geom_rollout.cpp`) runs **explicit** Euler where its Python namesake runs **semi-implicit** — a - genuine cross-language mismatch, though a narrow-blast-radius one (it isn't exported through - `pycvc_nav.i`, and its only callers are internal to `geom_rollout.cpp`'s multi-start training loss). - Worth knowing before you touch that path; not yet reconciled. +- **A second, smaller divergence, opt-in to close:** `cvc::nav`'s `integrate_surrogate_v2` + (`geom_rollout.cpp`) runs **explicit** Euler by default, where its Python namesake runs + **semi-implicit**. The default follows the material fork's `train_material.py`, the native + trainer's normative source. Its blast radius is narrow: it isn't exported through `pycvc_nav.i`, + and its only callers are internal to `geom_rollout.cpp`'s multi-start training loss (L_multi). + libcvc #596 adds a `semi_implicit` switch, reached from Python as + `MaterialTrainer(semi_implicit=True)` in `grl_snam/material_train_native.py`. With it, native + L_multi matches `grl_snam.surrogate_robust.multi_start_penalty`. Without it, the two agree on the + start but not the rollout. None of this changes how you should use `cvc::nav` day to day — the fidelity contract above is what governs comparing outputs, and it holds regardless of what you call the underlying physics. diff --git a/grl_snam/material_train_native.py b/grl_snam/material_train_native.py index fdd3e39..df98617 100644 --- a/grl_snam/material_train_native.py +++ b/grl_snam/material_train_native.py @@ -17,6 +17,17 @@ a guarantee — each op falls back to its host twin when the build has no CUDA, no device is present, or the batch exceeds a kernel's limits — so read :attr:`MaterialTrainer.cuda_active` for what is actually running. + +The multi-start term L_multi defaults to the material fork's legacy single start: +one rollout from 90% of the clearance toward the nearest obstacle, integrated with +EXPLICIT Euler (the fork's ``train_material.py`` order). ``ms_count``, +``frac_range``, ``seed`` and ``semi_implicit`` opt into GRL-SNAM #113's sampled +starts (libcvc #596). With ``semi_implicit=True`` the rollout is +:func:`grl_snam.surrogate_robust.integrate_surrogate_v2`'s, and L_multi is +:func:`~grl_snam.surrogate_robust.multi_start_penalty` with +``generator=torch.Generator().manual_seed(seed)``. Those args need a pycvc that +takes them (:data:`HAS_MATERIAL_TRAINER_MULTISTART`). Without one, only a +configuration that equals the legacy start can be built. """ from __future__ import annotations @@ -37,6 +48,33 @@ ) +def _takes_multi_start_args() -> bool: + """Whether ``nav_material_trainer_create`` takes the trailing L_multi sampling args. + + The args are new trailing positionals on an existing function (libcvc #596), so + ``hasattr`` cannot see them. The probe passes an inverted frac range. A binding + that takes the args range-checks it before it opens the model, so it raises + RuntimeError without a ``.cvcnm`` or a trainer. An older one rejects the argument + count with TypeError. The types must match the C signature exactly: SWIG's + overload dispatch also answers a type mismatch with TypeError. + """ + if not HAS_MATERIAL_TRAINER: + return False + # path, grad_clip..tau (19 doubles), ms_h, ms_dt_mult, use_cuda, then + # ms_count, frac_lo, frac_hi, seed, ms_semi_implicit. + probe = ("", *[0.0] * 19, 3, 4.0, 0, 1, 0.5, 0.4, 0, 0) + try: + _pycvc.nav_material_trainer_create(*probe) + except RuntimeError: + return True # past the arity check; the range check refused it + except Exception: + return False + return True # accepted outright (cannot happen with an inverted range) + + +HAS_MATERIAL_TRAINER_MULTISTART = _takes_multi_start_args() + + def cuda_available() -> bool: """True when this pycvc was built with CUDA and a device is present. @@ -116,6 +154,19 @@ class MaterialTrainer: Construct from a ``.cvcnm`` checkpoint (initial / Stage-1 warm-start weights); drive with :meth:`step` over batches; :meth:`save` the trained weights. + + L_multi start sampling (GRL-SNAM #113, libcvc #596): each of ``ms_count`` starts + moves every agent ``frac ~ U(frac_range)`` of its clearance toward its nearest + obstacle. ``seed`` fixes the draw, and only its low 32 bits count, as with + ``torch.Generator.manual_seed``. The k-th :meth:`step` draws with ``seed + k``, + so each step sees fresh starts. :meth:`loss` always draws with ``seed``, so + validation is deterministic. ``semi_implicit`` selects GRL-SNAM's integrator for + the short rollouts. The default is the fork's explicit Euler. A degenerate range + draws nothing and runs one rollout whatever ``ms_count`` is. So the defaults, and + any ``ms_count >= 1`` or ``seed`` with ``frac_range=(0.9, 0.9)`` and + ``semi_implicit=False``, are the legacy single start. They work on any pycvc + with the trainer. Every other configuration needs + :data:`HAS_MATERIAL_TRAINER_MULTISTART`. """ def __init__( @@ -144,6 +195,10 @@ def __init__( ms_h: int = 3, ms_dt_mult: float = 4.0, use_cuda: bool = False, + ms_count: int = 1, + frac_range: tuple[float, float] = (0.9, 0.9), + seed: int = 0, + semi_implicit: bool = False, ): if not HAS_MATERIAL_TRAINER: raise RuntimeError( @@ -156,7 +211,18 @@ def __init__( "nav_material_trainer_cuda_active. Rebuild with -DCVC_ENABLE_CUDA=ON, " "or pass use_cuda=False." ) - self._h = _pycvc.nav_material_trainer_create( + frac_lo, frac_hi = (float(f) for f in frac_range) + if not 0.0 <= frac_lo <= frac_hi < 1.0: + raise ValueError(f"frac_range must satisfy 0 <= lo <= hi < 1, got {frac_range!r}") + legacy_multi = frac_lo == frac_hi == 0.9 and not semi_implicit and ms_count >= 1 + if not legacy_multi and not HAS_MATERIAL_TRAINER_MULTISTART: + raise RuntimeError( + "ms_count/frac_range/seed/semi_implicit beyond the legacy single start need a " + "pycvc whose nav_material_trainer_create takes them (libcvc #596); this one " + "does not. Upgrade pycvc, or keep frac_range=(0.9, 0.9), " + "semi_implicit=False and ms_count >= 1." + ) + args = [ str(cvcnm_path), float(grad_clip), float(w_traj), @@ -180,7 +246,17 @@ def __init__( int(ms_h), float(ms_dt_mult), int(bool(use_cuda)), - ) + ] + if not legacy_multi: + # The legacy start keeps the old signature, which every trainer binding takes. + args += [ + int(ms_count), + frac_lo, + frac_hi, + int(seed) & 0xFFFFFFFF, + int(bool(semi_implicit)), + ] + self._h = _pycvc.nav_material_trainer_create(*args) self._requested_cuda = bool(use_cuda) @property diff --git a/grl_snam/surrogate_robust.py b/grl_snam/surrogate_robust.py index fc859d6..6fe4952 100644 --- a/grl_snam/surrogate_robust.py +++ b/grl_snam/surrogate_robust.py @@ -186,8 +186,14 @@ def multi_start_penalty( penalizes ``softplus(-clearance / tau)``. The default range is centred on the old fixed ``frac = 0.9``, so ``w_multi`` keeps its scale. A degenerate range ``(f, f)`` draws no random numbers; ``(0.9, 0.9)`` reproduces the pre-2026-10 penalty, whose ``ms_count`` rollouts were all - identical (libcvc's native ``cvc::nav::multi_start_penalty`` mirrors that single start). - ``generator`` makes the draw reproducible without touching the global RNG. + identical. ``generator`` makes the draw reproducible without touching the global RNG. + + libcvc's native ``cvc::nav::multi_start_penalty`` takes the same start but, by default, rolls it + out with EXPLICIT Euler. That is the material fork's order, which its ``train_material.py`` + imports. :func:`integrate_surrogate_v2` here is semi-implicit, so the native default does not + reproduce this penalty. It does only with ``semi_implicit=True`` (libcvc #596; see + ``MaterialTrainer`` in :mod:`grl_snam.material_train_native`). There, native's sampled starts + match this function's with ``generator=torch.Generator().manual_seed(seed)``, draw for draw. """ lo, hi = (float(f) for f in frac_range) if not 0.0 <= lo <= hi < 1.0: diff --git a/tests/test_material_train_native.py b/tests/test_material_train_native.py index 9e800a7..18f33bb 100644 --- a/tests/test_material_train_native.py +++ b/tests/test_material_train_native.py @@ -10,6 +10,7 @@ from __future__ import annotations +import inspect import struct import numpy as np @@ -225,3 +226,119 @@ def test_cuda_and_cpu_training_agree(tmp_path): # Float-equivalent ops compounded over `steps` optimizer updates. assert abs(c1 - g1) / abs(c1) < 1e-3, f"CPU {c1:.6f} vs GPU {g1:.6f}" assert abs(cval - gval) / abs(cval) < 1e-3 + + +# --------------------------------------------------------------------------- L_multi sampling + +_needs_multistart = pytest.mark.skipif( + not (mtn.HAS_MATERIAL_TRAINER_MULTISTART and mtn.HAS_MATERIAL_TRAINER_LOSS), + reason="pycvc's nav_material_trainer_create lacks the L_multi sampling args (libcvc #596)", +) +_SAMPLED = dict(ms_count=4, frac_range=(0.8, 0.98)) + + +def _close(x, y): + return abs(x - y) / (abs(y) + 1e-6) < 1e-6 + + +def test_multistart_probe_agrees_with_the_binding_signature(): + """The capability probe must not be fooled by its own argument types: SWIG answers a type + mismatch with the same TypeError an old arity gives. The proxy signature cross-checks it.""" + try: + params = inspect.signature(mtn._pycvc.nav_material_trainer_create).parameters + except (TypeError, ValueError): + pytest.skip("this pycvc's create has no Python-level signature to compare against") + assert mtn.HAS_MATERIAL_TRAINER_MULTISTART == ("ms_semi_implicit" in params) + + +@_needs_multistart +def test_sampled_multi_start_args(tmp_path): + """libcvc's test_sampled_multi_start_args, through the wrapper: a degenerate range is the + legacy single start, a sampled range and the integrator switch reach the loss, validation + draws with the base seed, and step k draws with seed + k.""" + ckpt = tmp_path / "init.cvcnm" + _write_random_cvcnm(ckpt, patch_size=16, seed=5) + batch = _synthetic_batch(seed=13) + + def loss(**kw): + return mtn.MaterialTrainer(ckpt, **kw).loss(batch) + + legacy = loss() + # The wrapper routes this through the legacy signature (no draw, one rollout)... + assert loss(ms_count=10, frac_range=(0.9, 0.9), seed=123) == legacy + # ...so check native's own degenerate handling on a configuration that needs the new args. + semi_one = loss(semi_implicit=True) + assert loss(ms_count=7, frac_range=(0.9, 0.9), seed=3, semi_implicit=True) == semi_one + assert semi_one != legacy, "semi_implicit did not reach the rollout" + + sampled = mtn.MaterialTrainer(ckpt, seed=5, **_SAMPLED) + base = sampled.loss(batch) + assert base != legacy, "sampled starts did not reach the loss" + assert sampled.loss(batch) == base, "validation loss is not deterministic" + assert loss(seed=5, semi_implicit=True, **_SAMPLED) != base, "semi_implicit ignored" + # Only the low 32 bits of the seed count, as with torch.Generator.manual_seed. + assert loss(seed=5 + 2**32, **_SAMPLED) == base + + # At lr=0 the weights never move, so the k-th step scores what a trainer seeded seed + k + # scores at its base seed. (step vs loss: the same value, compared to a tolerance.) + tr = mtn.MaterialTrainer(ckpt, seed=5, **_SAMPLED) + s0 = tr.step(batch, lr=0.0) + s1 = tr.step(batch, lr=0.0) + assert _close(s0, base), "step 0 did not draw with the base seed" + assert not _close(s1, s0), "the draw did not advance between steps" + assert _close(s1, loss(seed=6, **_SAMPLED)), "step 1 did not draw with seed + 1" + assert tr.loss(batch) == base, "steps moved the validation draw" + + with pytest.raises(ValueError, match="frac_range"): + mtn.MaterialTrainer(ckpt, ms_count=4, frac_range=(0.98, 0.8)) + + +@_needs_multistart +@pytest.mark.parametrize("case", [0, 1, 2]) +def test_semi_implicit_l_multi_matches_the_torch_reference(tmp_path, case): + """Native L_multi with semi_implicit=True is GRL-SNAM's multi_start_penalty, fed the native + model's coefficients and a manual_seed generator; the explicit default is not. + + L_multi is isolated as loss(w_multi=1) - loss(w_multi=0): every other term is the same + deterministic function of the same weights and batch. + """ + torch = pytest.importorskip("torch") + from grl_snam.surrogate_robust import multi_start_penalty + + rng = np.random.default_rng(100 + case) + seed = int(rng.integers(0, 2**32)) + (2**32 if case == 2 else 0) # high bits must not matter + ms_count = int(rng.integers(2, 7)) + lo = float(rng.uniform(0.5, 0.85)) + frac_range = (lo, float(rng.uniform(lo + 0.05, 0.99))) + ckpt = tmp_path / "init.cvcnm" + _write_random_cvcnm(ckpt, patch_size=16, seed=200 + case) + b = _synthetic_batch(seed=300 + case) + ms = dict(ms_count=ms_count, frac_range=frac_range, seed=seed) + + def native(semi): + on, off = ( + mtn.MaterialTrainer(ckpt, w_multi=w, semi_implicit=semi, **ms).loss(b) for w in (1, 0) + ) + return on - off + + B, N = b["C"].shape[:2] + alphas, beta, gamma = mtn._pycvc.nav_matnet_forward( + str(ckpt), + b["obs_feats"].reshape(B * N, 6), + b["obs_mask"].reshape(B * N), + (np.arange(B + 1) * N).astype(np.int32), + b["goal_feats"], + b["risk_patch"], + )[:3] + t = torch.from_numpy + ref = multi_start_penalty( + t(b["o0"]), t(b["v0"]), t(b["goal"]), t(b["C"]), t(b["R"]), t(b["obs_mask"].astype(bool)), + t(alphas.reshape(B, N)), t(beta), t(gamma), t(b["d_hat"]), t(b["dt"]), t(b["H"]), + robot_radius=t(b["rr"]), margin_factor=0.5, ms_h=3, ms_dt_mult=4.0, tau=0.05, + generator=torch.Generator().manual_seed(seed), ms_count=ms_count, frac_range=frac_range, + ).item() # fmt: skip + + semi, explicit = native(True), native(False) + assert ref > 1e-3, "fixture must actually score near-obstacle starts" + assert abs(semi - ref) <= 1e-5 * ref, f"semi-implicit native {semi!r} vs torch {ref!r}" + assert abs(explicit - ref) > 1e-3 * ref, "the explicit default should NOT match" diff --git a/tests/test_material_train_native_args.py b/tests/test_material_train_native_args.py new file mode 100644 index 0000000..89474af --- /dev/null +++ b/tests/test_material_train_native_args.py @@ -0,0 +1,127 @@ +"""MaterialTrainer's create-argument contract, against a fake binding so it runs without pycvc. + +The L_multi sampling args (``ms_count``, ``frac_range``, ``seed``, ``semi_implicit``) are new +trailing positionals on ``nav_material_trainer_create`` (libcvc #596), and an older binding rejects +the extra arguments with TypeError. So the wrapper must keep calling the old signature for the +legacy single start. It may refuse, with a clear error, only a configuration that needs the new +args. tests/test_material_train_native.py checks the same args against a real pycvc. +""" + +from __future__ import annotations + +import pytest + +from grl_snam import material_train_native as mtn + +_LEGACY_ARITY = 23 # path, grad_clip..tau (19 doubles), ms_h, ms_dt_mult, use_cuda +_NEW_ARITY = _LEGACY_ARITY + 5 # + ms_count, frac_lo, frac_hi, seed, ms_semi_implicit +_SAMPLED = dict(ms_count=4, frac_range=(0.8, 0.98), seed=5, semi_implicit=True) + + +class _FakeBinding: + """Records create() calls and rejects an unknown arity, as the SWIG proxy does.""" + + def __init__(self, takes_multi_start: bool): + self.max_args = _NEW_ARITY if takes_multi_start else _LEGACY_ARITY + self.calls: list[tuple] = [] + + def nav_material_trainer_create(self, *args): + if len(args) > self.max_args: + raise TypeError( + f"nav_material_trainer_create() takes from 1 to {self.max_args} positional " + f"arguments but {len(args)} were given" + ) + if len(args) == _NEW_ARITY and not args[24] <= args[25]: + raise RuntimeError("cvc::nav::multi_start: the frac range must satisfy lo <= hi") + self.calls.append(args) + return object() + + +@pytest.fixture +def binding(monkeypatch): + """Install a fake binding; the capability flag comes from the real probe run against it.""" + + def install(takes_multi_start: bool) -> _FakeBinding: + fake = _FakeBinding(takes_multi_start) + monkeypatch.setattr(mtn, "_pycvc", fake) + monkeypatch.setattr(mtn, "HAS_MATERIAL_TRAINER", True) + monkeypatch.setattr(mtn, "HAS_MATERIAL_TRAINER_MULTISTART", mtn._takes_multi_start_args()) + return fake + + return install + + +@pytest.mark.parametrize("takes", [False, True]) +def test_probe_reads_the_binding_without_building_a_trainer(binding, takes): + fake = binding(takes) + assert mtn.HAS_MATERIAL_TRAINER_MULTISTART is takes + assert fake.calls == [] + + +@pytest.mark.parametrize("takes", [False, True]) +@pytest.mark.parametrize( + "kw", + [{}, dict(ms_count=10), dict(ms_count=10, frac_range=(0.9, 0.9), seed=123)], + ids=["defaults", "ms_count", "degenerate-seeded"], +) +def test_the_legacy_start_keeps_the_old_signature(binding, takes, kw): + """A degenerate 0.9 range with explicit Euler draws nothing and runs one rollout, whatever + ms_count (>= 1) and seed are: it is the legacy start, so it must not need the new binding.""" + fake = binding(takes) + mtn.MaterialTrainer("m.cvcnm", **kw) + (args,) = fake.calls + assert len(args) == _LEGACY_ARITY + assert args[-1] == 0 # use_cuda + + +def test_sampled_args_are_appended_after_the_legacy_ones(binding): + fake = binding(True) + mtn.MaterialTrainer("m.cvcnm") + mtn.MaterialTrainer("m.cvcnm", **_SAMPLED) + legacy, sampled = fake.calls + assert sampled[:_LEGACY_ARITY] == legacy + tail = sampled[_LEGACY_ARITY:] + assert tail == (4, 0.8, 0.98, 5, 1) + assert [type(a) for a in tail] == [int, float, float, int, int] + + +@pytest.mark.parametrize( + ("seed", "want"), [(5 + 2**32, 5), (2**32 - 1, 2**32 - 1), (-1, 2**32 - 1)] +) +def test_only_the_seeds_low_32_bits_are_passed(binding, seed, want): + """As torch.Generator.manual_seed: its mt19937 keeps the low 32 bits (a negative seed wraps).""" + fake = binding(True) + mtn.MaterialTrainer("m.cvcnm", **{**_SAMPLED, "seed": seed}) + assert fake.calls[0][26] == want + + +@pytest.mark.parametrize( + "kw", + [ + dict(_SAMPLED), + dict(semi_implicit=True), + dict(frac_range=(0.8, 0.8)), + dict(ms_count=0), + ], + ids=["sampled", "semi-implicit", "other-degenerate-frac", "ms_count=0"], +) +def test_configs_beyond_the_legacy_start_need_the_new_binding(binding, kw): + fake = binding(False) + with pytest.raises(RuntimeError, match="libcvc #596"): + mtn.MaterialTrainer("m.cvcnm", **kw) + assert fake.calls == [] + + fake = binding(True) + mtn.MaterialTrainer("m.cvcnm", **kw) + (args,) = fake.calls + assert len(args) == _NEW_ARITY + + +@pytest.mark.parametrize("takes", [False, True]) +@pytest.mark.parametrize("bad", [(-0.1, 0.5), (0.5, 1.0), (0.9, 0.8), (float("nan"),) * 2]) +def test_a_bad_frac_range_is_a_value_error_on_any_binding(binding, takes, bad): + """Checked in Python, with multi_start_penalty's rule, before the capability check.""" + fake = binding(takes) + with pytest.raises(ValueError, match="frac_range"): + mtn.MaterialTrainer("m.cvcnm", ms_count=4, frac_range=bad) + assert fake.calls == [] diff --git a/tests/test_training_loss_audit.py b/tests/test_training_loss_audit.py index 0680efe..4985655 100644 --- a/tests/test_training_loss_audit.py +++ b/tests/test_training_loss_audit.py @@ -80,8 +80,11 @@ def test_multi_start_starts_stay_between_the_agent_and_the_obstacle(monkeypatch) def test_multi_start_degenerate_range_reproduces_the_legacy_single_start(): - """frac_range=(0.9, 0.9) is the pre-fix behaviour (and libcvc's native port) exactly, - and draws no random numbers.""" + """frac_range=(0.9, 0.9) is the pre-fix behaviour exactly, and draws no random numbers. + + libcvc's native port takes this same start, but its default rollout is explicit Euler (the + material fork's order), not this semi-implicit one. It matches this penalty only with + semi_implicit=True (libcvc #596); test_material_train_native checks that.""" o0, v0, goal, C, R, mask, alphas, beta, gamma, d_hat, dt, H = _scene() state = torch.random.get_rng_state() got = sr.multi_start_penalty(