Skip to content

Commit e09ab6e

Browse files
authored
feat(safety): native real-time safe-projection via PyO3 (#7)
Port the safe_projection workspace envelope (AABB clamp plus radial sphere push-out) into an allocation-free Rust core, exposed to Python as ghostloop._rt_safety. policies/safe_projection.py now dispatches to the native path for sphere-only workspaces and falls back to the unchanged pure-Python implementation when the module is not built, preserving identical results and the projected_from audit field. Adds tests/test_rt_safety_equivalence.py: native vs Python projection is an exact match (max per-axis error 0.0) across index-seeded cases, and projected points satisfy the workspace envelope. Build the module with maturin; the compiled artifact is gitignored.
1 parent 6577b20 commit e09ab6e

8 files changed

Lines changed: 763 additions & 21 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ dist/
1414
.coverage
1515
htmlcov/
1616
.DS_Store
17+
18+
# native PyO3 build artifact (build with maturin)
19+
ghostloop/_rt_safety*.so

‎ghostloop/policies/safe_projection.py‎

Lines changed: 96 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@
1616
Intent with corrected args + a ``"projected_from": orig_target``
1717
field for auditability.
1818
19+
When the optional native extension ``ghostloop._rt_safety`` is
20+
built AND the workspace carries only Sphere obstacles, the
21+
analytic geometry runs in the allocation-free Rust core instead
22+
of pure Python. The result is numerically identical (verified to
23+
1e-9 per axis in tests/test_rt_safety_equivalence.py); the
24+
audit/return shape is unchanged. Any workspace with a box
25+
obstacle, or any environment where the native module is not
26+
built, transparently uses the pure-Python path below.
27+
1928
``project_to_sdf(intent, workspace, extras=())``
2029
Numerical: gradient-descent on the SDF until distance >= 0.
2130
Slower than the analytic version; handles convex polytopes /
@@ -39,6 +48,15 @@
3948
)
4049

4150

51+
# Optional allocation-free Rust fast path for the sphere-only analytic
52+
# projection. Guarded so ghostloop runs unchanged when the native extension
53+
# has not been built (the pure-Python path below is always available).
54+
try:
55+
from .. import _rt_safety as _RT_SAFETY # type: ignore[attr-defined]
56+
except Exception: # noqa: BLE001 - any import/ABI failure must fall back cleanly.
57+
_RT_SAFETY = None
58+
59+
4260
Point3 = tuple[float, float, float]
4361

4462

@@ -61,6 +79,52 @@ def _set_target(args: dict, p: Point3) -> dict:
6179
return args
6280

6381

