Env NodeSet Template (Gym-like Interface)
The interface contract every interactive (MDP) env nodeset should implement so simulators expose one consistent surface. It keeps Gymnasium's four verbs — reset / step / close / observe — but adopts a pull-style perception model that fits VLN/EQA: step is a pure transition that returns no observation, and observe is the on-demand channel a method uses to fetch exactly the information it wants. Action and observation spaces are node-naming axes, not runtime config: each env exposes a step_<actionspace> family and an observe_<obsspace> family, all sharing fixed return contracts.
All five MDP env nodesets implement this contract (since 2026-06-10). Which env supports which spaces is registry information, not contract — see the capability matrix in the env catalog. The 2026-06 migration record (old→new node maps, pre-refactor audit) lives in this page's git history.
1. Interface taxonomy — where this template applies
env/ currently holds three interface types, separated by two axes: does the nodeset have an episode lifecycle, and does it have an action loop (do the agent's actions change what it observes next)? This template (§3–§5) specifies only the first type; the other two are listed so a new env nodeset gets classified deliberately instead of inheriting whatever shape its upstream code had.
| Type | Episode lifecycle | Action loop | Verb surface | Members |
|---|---|---|---|---|
| Interactive MDP (gym-like) | yes — reset / evaluate | closed — step mutates world state; future observations change | reset / step_* / observe_* / evaluate (+ suite-level close) | env_habitat, env_mp3d, env_hmeqa, env_simpler, env_libero |
| Replay benchmark (scored dataset) | yes — reset / episode_info / emit_metrics | none — data is pre-recorded; the agent's output feeds only the scorer | reset / episode_info / sample_frames / parse_score / emit_metrics | env_openeqa_em |
| Stateless service | none | none — pure request→response | per-capability tool verbs (locate_2d / locate_3d / segment) | model_detany3d (lives in model/, listed here for taxonomy only) |
Three boundary rules fall out of the table:
- A replay benchmark keeps the lifecycle half of the env contract and drops the interaction half.
env_openeqa_emhas reset, episode iteration, metric emission, and an env panel with episode selection + play/pause/stop — everything exceptstep. It is exempt from this template, not an outlier of it (its nodes deliberately kept their pre-gym names — renaming would have broken three verified graphs for a purely cosmetic change). - A stateless service is not an env interface type at all —
locate_2d / segmentis the same request→response shape as themodel/servers (vlm_prismatic,model_sam). DetAny3D sat inenv/by historical placement until 2026-07-04, when it moved tomodel/model_detany3dper the layout standard's membership test (its three verbs are generic detection primitives — the label space comes from the caller, not from ToolEQA). No lifecycle-less nodeset lives inenv/anymore; don't add new ones. - Variation inside the MDP type is parameterization, not a new type. Action-space suffixes (
step_discretevsstep_continuous), observation-space suffixes, andenv_libero's privileged GT observation space (observe_objects) all ride the same four-verb contract; none of them justifies a new row above.
The two-axis matrix has one empty cell — action loop without episode lifecycle. That is the continuing / real-time environment (a physical robot, a live world): no reset (the world cannot be rewound), no episode boundary, and time does not pause between step and observe — which breaks gym's lockstep assumption. If the platform reaches real-robot deployment, that type arrives as a contract change, not as one more nodeset under this template. Other conceivable shapes — trajectory replay of recorded (obs, action) demonstrations for offline training, turn-based multi-agent — become relevant only if the platform's scope grows to cover them; no verb surface is reserved in advance.
2. Why, and why not vanilla gym
Env nodesets are pure tools (decoupled-nodeset principle): observe / step / episode-control surfaces, no method reasoning. Before this contract (pre-2026-06), each env had grown its own surface and they didn't line up — step returns differed per env, reward was exposed nowhere, done had no terminated/truncated split, and observe/step each went by 3–5 different names. The contract exists so a method written against one env finds the same verbs, the same port shapes, on every other env.
Vanilla gym fixes naming but assumes a model we don't have: gym observations are env-pushed and fixed-schema (declared by observation_space, returned every step), so they ride on step. Ours are agent-pulled and parametric — a method asks for a panorama (n_views=12), a single egocentric view, or the navigable-viewpoint graph, on demand, and panoramas are expensive enough that you must not render one every step. That is gym's render() role, not its observation. So we split transition from perception.
3. The model: four verbs, pull perception
| Verb | Kind | Returns | Notes |
|---|---|---|---|
reset | lifecycle | episode metadata only — no observation | First frame is fetched by calling an observe node after reset |
step_<x> | transition | control signals only — reward / terminated / truncated / info | A family, one node per action space x; no observation |
observe_<y> | perception (pull) | the observation payload for obs space y | A family, one node per observation space y; called on demand, idempotent |
close | lifecycle | — | Suite-level, owned by env panel/manager — not a graph node. SIMPLER must never close per-episode (SAPIEN GC segfault) |
A typical agent loop becomes: observe_* → reason → step_* → observe_* → …, with the loop's continue-condition reading terminated / truncated off the last step. The method pulls exactly the modalities it needs each iteration, and pays for expensive observations (panorama) only when it asks.
Episode selection (split / episode_index / play) stays env panel-owned — the gym gym.make(id) analog, not a node. A thin evaluate metric-sink node is kept outside the four verbs (decision E, §7); it fires once in the after-loop band (triggered by the iterOut's final_stop) and reads the env's terminal metrics.
4. Spaces as naming axes
Action and observation spaces are not graph nodes and not runtime config — they are a fixed vocabulary that names the step_* / observe_* node families and types their ports. Each env declares which spaces it supports and exposes the matching nodes. Cross-env unification = this vocabulary is shared and each suffix carries the same port contract everywhere, so a method that wants waypoint nav looks for *__step_waypoint and gets the same shape on every env that has it.
4.1 Action spaces → step_<x>
| Space | Action input | Meaning |
|---|---|---|
discrete | action:ACTION (int 0–3) | Fixed discrete set: FORWARD / LEFT / RIGHT / STOP |
waypoint | viewpoint_id:TEXT | Navigate to a navigable viewpoint id (discrete graph) |
pose | target:POSE | Navigate / teleport to a target SE(3) pose |
hightolow | angle, distance | Macro (turn angle, advance distance) expanded to low-level actions |
continuous | action:TEXT (JSON: 7-vec or list of 7-vecs) | Continuous control delta (arm); accepts a single step or a runtime-variable K-step chunk. The wire-type registry has no VECTOR type — TEXT/JSON is the carrier (env_simpler, env_libero) |
ee_pose | action:TEXT (JSON 8-vec [x,y,z,qw,qx,qy,qz,grip] or {"pose": […], "hold_steps": N}) | Drive the end-effector to an absolute world-frame pose + gripper via closed-loop OSC convergence (manipulation). Distinct from pose: carries a gripper bit + optional settle ticks, so it is not target:POSE-shaped. Added 2026-07-03 when env_libero__step_pose was renamed step_ee_pose — its shape never matched the nav pose row |
4.2 Observation spaces → observe_<y>
| Space | Output ports | Meaning |
|---|---|---|
egocentric | rgb:IMAGE, depth:DEPTH, pose:POSE, intrinsics:ANY | Single first-person view; pose + intrinsics folded in here (no standalone localize/cam_intrinsics nodes) |
panorama | views:ANY, directions:TEXT, n_views:ANY | Multi-view / stitched panorama (parametric, expensive) |
navigable | viewpoint_id:TEXT, navigable:ANY, headings:ANY, position:ANY | Current viewpoint + reachable neighbors + headings (graph nav) |
objects | snapshot:ANY, error:TEXT | Privileged GT scene snapshot — object names, point clouds, EE pose, bounds in one dict, so GT planners read one atomic state |
frames | frames:ANY | Episodic-memory frame sequence (non-MDP envs) |
Reuse an existing suffix before inventing one — a new space name is a vocabulary change that every future env inherits. Which env implements which suffix is tracked in the capability matrix, not here.
5. Return contracts
5.1 reset
Begins an episode and emits episode-level metadata only (the gym info at episode start). These ports are env-specific because the metadata is — VLN carries instruction, EQA carries question — but the rule is fixed: no observation, no rgb/depth. The first frame comes from a following observe_* call.
Reset is an idempotent ensure-live: if the previous episode is done (a canvas re-run — reset fires once in the pre-loop band, before any observe), it re-arms an episode in place; a live episode — the batch-eval path, where the runner has just placed a fresh one — is read without disturbance. Episode placement (which episode) stays env panel-owned; reset never chooses, only revives (decision I, §7).
- VLN (habitat / mp3d / simpler):
instruction, episode_id, scene_id - EQA (hmeqa / openeqa):
question, choices/answer_gt, episode_id, scene
5.2 step_<x>
Input varies by action space (§4.1). Output is fixed across the whole family — every step_* on every env emits at least these four control ports:
| Port | Type | Meaning |
|---|---|---|
reward | ANY | Per-step reward, scalar (VLN sparse → 0 each step, payoff at terminal; SIMPLER real) |
terminated | BOOL | MDP terminal: STOP called / goal reached / answer submitted |
truncated | BOOL | Budget / step-limit cutoff |
info | ANY | Per-step diagnostics + terminal metrics (when terminated/truncated) |
The wire-type registry (app/standard/wire_types.py) has no FLOAT or DICT type — ANY is the canonical carrier for scalars and plain dicts. So reward and info ride ANY; terminated/truncated use BOOL.
Env-specific extra outputs may sit alongside the four (mirrored inside info): the manipulation envs (env_simpler, env_libero) add success + step_index convenience taps, and env_libero__step_ee_pose adds converged / final_pose / step_count / error. The four-port contract is a floor, not a ceiling — agents wiring extras accept env coupling, exactly as with reset's env-specific metadata.
5.3 observe_<y>
Input is a bare trigger (perception is a read of current state, idempotent). Output ports are the obs-space payload from §4.2. No control signals — observe never advances the env, and performs no lifecycle action: observing a finished episode returns the terminal frame unchanged, never a fresh episode. Rollover ownership lives in reset (decision I, §7) — the ADR-012-era auto-reset inside observe_* was removed 2026-06-11 because a post-loop re-fire of an observe could silently roll the env into a new episode under evaluate.
5.4 close
Suite-level teardown owned by the env panel/manager, not a graph node. Per-episode close is forbidden for SIMPLER (SAPIEN 2.2.2 GC ordering segfault); mirror the upstream no-close pattern.
6. Checklist: what a new env nodeset provides
The contract above fixes the node surface; this is the fill-in-the-blanks list for shipping a new MDP env nodeset under it.
- Classify first (§1). If there is no action loop, you are building a replay benchmark or a service — this template does not apply.
- Three-layer file shape: a singleton manager owning the simulator handle (single-thread executor if the sim has GL/thread affinity) · tool nodes implementing the verbs · a NodeSet binding (
get_tools/initialize/shutdown). - Nodes:
reset(§5.1) · onestep_<x>per supported action space (§4.1, return contract §5.2) · oneobserve_<y>per obs space (§4.2, §5.3) · a thinevaluatesink. Reuse existing space suffixes before inventing one. - Env panel: placement cascade fields (e.g.
split → episode_index) + the play / pause / stop / reset action quartet; field changes and the reset action emit theepisode_resetsignal carrying the cascade snapshot, while play re-seats the selected episode viaset_episodeand returnsrun_start(a panel response carries one side effect — no shipped env emits a signal from play). See the env-panel design doc. - NodeSet ClassVars:
server_python = conda_env_python("<env>", "<VAR>_PYTHON")for a dedicated conda env ·parallelism = "replicated"for stateful sims ·default_per_step_budget_secmatched to step latency · optionalreplay_parser. - Eval integration:
get_eval_metadata()(splits, episode counts, metrics, step budget) so batch eval can drive the env. - Ship with: an install script
scripts/install/install_<env>.sh(creates the conda env; initializes anythird_party/submodules it needs) and a row in the env catalog tables.
7. Decisions
| # | Decision | Outcome |
|---|---|---|
| A | Observation port shape | Resolved: obs is off step; pulled via per-obs-space observe_* nodes |
| B | Action polymorphism | Resolved: one node per action space (step_* family), not a single polymorphic step |
| C | terminated / truncated split | Adopted: gymnasium two-bool split (STOP = terminated, budget = truncated) |
| D | Expose reward | Adopted: yes, even where VLN reward is 0 every step |
| E | evaluate node | Adopted, role updated 2026-06-11: a thin metric-sink reading the env's terminal state — wired in the after-loop band (iter_out.final_stop → evaluate.trigger), fired exactly once at termination. Per-step telemetry rides step.info → viewer; the verdict never comes from an in-loop evaluate |
| F | openeqa_em exemption | Resolved naturally: no action ⇒ no step node; it is reset + observe_frames |
| G | reset returns obs? | Decided: no — reset emits episode metadata only |
| H | pose / intrinsics nodes | Decided: folded into observe_egocentric, no standalone nodes |
| I | episode rollover ownership | Decided 2026-06-11: reset re-arms a done episode (idempotent ensure-live, §5.1); observe_* is a pure read. Replaces the ADR-012-era (2026-03-29) auto-reset inside habitat's observe nodes — a leftover from the commit that deleted explicit episode management before the env panel existed, and a metric-pollution hazard once evaluate moved to the after-loop band |