Skip to content

Commit 1d1a896

Browse files
author
Horde
committed
Harden LLM probe backend validation
Block ready decisions when explicit runtime evidence contradicts the requested physics backend, preventing invalid benchmark measurements.
1 parent 0f8661b commit 1d1a896

6 files changed

Lines changed: 106 additions & 12 deletions

File tree

tools/perf_bisection/docs/llm-policies.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ classification or binary-search decisions.
1414
No provider is selected by default. Always pass `--base_url`; remote endpoints
1515
must use HTTPS and URLs containing credentials are rejected. NVIDIA users must
1616
select an endpoint approved by the Agent Security Readiness portal (normally
17-
`inference.nvidia.com` when NVIDIA data or internal network access is present,
18-
or `build.nvidia.com` only in the documented isolated use case). Do not use a
19-
personal provider key with NVIDIA data.
17+
`inference-api.nvidia.com` when authorized for NVIDIA data or internal network
18+
access, or `build.nvidia.com` only in the documented isolated use case). Do not
19+
use a personal provider key with NVIDIA data.
2020

2121
Deployments can enforce an exact hostname allowlist with the comma-separated
2222
`ISAACLAB_BISECTION_LLM_HOSTS` environment variable. Redirects are rejected so

tools/perf_bisection/src/isaaclab_bisection/bisection/probe.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import json
20+
import re
2021
from dataclasses import dataclass, field
2122
from pathlib import Path
2223
from typing import Any, Protocol
@@ -42,6 +43,10 @@
4243
)
4344

4445
_PROMPT_PATH = Path(__file__).resolve().parents[1] / "prompts" / "container_probe.md"
46+
_RESOLVED_BACKEND_PATTERNS = (
47+
re.compile(r"resolved physics backend:\s*([A-Za-z0-9_.-]+)", re.IGNORECASE),
48+
re.compile(r"\bKit started,\s*backend\s*=\s*([A-Za-z0-9_.-]+)", re.IGNORECASE),
49+
)
4550

4651

4752
@dataclass(frozen=True)
@@ -121,6 +126,30 @@ def _context_prompt(ctx: ProbeContext) -> str:
121126
return redact_sensitive_text(json.dumps(payload, indent=2, sort_keys=True))
122127

123128

129+
def _backend_family(value: str) -> str | None:
130+
"""Return the supported physics-backend family named by ``value``."""
131+
normalized = value.strip().lower()
132+
if normalized.startswith("newton") or "newton_mjwarp" in normalized:
133+
return "newton"
134+
if normalized.startswith("physx") or "isaacsim_physx" in normalized:
135+
return "physx"
136+
return None
137+
138+
139+
def _explicit_backend_mismatch(ctx: ProbeContext) -> tuple[str, str] | None:
140+
"""Detect an explicit resolved physics backend that contradicts the plan."""
141+
expected = _backend_family(ctx.backend_key)
142+
if expected is None:
143+
return None
144+
evidence = "\n".join((ctx.live_output_tail, ctx.benchmark_log_tail))
145+
for pattern in _RESOLVED_BACKEND_PATTERNS:
146+
for match in pattern.finditer(evidence):
147+
resolved = _backend_family(match.group(1))
148+
if resolved is not None and resolved != expected:
149+
return expected, resolved
150+
return None
151+
152+
124153
@dataclass
125154
class NoProbePolicy:
126155
"""Probe policy that immediately allows benchmarking."""
@@ -160,11 +189,22 @@ def decide(self, ctx: ProbeContext) -> ProbeDecision:
160189
f"probe model unavailable: {exc}",
161190
confidence="high",
162191
)
163-
return self._parse(reply) or ProbeDecision(
164-
PROBE_ACTION_HARNESS_BLOCKED,
165-
"probe model returned an invalid decision",
166-
confidence="high",
167-
)
192+
decision = self._parse(reply)
193+
if decision is None:
194+
return ProbeDecision(
195+
PROBE_ACTION_HARNESS_BLOCKED,
196+
"probe model returned an invalid decision",
197+
confidence="high",
198+
)
199+
if decision.action == PROBE_ACTION_READY and (mismatch := _explicit_backend_mismatch(ctx)):
200+
expected, resolved = mismatch
201+
return ProbeDecision(
202+
PROBE_ACTION_HARNESS_BLOCKED,
203+
f"probe model marked the container ready despite an explicit backend mismatch: "
204+
f"requested {expected}, resolved {resolved}",
205+
confidence="high",
206+
)
207+
return decision
168208

169209
def _parse(self, reply: str) -> ProbeDecision | None:
170210
text = reply.strip()