82+
def _project_point_native(
83+
target: Point3, workspace: WorkspaceModel,
84+
) -> Point3 | None:
85+
"""Run the sphere-only analytic projection in the native Rust core.
86+
87+
Returns the projected point, or ``None`` if the native path does not
88+
apply (module not built, or the workspace has a non-sphere obstacle).
89+
The numerics match the pure-Python branch below bit-for-bit: the Rust
90+
core uses f64 and the identical operation order (clamp to bounds, then
91+
sequential radial push-out per sphere, degenerate ``dist < 1e-9`` sets
92+
``p.x = center.x + forbidden_r`` and continues).
93+
"""
94+
if _RT_SAFETY is None:
95+
return None
96+
spheres = []
97+
for ob in workspace.obstacles:
98+
if not isinstance(ob, Sphere):
99+
# Box (or any future non-sphere) obstacle: the native core does
100+
# not handle these analytically; defer to pure Python.
101+
return None
102+
spheres.append(
103+
(
104+
float(ob.center[0]),
105+
float(ob.center[1]),
106+
float(ob.center[2]),
107+
float(ob.radius),
108+
float(ob.inflation),
109+
)
110+
)
111+
bmin = (
112+
float(workspace.bounds_min[0]),
113+
float(workspace.bounds_min[1]),
114+
float(workspace.bounds_min[2]),
115+
)
116+
bmax = (
117+
float(workspace.bounds_max[0]),
118+
float(workspace.bounds_max[1]),
119+
float(workspace.bounds_max[2]),
120+
)
121+
try:
122+
out = _RT_SAFETY.project_to_workspace(target, bmin, bmax, spheres)
123+
except Exception: # noqa: BLE001 - never let the fast path break a repair.
124+
return None
125+
return float(out[0]), float(out[1]), float(out[2])
126+
127+
64128
def project_to_workspace(
65129
intent: Intent, workspace: WorkspaceModel,
66130
) -> Intent:
@@ -73,32 +137,43 @@ def project_to_workspace(
73137
AxisAlignedBox obstacles are NOT pushed out of analytically (the
74138
closest-point-on-box-exterior is geometry that's annoying to
75139
compute robustly without numpy). For boxes use ``project_to_sdf``.
140+
141+
When ``ghostloop._rt_safety`` is built and the workspace has only
142+
Sphere obstacles, the analytic geometry runs in the native Rust core;
143+
the result and the ``projected_from`` audit field are identical to the
144+
pure-Python computation. Otherwise the pure-Python path runs unchanged.
76145
"""
77146
target = _extract_target(intent.args)
78147
if target is None:
79148
return intent
80-
p = list(target)
81-
# Clamp to outer bounds.
82-
for i in range(3):
83-
p[i] = max(workspace.bounds_min[i], min(workspace.bounds_max[i], p[i]))
84-
# Radial pushout from each sphere.
85-
for ob in workspace.obstacles:
86-
if not isinstance(ob, Sphere):
87-
continue
88-
cx, cy, cz = ob.center
89-
dx, dy, dz = p[0] - cx, p[1] - cy, p[2] - cz
90-
dist = math.sqrt(dx * dx + dy * dy + dz * dz)
91-
forbidden_r = ob.radius + ob.inflation
92-
if dist < forbidden_r:
93-
if dist < 1e-9:
94-
# Degenerate: pick an arbitrary direction.
95-
p[0] = cx + forbidden_r
149+
150+
native = _project_point_native(target, workspace)
151+
if native is not None:
152+
out_target = native
153+
else:
154+
p = list(target)
155+
# Clamp to outer bounds.
156+
for i in range(3):
157+
p[i] = max(workspace.bounds_min[i], min(workspace.bounds_max[i], p[i]))
158+
# Radial pushout from each sphere.
159+
for ob in workspace.obstacles:
160+
if not isinstance(ob, Sphere):
96161
continue
97-
scale = forbidden_r / dist
98-
p[0] = cx + dx * scale
99-
p[1] = cy + dy * scale
100-
p[2] = cz + dz * scale
101-
out_target = (p[0], p[1], p[2])
162+
cx, cy, cz = ob.center
163+
dx, dy, dz = p[0] - cx, p[1] - cy, p[2] - cz
164+
dist = math.sqrt(dx * dx + dy * dy + dz * dz)
165+
forbidden_r = ob.radius + ob.inflation
166+
if dist < forbidden_r:
167+
if dist < 1e-9:
168+
# Degenerate: pick an arbitrary direction.
169+
p[0] = cx + forbidden_r
170+
continue
171+
scale = forbidden_r / dist
172+
p[0] = cx + dx * scale
173+
p[1] = cy + dy * scale
174+
p[2] = cz + dz * scale
175+
out_target = (p[0], p[1], p[2])
176+
102177
if out_target == target:
103178
return intent
104179
new_args = _set_target(intent.args, out_target)

‎native/rt_safety/.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/target
2+
/.m4venv

‎native/rt_safety/Cargo.lock‎

Lines changed: 180 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎native/rt_safety/Cargo.toml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[package]
2+
name = "rt_safety"
3+
version = "0.1.0"
4+
edition = "2021"
5+
authors = ["Joe Munene <joemunene984@gmail.com>"]
6+
description = "Allocation-free, real-time-grade port of ghostloop's safe-projection envelope, exposed to Python via PyO3."
7+
license = "MIT"
8+
9+
[lib]
10+
# cdylib: the Python extension module (.so / .pyd).
11+
# rlib: so the same logic can be unit-tested with `cargo test` and reused
12+
# by other Rust crates (the M4 rt_control campaign core).
13+
name = "rt_safety"
14+
crate-type = ["cdylib", "rlib"]
15+
16+
[dependencies]
17+
pyo3 = { version = "0.22", features = ["extension-module"] }
18+
19+
[profile.release]
20+
opt-level = 3
21+
lto = true
22+
codegen-units = 1
23+
panic = "abort"

‎native/rt_safety/pyproject.toml‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# maturin build config for the rt_safety extension.
2+
#
3+
# This pyproject exists only so `maturin` knows how to compile the PyO3
4+
# cdylib (it injects the macOS `-undefined dynamic_lookup` link flag that a
5+
# bare `cargo build` of an extension-module cdylib lacks). The compiled
6+
# artifact is installed into the ghostloop package as `ghostloop._rt_safety`;
7+
# the parent ghostloop project itself stays a plain setuptools build.
8+
[build-system]
9+
requires = ["maturin>=1.7,<2"]
10+
build-backend = "maturin"
11+
12+
[project]
13+
name = "ghostloop-rt-safety"
14+
version = "0.1.0"
15+
description = "Allocation-free real-time port of ghostloop's safe-projection envelope."
16+
requires-python = ">=3.10"
17+
18+
[tool.maturin]
19+
# Install the compiled module at ghostloop._rt_safety so the in-tree shim's
20+
# `from ghostloop import _rt_safety` resolves.
21+
module-name = "ghostloop._rt_safety"

0 commit comments

Comments
 (0)