Skip to content

feat(evals): TypeSafe (Jev) navigation policy agent - #4206

Draft
ruthwikdasyam wants to merge 9 commits into
mainfrom
ruthwik/feat/typesafe-policy-eval
Draft

ruthwikdasyam wants to merge 9 commits into
mainfrom
ruthwik/feat/typesafe-policy-eval

Conversation

@ruthwikdasyam

@ruthwikdasyam ruthwikdasyam commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Contribution path

Standalone draft off main. Scaffold for review — not ready to merge.

Problem

We want to evaluate TypeSafe's System One model (Jev) as a navigation policy, not as a grader: feed it a god-view world state, have it emit a direction, and drive the robot in sim until it reports it has arrived. Then grade the episode offline from the recording.

Solution

TypeSafePolicy is an Agent, so it plugs into the existing runner with no framework change. Each tick it assembles a WorldState, asks Jev for one world-frame unit step plus a termination flag, rotates the pick into the body frame, and publishes a single Twist. Grading is entirely post-hoc off the --record recording.

Two contracts are deliberately isolated for iteration:

  • InputWorldState: robot pose, goal, room bounds, and labeled axis-aligned obstacle boxes, all in one global ROS world frame. Obstacles/room/goal come from a scene JSON; pose comes live off /odom.
  • OutputSTEP_CRITERIA / build_questions(): a Choice over five coordinate-keyed unit steps (0,0) (0,1) (1,0) (0,-1) (-1,0), plus a reached Noul.

Design notes worth reviewing:

  • Publishes /nav_cmd_vel, not /cmd_vel. MovementManager owns cmd_vel: Out[Twist] in the go2 stack, so publishing there would collide. This drops into the existing arbitration slot, keeping teleop override and stop_movement working.
  • No skill access. The blueprint is ["unitree-go2", "mcp-server"] with no skill container, so MCP comes up with zero tools exposed. mcp-server cannot be dropped entirely — Sim.start() hard-blocks on McpAdapter.wait_for_ready().
  • Code owns all geometry. The model picks a world-frame direction; the body-frame rotation stays in Python.
  • Raw NNN-request.json / NNN-response.json naming matches what EvalRunner globs for request_attempts.

Scene

