File size: 12,738 Bytes
5b063c2
 
 
 
 
 
 
 
536e671
 
 
 
 
 
 
5b063c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536e671
5b063c2
 
536e671
 
 
 
 
 
 
 
 
5b063c2
536e671
 
 
 
 
5b063c2
 
536e671
5b063c2
 
536e671
 
 
 
5b063c2
536e671
5b063c2
 
536e671
5b063c2
 
 
536e671
 
 
 
 
 
5b063c2
 
 
536e671
 
 
 
 
 
 
 
 
 
5b063c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536e671
5b063c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536e671
5b063c2
536e671
5b063c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536e671
5b063c2
 
 
 
536e671
 
 
 
 
 
 
 
 
8501103
536e671
 
 
 
 
 
5b063c2
536e671
 
5b063c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536e671
 
 
5b063c2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
"""RosDiff's intelligence & routing layer: which model, which simulator, and what it will cost.

Every generation goes through `plan_scene` / `plan_code`, which look at the request (and what retrieval found
for it), rate its complexity, and choose the cheapest model tier that should handle it reliably. When a draft
fails validation, `model_for_attempt` escalates to the next tier for the repair, so hard tasks end up on the
strongest model without anyone picking it. Users never see a model picker; the plan's tier and reason are shown
as information.

There are two registries, one for scenes and one for code, of capable low-cost Chinese open-weight models on
OpenRouter chosen by benchmark and price rather than by release date (see README for sources). Either can be
replaced with SCENE_MODELS / CODE_MODELS (JSON) without code changes.

Debugging: the first repair after a failed check goes to a free model through OpenCode Zen (DEBUG_MODEL, default
opencode/deepseek-v4-flash-free), never through OpenRouter. If that call fails (rate limit, model withdrawn,
OpenCode missing) the same attempt falls back to the next paid tier, so a free model can never cost a repair.

Simulators: RosDiff scenes are MuJoCo (MJCF) scenes, so the routing chooses between what can run them today:
MuJoCo (one run with video), MuJoCo Warp (many randomized copies on the GPU) and Isaac Sim (a photoreal RTX
render of the same motion). Other simulators are listed as not available yet rather than pretended.
"""

from __future__ import annotations

import json
import os
import re
from dataclasses import asdict, dataclass, field

TIERS = ("low", "mid", "high")


@dataclass(frozen=True)
class ModelSpec:
    id: str  # OpenRouter model id
    tier: str  # low | mid | high
    provider: str
    input_usd_per_m: float
    output_usd_per_m: float
    note: str = ""
    free: bool = False  # served free through OpenCode Zen: recorded, never billed


# OpenRouter list prices (USD per million tokens, Sept 2026) and the benchmark each was chosen on.
CODE_REGISTRY = (
    ModelSpec("deepseek/deepseek-v4-flash", "low", "DeepSeek", 0.089, 0.177, "79.0% SWE-bench Verified; very cheap"),
    ModelSpec("deepseek/deepseek-v4-pro", "mid", "DeepSeek", 0.435, 0.87, "80.6% SWE-bench Verified"),
    ModelSpec(
        "z-ai/glm-5.2", "high", "Zhipu AI (Z.ai)", 0.49, 1.54, "62.1% SWE-bench Pro, best open model on hard code"
    ),
)
SCENE_REGISTRY = (
    ModelSpec(
        "deepseek/deepseek-v4-flash", "low", "DeepSeek", 0.089, 0.177, "listed for completeness; scenes start at mid"
    ),
    ModelSpec("z-ai/glm-5.2", "mid", "Zhipu AI (Z.ai)", 0.49, 1.54, "within a point of Claude Opus 4.8 on FrontierSWE"),
    ModelSpec(
        "moonshotai/kimi-k2.6", "high", "Moonshot AI", 0.74, 3.49, "80.2% SWE-bench Verified; top open-weight AA index"
    ),
)
DEFAULT_REGISTRY = CODE_REGISTRY  # kept for callers that don't say which task


def load_registry(task: str = "code") -> tuple[ModelSpec, ...]:
    """SCENE_MODELS / CODE_MODELS (or MODEL_REGISTRY for both) = '[{"id": ..., "tier": ..., ...}, ...]'."""
    raw = os.environ.get("SCENE_MODELS" if task == "scene" else "CODE_MODELS", "").strip()
    raw = raw or os.environ.get("MODEL_REGISTRY", "").strip()
    if not raw:
        return SCENE_REGISTRY if task == "scene" else CODE_REGISTRY
    specs = tuple(ModelSpec(**m) for m in json.loads(raw))
    if {s.tier for s in specs} != set(TIERS):
        raise ValueError("a model registry needs one model for each tier: low, mid, high")
    return specs


