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(