tools/perf_bisection/src/isaaclab_bisection/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ def _parse_args() -> argparse.Namespace:
233233
local.add_argument(
234234
"--base_url",
235235
default=None,
236-
help="OpenAI-compatible base URL for --recovery llm (defaults to the provider default).",
236+
help="Explicit OpenAI-compatible base URL required for --recovery llm or --probe llm.",
237237
)
238238
local.add_argument(
239239
"--api_key_env",

tools/perf_bisection/src/isaaclab_bisection/prompts/container_probe.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,10 @@ image. The orchestrator will reject packages outside `allowed_apt_packages`. For
6363
example, `Cannot find GMP` from a CMake build can be repaired with
6464
`"apt_packages": ["libgmp-dev"]` when that package is allowlisted. Use
6565
`harness_blocked` when no safe progress remains.
66+
67+
Before returning `ready`, compare the requested physics backend with every
68+
explicitly resolved physics backend in the evidence. The backend families must
69+
match exactly. Renderer names do not override the resolved physics backend. For
70+
example, a plan requesting Newton with `resolved physics backend: physx` is a
71+
`plan_issue`, even when installation succeeded or a renderer name contains
72+
`newton`.

tools/perf_bisection/tests/test_bisect_runner.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from isaaclab_bisection.bisection.models import BisectionPlan, MetricSpec, RunnerSpec, TaskSpec # noqa: E402
4040
from isaaclab_bisection.bisection.paired_reference import summarize_measurements # noqa: E402
4141
from isaaclab_bisection.bisection.probe import ( # noqa: E402
42+
PROBE_ACTION_HARNESS_BLOCKED,
4243
PROBE_ACTION_PLAN_ISSUE,
4344
PROBE_ACTION_READY,
4445
PROBE_ACTION_REPAIR_BASE_IMAGE,
@@ -1276,6 +1277,16 @@ def test_write_repair_dockerfile(self, tmp_path: Path) -> None:
12761277
class TestLLMProbePolicy:
12771278
"""The LLM-driven probe parser accepts structured setup-doctor decisions."""
12781279

1280+
def _context(self, *, backend_key: str, live_output_tail: str) -> ProbeContext:
1281+
return ProbeContext(
1282+
commit_sha="0" * 40,
1283+
task_id="Isaac-Cartpole-Direct",
1284+
backend_key=backend_key,
1285+
artifact_dir=Path("."),
1286+
plan={"task_id": "Isaac-Cartpole-Direct", "backend_key": backend_key},
1287+
live_output_tail=live_output_tail,
1288+
)
1289+
12791290
def test_parse_plan_issue_decision(self) -> None:
12801291
policy = LLMProbePolicy(model="dummy-model")
12811292
decision = policy._parse(
@@ -1316,6 +1327,42 @@ def test_parse_rejects_unknown_action(self) -> None:
13161327
policy = LLMProbePolicy(model="dummy-model")
13171328
assert policy._parse('{"action": "good", "reason": "not allowed"}') is None
13181329

1330+
def test_ready_is_blocked_when_explicit_backend_mismatches(self, monkeypatch: pytest.MonkeyPatch) -> None:
1331+
policy = LLMProbePolicy(model="dummy-model")
1332+
monkeypatch.setattr(
1333+
policy._client,
1334+
"complete",
1335+
lambda system, user: '{"action":"ready","reason":"install succeeded","confidence":"high"}',
1336+
)
1337+
1338+
decision = policy.decide(
1339+
self._context(
1340+
backend_key="newton_newton_renderer",
1341+
live_output_tail="[INFO] resolved physics backend: physx\n"
1342+
"[INFO] renderer preset: physx_newton_renderer\n",
1343+
)
1344+
)
1345+
1346+
assert decision.action == PROBE_ACTION_HARNESS_BLOCKED
1347+
assert "requested newton, resolved physx" in decision.reason
1348+
1349+
def test_ready_is_allowed_when_explicit_backend_matches(self, monkeypatch: pytest.MonkeyPatch) -> None:
1350+
policy = LLMProbePolicy(model="dummy-model")
1351+
monkeypatch.setattr(
1352+
policy._client,
1353+
"complete",
1354+
lambda system, user: '{"action":"ready","reason":"backend matches","confidence":"high"}',
1355+
)
1356+
1357+
decision = policy.decide(
1358+
self._context(
1359+
backend_key="newton",
1360+
live_output_tail="[INFO] Kit started, backend=newton\n",
1361+
)
1362+
)
1363+
1364+
assert decision.action == PROBE_ACTION_READY
1365+
13191366

13201367
@dataclass
13211368
class _FakeComponentStack:

tools/perf_bisection/tests/test_security.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,14 @@ def test_llm_endpoint_must_be_explicit_and_secure() -> None:
7171
with pytest.raises(ValueError, match="private, link-local, or reserved"):
7272
validate_llm_base_url("https://169.254.169.254/v1")
7373

74-
assert validate_llm_base_url("https://inference.nvidia.com/v1/") == "https://inference.nvidia.com/v1"
74+
assert validate_llm_base_url("https://inference-api.nvidia.com/v1/") == "https://inference-api.nvidia.com/v1"
7575
assert validate_llm_base_url("http://localhost:8000/v1") == "http://localhost:8000/v1"
7676

7777

7878
def test_llm_endpoint_honors_deployment_host_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
79-
monkeypatch.setenv("ISAACLAB_BISECTION_LLM_HOSTS", "inference.nvidia.com")
79+
monkeypatch.setenv("ISAACLAB_BISECTION_LLM_HOSTS", "inference-api.nvidia.com")
8080

81-
assert validate_llm_base_url("https://inference.nvidia.com/v1") == "https://inference.nvidia.com/v1"
81+
assert validate_llm_base_url("https://inference-api.nvidia.com/v1") == "https://inference-api.nvidia.com/v1"
8282
with pytest.raises(ValueError, match="not in ISAACLAB_BISECTION_LLM_HOSTS"):
8383
validate_llm_base_url("https://models.example.com/v1")
8484

0 commit comments

Comments
 (0)