def all_models() -> dict[str, ModelSpec]:
    return {m.id: m for task in ("code", "scene") for m in load_registry(task)}


def model_for_tier(tier: str, registry: tuple[ModelSpec, ...] | None = None, task: str = "code") -> ModelSpec:
    registry = registry or load_registry(task)
    return next(m for m in registry if m.tier == tier)


def free_debugger(settings) -> ModelSpec | None:
    if not getattr(settings, "free_debug", False) or not settings.debug_model:
        return None
    return ModelSpec(settings.debug_model, "free", "OpenCode Zen", 0.0, 0.0, "free debugging model", free=True)


def is_free(model_id: str | None) -> bool:
    return bool(model_id) and model_id.startswith("opencode/")


def escalate(tier: str, steps: int = 1) -> str:
    return TIERS[min(TIERS.index(tier) + steps, len(TIERS) - 1)]


# --------------------------------------------------------------------------- complexity

_CODE_COMPLEX = (
    "nav2", "navigation stack", "moveit", "slam", "costmap", "planner", "behavior tree", "behaviour tree",
    "state machine", "lifecycle", "ros2_control", "hardware interface", "controller manager", "action server",
    "perception", "point cloud", "pointcloud", "kalman", "ekf", "localization", "localisation", "multi-robot",
    "fleet", "autonomous", "inverse kinematics", "trajectory", "mpc", "reinforcement",
)  # fmt: skip
_CODE_MEDIUM = (
    "service", "action", "parameter", "launch", "timer", "tf", "transform", "filter", "odometry", "odom",
    "lidar", "laser", "imu", "camera", "image", "twist", "subscriber", "publisher", "client", "path", "c++",
)  # fmt: skip
_SCENE_COMPLEX = (
    "maze", "warehouse", "rough terrain", "heightfield", "uneven", "hinge", "door that opens", "drawer", "joint",
    "articulated", "pendulum", "tendon", "cloth", "rope", "multiple robots", "two robots", "conveyor", "stairs with",
    "spiral", "ramp and stairs",
)  # fmt: skip
_SCENE_MEDIUM = (
    "stairs", "steps", "ramp", "slope", "table", "shelf", "wall", "doorway", "obstacle", "boxes", "cubes", "ball",
    "friction", "terrain", "tray",
)  # fmt: skip


def _count(text: str, words: tuple[str, ...]) -> list[str]:
    t = text.lower()
    return [w for w in words if w in t]


@dataclass
class Plan:
    task: str  # scene | code
    complexity: str  # simple | medium | complex
    tier: str
    model: str
    reason: str
    category: str  # simulation_ai | coding (repairs are booked as debugging)
    estimate_inr: tuple[float, float] = (0.0, 0.0)
    simulator: str | None = None
    simulator_reason: str | None = None
    signals: list[str] = field(default_factory=list)
    debugger: str | None = None  # the free model tried first on the first repair

    def to_dict(self) -> dict:
        d = asdict(self)
        d["estimate_inr"] = list(self.estimate_inr)
        return d


def _level(score: int) -> str:
    return "complex" if score >= 4 else "medium" if score >= 2 else "simple"


def _tier_for(level: str) -> str:
    return {"simple": "low", "medium": "mid", "complex": "high"}[level]


def plan_scene(description: str, robot: str | None, settings, n_numbers: int | None = None) -> Plan:
    complex_hits = _count(description, _SCENE_COMPLEX)
    medium_hits = _count(description, _SCENE_MEDIUM)
    objects = n_numbers if n_numbers is not None else len(re.findall(r"\d+(?:\.\d+)?", description))
    # everyday parts add up to 'medium' at most; only genuinely hard ones push a task to the top tier
    score = 3 * len(complex_hits) + min(len(medium_hits), 3) + (objects >= 6) + (len(description) > 400)
    level = _level(score)
    # Scenes must be spatially exact; the cheapest tier is only used for very small ones.
    tier = max(_tier_for(level), "mid", key=TIERS.index)
    signals = complex_hits + medium_hits
    reason = {
        "simple": "a small scene",
        "medium": "a scene with several objects to place exactly",
        "complex": "a scene with articulated or intricate parts",
    }[level]
    sim, sim_reason = choose_simulator(description)
    plan = Plan("scene", level, tier, model_for_tier(tier, task="scene").id, reason, "simulation_ai", signals=signals)
    plan.simulator, plan.simulator_reason = sim, sim_reason
    plan.debugger = (d := free_debugger(settings)) and d.id
    plan.estimate_inr = estimate_inr(plan, settings)
    return plan