apartment_couch.json is generated from the real DimSim manifest (misc/DimSim/scenes/apartment/objects/manifest.json) by dimos.evals.suites.lib.dimsim_scene: 30 floor-standing obstacles with real centres, converted from Three.js Y-up to the ROS world frame using the same axis swap DimSimClient does. Wall-mounted items and tabletop clutter are filtered out (Jev's accuracy falls with irrelevant state).

Footprints are a uniform 0.5 m half-extent — the manifest carries positions but not extents (those live in the GLBs). Real centres, approximate sizes; --half-extent tunes it. A test pins the committed JSON to the generator output so it can't drift from the manifest silently.

The goal is the couch (sectional) with a 2.0 m arrival band, matching the DimSim-native go-to-couch rubric so both harnesses agree on success.

Dependency

New typesafe extra (typesafe-sdk>=0.6.0). The package shipped after the repo's 7-day exclude-newer window, so it gets an exclude-newer-package override — same pattern as the nine packages already listed there. TYPESAFE_API_KEY is added to default.env and the conftest passthrough.

uv sync --extra typesafe --inexact

How to Test

dimos evals run dimos.evals.suites.typesafe_nav \
    --agent dimos.evals.agents.typesafe_policy \
    --set scene_json=dimos/evals/suites/scenes/apartment_couch.json

Offline (no key, no sim):

uv run pytest dimos/evals/agents/test_typesafe_policy.py dimos/evals/suites/lib/test_dimsim_scene.py

Validation so far

  • ruff check / ruff format / pre-commit clean.
  • 17 offline tests pass — body-frame rotation at 0° and 90° yaw, confidence gate, scene round-trip, observe() merge, preflight rejection, manifest frame swap, clutter filter, room bounds, and committed-JSON-matches-generator.
  • Live API verified with a real key: jev-1.13.0 answered a Choice + Noul fan-out and every field the agent reads (model, choice, confidence, probabilities, noul, usage.input_tokens/output_tokens) came back as coded.
  • SDK signatures verified against the installed 0.6.0, not just the docs. This caught four bugs in the first commit (fixed in the second): model was never passed to the client, default model is jev-latest not jev-1.13, Usage fields are input_tokens/output_tokens so token metrics were silently zero, and the request timeout was implicit.
  • Not run: a live sim eval, mypy (not in the local venv).

Known gaps

  • Obstacle footprints are approximate (see Scene). Deriving real extents means parsing the GLBs.
  • No baselines yet. A random-direction policy and a greedy-bearing policy are needed before any number here means anything — greedy-bearing is near-optimal in an open room, so the scene must require detours for the eval to discriminate.
  • Jev is documented as unreliable at numeric comparison (jev-1.13 jaggedness). Feeding raw coordinates is a deliberate choice; if it underperforms, the fallback is to precompute qualitative facts (bearing words, per-direction clearance) in Python. Early signal: "standing next to the bed" scored reached = 0.27, so the reached instruction and a code-computed goal distance will need to agree on what "arrived" means.
  • Call volume: ~180 API calls per case at a 1s tick over a 180s episode.

AI assistance

Claude (Claude Code) wrote the scaffold, generator, tests and PR body under user-directed design review; contracts and architecture decided by the user.

Checklist

  • I have read and approved the CLA.

Barebones scaffold for evaluating TypeSafe's System One model as a
closed-loop navigation policy in DimSim. Skeleton for review; the model
call path is untested against the live API.

The agent calls no skill. Each tick it assembles a world state, asks Jev
for one world-frame unit step plus a termination flag, rotates the pick
into the body frame and publishes a single Twist.

Two contracts are isolated for iteration: WorldState (input) and
STEP_CRITERIA/build_questions (output). Obstacles, room bounds and the
goal come from a static scene JSON; the robot pose comes live off /odom.

Notes:
- Publishes /nav_cmd_vel, not /cmd_vel: MovementManager owns cmd_vel in
  the go2 stack, so this drops into the existing arbitration slot.
- Blueprint carries mcp-server but no skill container, so MCP comes up
  with zero tools exposed. Sim.start() requires MCP, so it cannot be
  dropped entirely.
- observe() is fully wired; no stubs remain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

❌ 4 Tests Failed:

Tests completed Failed Passed Skipped
5444 4 5440 217
View the top 3 failed test(s) by shortest run time
::dimos.evals.agents.test_typesafe_policy
Stack Traces | 0s run time
ImportError while importing test module '.../evals/agents/test_typesafe_policy.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
.../evals/agents/test_typesafe_policy.py:26: in <module>
    from typesafe_sdk import Choice, ChoiceAnswer, Noul
E   ModuleNotFoundError: No module named 'typesafe_sdk'
dimos.evals.test_evals::test_suites_and_agents_importable
Stack Traces | 0.207s run time
def test_suites_and_agents_importable() -> None:
        """Modules construct without data or network (lambdas stay lazy)."""
    
        for module in (examples, go2_smoke, go2_vqa, dimsim_house, dimsim_pointcloud_mapping):
            assert module.SUITE, module.__name__
        agents = list_agents()
>       assert {m.rsplit(".", 1)[1] for m in agents} == {
            "question_answer",
            "blind",
            "mcp_client_adapter",
            "pi",
            "dimcode",
        }
E       AssertionError: assert {'blind', 'di...esafe_policy'} == {'blind', 'di...stion_answer'}
E         
E         Extra items in the left set:
E         #x1B[0m#x1B[33m'#x1B[39;49;00m#x1B[33mtypesafe_policy#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
E         
E         Full diff:
E         #x1B[0m#x1B[90m #x1B[39;49;00m {#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'blind',#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'dimcode',#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'mcp_client_adapter',#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'pi',#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'question_answer',#x1B[90m#x1B[39;49;00m
E         #x1B[92m+     'typesafe_policy',#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m }#x1B[90m#x1B[39;49;00m

agents     = ['dimos.evals.agents.blind', 'dimos.evals.agents.dimcode', 'dimos.evals.agents.mcp_client_adapter', 'dimos.evals.agents.pi', 'dimos.evals.agents.question_answer', 'dimos.evals.agents.typesafe_policy']
module     = <module 'dimos.evals.suites.dimsim_pointcloud_mapping' from '.../evals/suites/dimsim_pointcloud_mapping.py'>

dimos/evals/test_evals.py:707: AssertionError
dimos.codebase_checks.test_no_sections::test_no_section_markers
Stack Traces | 0.91s run time
def test_no_section_markers():
        """
        Fail if any file contains section-style comment markers.
    
        If a file is too complicated to be understood without sections, then the
        sections should be files. We don't need "subfiles".
        """
        violations = find_section_markers()
        if violations:
            report_lines = [
                f"Found {len(violations)} section marker(s). "
                "If a file is too complicated to be understood without sections, "
                'then the sections should be files. We don\'t need "subfiles".',
                "",
            ]
            for path, lineno, text in violations:
                report_lines.append(f"  {path}:{lineno}: {text.strip()}")
>           raise AssertionError("\n".join(report_lines))
E           AssertionError: Found 8 section marker(s). If a file is too complicated to be understood without sections, then the sections should be files. We don't need "subfiles".
E           
E             .../evals/agents/test_typesafe_policy.py:107: # --- scene loading ---------------------------------------------------------------
E             .../evals/agents/test_typesafe_policy.py:159: # --- controller ------------------------------------------------------------------
E             .../evals/agents/test_typesafe_policy.py:196: # --- preflight -------------------------------------------------------------------
E             .../evals/agents/test_typesafe_policy.py:218: # --- goal from the instruction ---------------------------------------------------
E             .../evals/agents/typesafe_policy.py:50: # --- input contract: what Jev sees each tick ---------------------------------
E             .../evals/agents/typesafe_policy.py:87: # --- output contract: what Jev answers ---------------------------------------
E             .../evals/agents/typesafe_policy.py:129: # --- scene file --------------------------------------------------------------
E             .../evals/agents/typesafe_policy.py:280: # --- the agent ---------------------------------------------------------------

lineno     = 280
path       = '.../evals/agents/typesafe_policy.py'
report_lines = ['Found 8 section marker(s). If a file is too complicated to be understood without sections, then the sections should ...test_typesafe_policy.py:218: # --- goal from the instruction ---------------------------------------------------', ...]
text       = '# --- the agent ---------------------------------------------------------------'
violations = [('.../evals/agents/test_typesafe_policy.py', 107, '# --- scene loading --------------------------------------------...gents/typesafe_policy.py', 87, '# --- output contract: what Jev answers ---------------------------------------'), ...]

dimos/codebase_checks/test_no_sections.py:145: AssertionError
dimos.evals.test_evals::test_sim_launches_base_blueprints_and_agent_modules_in_order
Stack Traces | 4.03s run time
dataset = '.../popen-gw1/test_sim_launches_base_bluepri0/tiny.db'
mocker = <pytest_mock.plugin.MockerFixture object at 0xffb116eb3fe0>

    def test_sim_launches_base_blueprints_and_agent_modules_in_order(
        dataset: str, mocker: MockerFixture
    ) -> None:
        proc = mocker.patch("dimos.evals.environments.sim.DimosCliCall").return_value
        adapter = mocker.patch("dimos.evals.environments.sim.McpAdapter")
        adapter.return_value.wait_for_ready.return_value = True
        sim_client = mocker.patch("dimos.evals.environments.dimsim.DimSimClient")
        setup = mocker.Mock()
        env = _sim(
            scene="empty",
            launch_timeout_s=4.0,
            setup=setup,
            disable=("wavefront-frontier-explorer", "patrolling-module"),
        )
        mocker.patch.object(env, "_wait_recording", return_value=Path(dataset))
    
        try:
>           env.start(("mcp-client", "speak-skill"))

adapter    = <MagicMock name='McpAdapter' id='281136058100064'>
dataset    = '.../popen-gw1/test_sim_launches_base_bluepri0/tiny.db'
env        = <dimos.evals.environments.dimsim.DimSimEnvironment object at 0xffb116e5adb0>
mocker     = <pytest_mock.plugin.MockerFixture object at 0xffb116eb3fe0>
proc       = <MagicMock name='DimosCliCall()' id='281136058096368'>
setup      = <Mock id='281136058446864'>
sim_client = <MagicMock name='DimSimClient' id='281136058442976'>

dimos/evals/test_evals.py:293: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../evals/environments/sim.py:155: in start
    artifacts.update(self.prepare_recording(self._recording, path, deadline))
        SqliteStore = <class 'dimos.memory.store.sqlite.SqliteStore'>
        adapter    = <MagicMock name='McpAdapter()' id='281136052386368'>
        artifacts  = {'recording': PosixPath('.../popen-gw1/test_sim_launches_base_bluepri0/tiny.db')}
        bridge     = []
        deadline   = 552.908788025
        disabled   = ['--disable', 'wavefront-frontier-explorer', '--disable', 'patrolling-module']
        mcp_url    = 'http://localhost:22683/mcp'
        modules    = ('mcp-client', 'speak-skill')
        path       = PosixPath('.../popen-gw1/test_sim_launches_base_bluepri0/tiny.db')
        pid        = <MagicMock name='DimosCliCall().process.pid' id='281136058637376'>
        proc       = <MagicMock name='DimosCliCall()' id='281136058096368'>
        process    = <MagicMock name='DimosCliCall().process' id='281136052381904'>
        self       = <dimos.evals.environments.dimsim.DimSimEnvironment object at 0xffb116e5adb0>
.../evals/environments/dimsim.py:62: in prepare_recording
    self.wait_ready(recording, deadline=deadline)
        deadline   = 552.908788025
        path       = PosixPath('.../popen-gw1/test_sim_launches_base_bluepri0/tiny.db')
        recording  = <dimos.memory.store.sqlite.SqliteStore object at 0xffb11689ef00>
        self       = <dimos.evals.environments.dimsim.DimSimEnvironment object at 0xffb116e5adb0>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <dimos.evals.environments.dimsim.DimSimEnvironment object at 0xffb116e5adb0>
recording = <dimos.memory.store.sqlite.SqliteStore object at 0xffb11689ef00>

    def wait_ready(self, recording: Store, *, deadline: float) -> None:
        # The Observation's ts is the recorder's wall clock, which is set even
        # when the sim leaves PoseStamped.ts at zero.
        while time.monotonic() < deadline:
            try:
                if "odom" in recording.streams:
                    latest = recording.streams.odom.last()
                    if time.time() - latest.ts < self.config.fresh_odom_s:
                        return
            except LookupError:
                pass
            time.sleep(0.1)
>       raise TimeoutError("DimSim did not publish fresh odometry before the launch deadline")
E       TimeoutError: DimSim did not publish fresh odometry before the launch deadline

deadline   = 552.908788025
latest     = Observation(id=5, ts=1004.0, data_type=<class 'dimos.msgs.geometry_msgs.PoseStamped.PoseStamped'>, pose_tuple=None, ta...n Backend._make_loader.<locals>.loader at 0xffb1168e7560>, _data_lock=<unlocked _thread.lock object at 0xffb116e4eb00>)
recording  = <dimos.memory.store.sqlite.SqliteStore object at 0xffb11689ef00>
self       = <dimos.evals.environments.dimsim.DimSimEnvironment object at 0xffb116e5adb0>

.../evals/environments/dimsim.py:77: TimeoutError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

ruthwikdasyam and others added 8 commits September 17, 2026 16:42
… API

Add a `typesafe` extra (typesafe-sdk>=0.6.0) with an exclude-newer
override, TYPESAFE_API_KEY in default.env and the conftest passthrough.

Fix four bugs found by inspecting the installed SDK rather than the docs:
- config.model was never passed to the client; it now sets the client
  default and the recorded model_name comes from response.model
- default model is jev-latest, not jev-1.13
- Usage fields are input_tokens/output_tokens, so token metrics were
  silently always zero
- request timeout is now explicit (SDK default is 10s)

Add offline tests for the body-frame rotation, confidence gate, scene
loading and preflight rejection. 12 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the invented apartment_bed.json with apartment_couch.json built
from misc/DimSim/scenes/apartment/objects/manifest.json: real object
centres for 30 floor-standing obstacles, converted Three.js Y-up -> ROS
world frame. Footprints are a uniform 0.5 m half-extent (the manifest has
no extents; they live in the GLBs) — approximate, tunable per scene.

Target the couch instead of the bed so the arrival band (2.0 m) matches
the DimSim-native go-to-couch rubric. The docs' BED constant did not
match this scene's manifest anyway.

A test pins the committed JSON to the generator output so it cannot
drift from the manifest silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sim.start() only waits for MCP, but the headless browser boots long after
that on a cold start (Deno + Chromium download, Vite build): the first
live run handed TypeSafePolicy a robot that did not exist yet and it gave
up on /odom after 10s, 0.7s before the engine came up.

DimSimEnvironment now overrides prepare_recording() to block until a
fresh odom row lands in the recording, mirroring HabitatEnvironment.
Freshness uses the Observation's recorder timestamp, which is set even
if the sim leaves PoseStamped.ts at zero. The agent's own pose wait is
lifted from a hard-coded 10s to a 60s config backstop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First live run: Jev chose the correct direction 60/60 ticks, every Twist
reached the sim bridge, and the robot moved 0.00 m. DimSim's "holonomic"
ground model only integrates linear.x and angular.z (physics.ts:74);
linear.y is stored and ignored, and the agent commanded pure strafe.

Jev's compass contract is unchanged. twist() now treats the chosen world
direction as a target heading: rotate in place until within align_deg,
then drive forward with a proportional yaw correction. The controller is
re-evaluated against the live pose every control_dt (100 ms) for the
whole tick, which also beats the bridge's 500 ms cmd_vel deadman that
would otherwise zero a once-per-second command half the time.

Speed drops to 0.2 m/s (0.6 m/s after the sim's 3x scale) so one tick
is ~0.6 m, not 1.2 m, against 0.5 m obstacle boxes.

Grader: a robot that never moved scored 0.3 because ~1e-9 m of odom
jitter satisfied travelled > 0 and earned full directness. Directness now
needs at least 0.1 m of travel; regression test added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the manifest-derived scene (real centres, invented 1 m squares)
with DimSim's own annotation snapshot from PR #4208: 107 objects and
walls with real extents, already in the ROS world frame odometry uses.
Copied verbatim (misc/DimSim/scenes/apartment/object_detections.json at
7fd0e2f); the live SceneClient.get_object_detections() emits the same
schema, so going live later is a one-line change in run().

load_scene() now reads that schema and filters geometrically instead of
by word list: boxes whose bottom clears the robot's 0.74 m capsule top
are overhead (door headers, wall cabinets, the TV); footprints contained
in another kept footprint are clutter (books on shelves, plates on
carts, bedding, a chair under its table). 107 -> 15 walls + 33 objects.
Room bounds come from the outer walls.

The second live run ended pressed against the real couch, 0.69 m before
my approximate box even began, and scored 0.54 because arrival measured
to the couch's centre, which is inside the couch. The grader now
measures to the goal box edge; that run re-scores 0.85 (test added).
The state gains goal_box so "reached" has something concrete to mean.

Step criteria drop compass words: the scene's wall labels put
"wall-east" at +y, which contradicts "+y = north" in the same prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…truction

Add typesafe_nav_bathtub. The bathtub is in the bathroom: from spawn the
robot must leave through the main doorway at (0, 0) and enter through the
bathroom door near (-2.5, 1), moving away from the goal twice. Greedy
bearing cannot do this, so the suite can now separate a policy that
reads the walls from one that does not.

The goal was an agent config (goal_label="sectional"), so two cases with
different goals could not share a run. It is now a case property: the
agent derives it from the instruction as the longest word that occurs in
a scene label ("sectional", "bathtub"), with goal_label kept as an
override. Code does the lookup, not Jev; letting the model pick the goal
would be a separate Choice question and a separate eval. A test checks
that every case's instruction resolves to the same box its grader uses.

The couch instruction becomes "navigate to the sectional couch" so it
names the object by a label word. Each case now builds its own
DimSimEnvironment; cases are tagged "couch" / "bathtub" for --tags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The five options become body-frame (linear.x, angular.z) pairs: forward,
backward, turn left, turn right, stop. twist() scales the pick by speed
and turn_rate and that is the command; no atan2, no alignment band, no
yaw correction, no pose needed for control. "Turn right" and "back" are
now literal choices on Jev's menu rather than something code derives.

The trade is explicit: Jev must now reason about its own heading
(robot_yaw_deg is in the state; the instruction says 0 = +x, 90 = +y),
which is the kind of numeric judgment it is weakest at. The bathtub
run showed it cannot read wall geometry either way, so simpler wins
until that is solved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On the body-frame contract Jev's confidence sits around 0.31, so the
0.35 gate zeroed 49 of 60 decisions in the last run and became the
de-facto controller: we were measuring the threshold, not the model.
The SDK's choice is the argmax of probabilities (verified 60/60 on that
run), and it is now applied as is. Only a "0,0" pick, reached > 0.8,
max_ticks or the timeout stops the robot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant