Skip to content

Commit 7166b5d

Browse files
committed
fix(habitat): verify spatial profile against Node sources
1 parent fdf2a67 commit 7166b5d

4 files changed

Lines changed: 64 additions & 2 deletions

File tree

‎docs/decisions/0047-reset-state-deployment-candidates.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ world.
4343
can shift a coordinate by one bit and reject an otherwise intact artifact.
4444
3. The B1 launcher binds the matrix to its frozen run, episode, scene, dataset,
4545
semantic evidence, Node-profile digest, and seed before starting Control.
46+
Before Control starts, the launcher also reconstructs the spatial profile
47+
from the exact Node config files and rejects any changed endpoint identity
48+
or capability value, even if the profile's unkeyed digest was recomputed.
4649
The Controller loads the bounded matrix as an optional deployment-owned
4750
source and checks its schema, content digest, endpoint coverage, reset
4851
positions, and decision consistency. The source is not Mission Grounding,

‎integrations/habitat-local-eaios/habitat_local_eaios/spatial_feasibility.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,24 @@ def load_spatial_profile_snapshot(path: Path) -> tuple[FloorTransitionProfile, .
193193
raise IntegrationError(f"cannot admit deployment spatial profile: {error}") from error
194194

195195

196+
def verify_spatial_profile_sources(path: Path, node_configs: tuple[tuple[int, Path], ...]) -> None:
197+
"""Require the frozen profile to equal the exact configured Node declarations.
198+
199+
The Habitat Python 3.9 child checks source byte digests but cannot parse TOML
200+
with its standard library. This deployment preflight runs under Python 3.11
201+
before Control starts, so a recomputed snapshot digest cannot substitute
202+
different capability facts or endpoint identities for the Node sources.
203+
"""
204+
load_spatial_profile_snapshot(path)
205+
try:
206+
document = json.loads(path.read_text(encoding="utf-8"))
207+
expected = build_spatial_profile_snapshot(node_configs)
208+
except (OSError, ValueError, TypeError) as error:
209+
raise IntegrationError(f"cannot verify deployment spatial profile: {error}") from error
210+
if document != expected:
211+
raise IntegrationError("deployment spatial profile differs from configured Node sources")
212+
213+
196214
def assess_spatial_feasibility(
197215
habitat_env: Any,
198216
agent_id: int,
@@ -322,13 +340,18 @@ def _digest(value: dict[str, object]) -> str:
322340

323341

324342
def main() -> None:
325-
"""Build an exact run-local Node registration snapshot with Python 3.11+."""
343+
"""Build or verify a run-local Node registration snapshot with Python 3.11+."""
326344
parser = argparse.ArgumentParser(description="Freeze Node spatial capability facts")
327345
parser.add_argument("--node-a", type=Path, required=True)
328346
parser.add_argument("--node-b", type=Path, required=True)
329347
parser.add_argument("--output", type=Path, required=True)
348+
parser.add_argument("--verify-sources", action="store_true")
330349
arguments = parser.parse_args()
331-
document = build_spatial_profile_snapshot(((0, arguments.node_a), (1, arguments.node_b)))
350+
node_configs = ((0, arguments.node_a), (1, arguments.node_b))
351+
if arguments.verify_sources:
352+
verify_spatial_profile_sources(arguments.output, node_configs)
353+
return
354+
document = build_spatial_profile_snapshot(node_configs)
332355
write_text_atomic(arguments.output, json.dumps(document, sort_keys=True, indent=2) + "\n")
333356

334357

‎integrations/habitat-local-eaios/tests/test_spatial_feasibility.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525
from habitat_local_eaios.shared_world import SharedEmosStage2Runtime # noqa: E402
2626
from habitat_local_eaios.spatial_feasibility import ( # noqa: E402
2727
FloorTransitionProfile,
28+
_digest,
2829
assess_spatial_feasibility,
2930
build_spatial_profile_snapshot,
3031
load_spatial_profile_snapshot,
32+
verify_spatial_profile_sources,
3133
)
3234

3335
_ROOT = Path(__file__).parents[3]
@@ -127,6 +129,7 @@ def test_exact_node_registration_snapshot_round_trip_and_source_change(tmp_path:
127129
profiles = load_spatial_profile_snapshot(snapshot)
128130
assert [profile.node_id for profile in profiles] == ["e1-shared-node-a", "e1-shared-node-b"]
129131
assert [profile.support_for("mobility.move@v1") for profile in profiles] == [True, False]
132+
verify_spatial_profile_sources(snapshot, ((0, a), (1, b)))
130133
tampered = json.loads(snapshot.read_text(encoding="utf-8"))
131134
tampered["agents"][1]["operation_support"]["mobility.move@v1"] = True
132135
snapshot.write_text(json.dumps(tampered), encoding="utf-8")
@@ -141,6 +144,34 @@ def test_exact_node_registration_snapshot_round_trip_and_source_change(tmp_path:
141144
load_spatial_profile_snapshot(snapshot)
142145

143146

147+
@pytest.mark.parametrize("field", ["operation_support", "node_id", "agent_id"])
148+
def test_resealed_spatial_profile_cannot_replace_node_source_facts(
149+
tmp_path: Path, field: str
150+
) -> None:
151+
"""A fresh digest cannot replace exact Node capability or endpoint declarations."""
152+
source = _ROOT / "scenarios/e1-shared-world-episode-51"
153+
a = tmp_path / "a.toml"
154+
b = tmp_path / "b.toml"
155+
a.write_bytes((source / "node-a.toml").read_bytes())
156+
b.write_bytes((source / "node-b.toml").read_bytes())
157+
snapshot = tmp_path / "profile.json"
158+
document = build_spatial_profile_snapshot(((0, a), (1, b)))
159+
second = cast(dict[str, Any], cast(list[Any], document["agents"])[1])
160+
if field == "operation_support":
161+
support = cast(dict[str, bool], second["operation_support"])
162+
support["mobility.move@v1"] = True
163+
elif field == "node_id":
164+
second["node_id"] = "substituted-node"
165+
else:
166+
second["agent_id"] = 2
167+
body = {key: value for key, value in document.items() if key != "digest"}
168+
document["digest"] = _digest(body)
169+
snapshot.write_text(json.dumps(document), encoding="utf-8")
170+
load_spatial_profile_snapshot(snapshot)
171+
with pytest.raises(IntegrationError, match="differs from configured Node sources"):
172+
verify_spatial_profile_sources(snapshot, ((0, a), (1, b)))
173+
174+
144175
@pytest.mark.parametrize(
145176
("support", "expected"),
146177
[(False, "incompatible"), (True, "compatible"), (None, "unknown")],

‎scenarios/e1-shared-world-episode-51/run-b1-roboguide.sh‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ wait_http http://127.0.0.1:28102/v1/health 30 ONLINE
242242
uv run --project "$REPO" python -m roboguide_eval.b1_planning_source \
243243
"$RUN" --check-artifact \
244244
|| { FAILURE_REASON=planning_world_evidence_unavailable; exit 1; }
245+
PYTHONPATH="$REPO/integrations/habitat-local-eaios" python3 -m \
246+
habitat_local_eaios.spatial_feasibility \
247+
--node-a "$RUN/node-a.toml" --node-b "$RUN/node-b.toml" \
248+
--output "$RUN/spatial-profile.json" --verify-sources \
249+
|| { FAILURE_REASON=spatial_profile_source_mismatch; exit 1; }
245250
uv run --project "$REPO" python -m roboguide_eval.b1_deployment_feasibility "$RUN" \
246251
|| { FAILURE_REASON=preassignment_feasibility_unavailable; exit 1; }
247252

0 commit comments

Comments
 (0)