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
14 changes: 9 additions & 5 deletions docs/cvc-nav-and-grl-snam-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 78 additions & 2 deletions grl_snam/material_train_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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__(
Expand Down Expand Up @@ -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(
Expand All @@ -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),
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions grl_snam/surrogate_robust.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
117 changes: 117 additions & 0 deletions tests/test_material_train_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

import inspect
import struct

import numpy as np
Expand Down Expand Up @@ -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"
Loading
Loading