def plan_code(request: str, distro: str, language: str, settings, n_interfaces: int = 0) -> Plan:
    complex_hits = _count(request, _CODE_COMPLEX)
    medium_hits = _count(request, _CODE_MEDIUM)
    score = 3 * len(complex_hits) + min(len(medium_hits), 3) + (1 if language == "cpp" else 0)
    score += 1 if n_interfaces >= 3 else 0
    score += 1 if len(request) > 500 else 0
    level = _level(score)
    tier = _tier_for(level)
    reason = {
        "simple": "a small, self-contained ROS 2 node",
        "medium": "a ROS 2 package with several parts to connect",
        "complex": "a ROS 2 system with navigation, control or planning",
    }[level]
    if language == "cpp" and level != "complex":
        reason += " in C++"
    plan = Plan("code", level, tier, model_for_tier(tier).id, reason, "coding", signals=complex_hits + medium_hits)
    plan.debugger = (d := free_debugger(settings)) and d.id
    plan.estimate_inr = estimate_inr(plan, settings)
    return plan


def candidates_for_attempt(plan: Plan, attempt: int) -> list[ModelSpec]:
    """Models to try for one attempt, in order (the next is only used if a call fails outright).

    Attempt 0: the planned tier. Attempt 1: the free debugger, falling back to one tier up. Later repairs keep
    going up one tier each. Without a free debugger every repair simply goes one tier up."""
    registry = load_registry(plan.task)
    if attempt == 0:
        return [model_for_tier(plan.tier, registry)]
    if plan.debugger:
        paid = model_for_tier(escalate(plan.tier, attempt), registry)
        if attempt == 1:
            return [ModelSpec(plan.debugger, "free", "OpenCode Zen", 0.0, 0.0, free=True), paid]
        return [paid]
    return [model_for_tier(escalate(plan.tier, attempt), registry)]


def model_for_attempt(plan: Plan, attempt: int) -> ModelSpec:
    """The paid model an attempt ends on in the worst case."""
    return candidates_for_attempt(plan, attempt)[-1]


# --------------------------------------------------------------------------- simulators

SIMULATORS = {
    "mujoco": {"label": "MuJoCo", "what": "one run with video and 3D replay", "available": True},
    "mujoco_warp": {"label": "MuJoCo Warp", "what": "64 randomized copies at once on the GPU", "available": True},
    "isaac_sim": {"label": "Isaac Sim", "what": "photoreal RTX render of the same motion", "available": True},
    "gazebo": {"label": "Gazebo", "what": "not available in RosDiff yet", "available": False},
    "webots": {"label": "Webots", "what": "not available in RosDiff yet", "available": False},
    "pybullet": {"label": "PyBullet", "what": "not available in RosDiff yet", "available": False},
}

_WANTS_RENDER = ("photoreal", "realistic render", "camera image", "rgb", "lighting", "vision", "synthetic image",
                 "rendered image", "ray tracing", "ray-traced")  # fmt: skip
_WANTS_MANY = ("robust", "random", "variation", "many times", "stress", "domain randomization", "monte carlo",
               "reinforcement", "train a policy", "statistics", "how often")  # fmt: skip


def choose_simulator(text: str) -> tuple[str, str]:
    if hits := _count(text, _WANTS_MANY):
        return "mujoco_warp", f"you asked about {hits[0]}: many randomized copies show how often it works"
    if hits := _count(text, _WANTS_RENDER):
        return "isaac_sim", f"you mentioned {hits[0]}: MuJoCo simulates, Isaac Sim renders it photoreal"
    return "mujoco", "contact-rich rigid-body physics with the real robot model, fast and precise"


# --------------------------------------------------------------------------- cost

# Typical tokens per model call, measured from RosDiff's prompts (context + robot facts / interface definitions).
TOKENS = {
    ("scene", "direct"): (16_000, 3_500),
    ("code", "direct"): (20_000, 6_000),
    ("code", "opencode"): (60_000, 9_000),  # OpenCode's agent loop re-reads context across several steps
}


def call_cost_usd(model: ModelSpec, input_tokens: int, output_tokens: int) -> float:
    return (input_tokens * model.input_usd_per_m + output_tokens * model.output_usd_per_m) / 1e6


def estimate_inr(plan: Plan, settings) -> tuple[float, float]:
    """(likely, worst case): one call on the planned tier vs. every attempt, escalating."""
    mode = "opencode" if plan.task == "code" and settings.codegen_backend == "opencode" else "direct"
    tin, tout = TOKENS[(plan.task, mode)]
    attempts = settings.mjcf_max_attempts if plan.task == "scene" else settings.code_max_attempts
    first = call_cost_usd(model_for_attempt(plan, 0), tin, tout)
    # worst case: every attempt runs, and the free debugger fails so its paid fallback is billed
    worst = sum(call_cost_usd(model_for_attempt(plan, i), tin * (1 + i // 2), tout) for i in range(attempts))
    return round(first * settings.usd_inr, 2), round(worst * settings.usd_inr, 2)