Three-Step Nav on AgentCanvas — paper analysis + threestepnav port
Three-Step Nav (A Hierarchical Global-Local Planner for Zero-Shot Vision-and-Language Navigation, arXiv 2604.26946) is a zero-shot LLM agent for continuous VLN (R2R-CE / RxR-CE) that descends directly from Open-Nav — its agent class literally is class Open_Nav. It runs three steps per episode: (1) global — decompose the instruction into sub-instructions + per-sub landmarks; (2) local — a MapGPT-style image navigator picks the next direction and self-reports Completion Estimation: Yes/No; (3) verify — when completion is "Yes", a continue / stay / backtrack / look-around judge advances a sub-instruction pointer. The threestepnav nodeset (single-version monolith) ports the method-side reasoning; it reuses Open-Nav's env_habitat + opennav_waypoint (TRM) substrate wholesale, with the perception FMs consumed as generic wrappers since the TODO #56 extraction (2026-07-04): model_ram__tag_views (RAM Swin-L @ 224, raw | separators) + vlm_spatialbot__caption_views (SpatialBot-3B), fed by the pure threestepnav__select_candidate_views filter.
| Upstream paper | Three-Step Nav (arXiv 2604.26946) — claims zero-shot SOTA on R2R-CE / RxR-CE val_unseen |
|---|---|
| Pinned commit | github.com/ZoeyZheng0/3-step-Nav @ 5cdbdcf |
| Env spaces consumed | env_habitat — reset · observe_panorama (RGB-D ×12) · step_hightolow (angle, distance) · evaluate |
| FM nodes consumed | opennav_waypoint (TRM 12-dir waypoints) · model_ram__tag_views + vlm_spatialbot__caption_views (RAM tags + SpatialBot-3B depth captions; byte-equivalence 10/10 vs the embedded originals) · 4× llmCall |
| Nodes | 14 method nodes · 34-node graph |
| Graph | workspace/graphs/vln/verified/threestepnav_ce.json — promoted from unverified/ 2026-07-03; pins the faithful config (gpt-5 · 1024 px · JPEG) |
| Status vs paper | Reproduced under the paper's own metric at n=25; official stop-gated SR is low by construction. Aligned-stack faithful run 20260703_141242 (gpt-5 · 1024 px · 25 ep rand100): SR* 0.24 vs paper 0.34 — statistically compatible (P(X≤6 | p=0.34) ≈ 0.20) — with OSR 0.28 · NE 6.62 · TL 9.25 ≈ paper 9.18 · nDTW 0.535. Official stop-gated SR 0.08: Three-Step rarely decides to stop, and the paper's 0.34 never required stopping (the two rulers: §4.1). The 100-ep gpt-5-mini scale check corroborates: SR* 0.29 · OSR 0.39 (= paper) · official SR 0.10. Full run table + history: §4. |
| Fidelity verdict | faithful with justified deviations — implementation frozen 2026-07-03. Five cold audit passes (2026-06-22 → 2026-07-03, list in §3E): all prompts byte-identical via the upstream-import harness (82 checks / 0 mismatch, the real upstream code as oracle), waypoint path end-to-end tensor-equal (6/6, real weights), perception byte chains equal, decomp byte-equivalent (13/13). Bucket E is empty; every B/C/D row carries a recorded rationale (the success-metric divergence lives in D under a dual-reporting policy). Graph promoted to vln/verified/ (2026-07-03, user decision) on the aligned-stack result. |
1. Upstream method analysis (Zheng et al. 2026)
The driver is base_il_trainer_llm.BaseVLNCETrainerLLM._eval_llm. The agent (spatialNavigator.Open_Nav) and judge (decision_agent) are pure-Python over an OpenAI client (api.llmClient) and the perception stack (api.spatialClient — RAM + SpatialBot-3B). Per episode:
- Pre-loop (Step 1, global) —
navigator.detect_actions(ACTION_DETECTION) splits the instruction intoaction_list;navigator.detect_landmarks(LANDMARK_DETECTION, one call per sub-instruction) buildslandmark_list. base_il_trainer_llm.py:589-594 - Per step (Step 2, local) — the policy net predicts candidate waypoints (
BinaryDistPredictor_TRM);construct_image_dictsbins them into the 12 directions (30° each);navigator.observe_environmentfuses RAM tags + SpatialBot captions per candidate;navigator.move_to_next_vp_single(MAPGPT_NAVIGATOR, image prompt over candidate tiles) returnsPrediction(next vp) +Completion Estimation. :607-790 - Per step (Step 3, verify) — gated on
completion_estimation == "Yes":decision_agent.make_informed_decision_with_capturereturns continue / stay / backtrack / look-around.continueadvancescurrent_action_idx(resetting per-sub history); at the last sub-instructioncontinue→stop_flag;should_stop_navigationalso stops oncontinue/stayat the last sub-instruction.backtrackreverses the last move. :760-839 - Env action —
{'action': 4, 'action_args': {'angle': radius_dict[next_vp], 'distance': distance_dict[next_vp]}}(HIGHTOLOW). Loop ends atcurrent_step == step_length(step_length = MAX_EPISODE_STEPS). :849-875
1.1 Upstream invariants worth marking
- The judge is gated on completion.
make_informed_decisiononly fires when the navigator self-reportsCompletion Estimation: Yes; otherwise the agent silently stays on the current sub-instruction (no extra LLM call). base_il_trainer_llm.py:760, 841-843 - STOP is decide-to-stop, not an env action. There is no STOP in the action space — the episode ends when the agent completes the last sub-instruction (
continue/stayatidx == len-1while completion is Yes). decision_agent.py:957-959 - 12-direction binning is fixed.
construct_image_dictsmaps each predicted angle to one of 12 viewpoint ids (0 = current orientation, 30° apart). The navigator prompt's geometry depends on this exact mapping. base_il_trainer_llm.py:202-259 - Per-sub-instruction history resets on
continue. Advancing the pointer clearsnav_history. base_il_trainer_llm.py:783-784 - The judge sees a chosen-path image sequence seeded with the current view and appended each step. base_il_trainer_llm.py:487-491, 687
- Default model is gpt-5 (
run.sh: --llm gpt-5-2025-08-07), shipped abilities arecontinue + stayonly (run_OpenNav.yaml: ENABLED_META_ABILITIES), with nomax_tokenscap.
2. The AgentCanvas port
workspace/nodesets/method/threestepnav/__init__.py mirrors the upstream method bodies 1:1 within the framework floor. Because Three-Step makes ~4 LLM calls per step (navigator + two history-compression summaries + the gated judge) plus the pre-loop decompose, the floor is already 14 method nodes — most are single-responsibility prompt builders / parsers. The one fat node is judge_decide, the sole writer of all four state keys. The env (env_habitat), waypoint predictor (opennav_waypoint) and perception wrappers (model_ram / vlm_spatialbot) are shared with Open-Nav.
2.1 Per-step flow
The loop is a v3 iterIn/iterOut scope: action_list/landmark_list ride the init_* ports (persist) and are re-emitted each step; select_subinstruction reads the pointer to emit the current/next sub-instruction. The judge LLM is internal to judge_decide (it is conditional on completion, so it cannot be a stand-alone llmCall node). step_hightolow.info triggers the next panorama_rgbd; judge_decide.stop → iter_out.stop ends the loop; iter_out.final_stop fires evaluate once.
2.2 Node inventory
| Node | In → Out | Role |
|---|---|---|
detect_actions_prompt | instruction:TEXT → system/user:TEXT | Assemble ACTION_DETECTION prompt (Step 1a, pre-loop). |
extract_landmarks | actions:TEXT → action_list/landmark_list:ANY | Split sub-instructions + one internal LANDMARK_DETECTION LLM call per sub-instruction (variable-N fan-out — not a static llmCall). |
select_subinstruction | action_list/landmark_list:ANY → current_action · next_instruction · action_idx · num_actions | Read current_action_idx → emit current/next sub-instruction + landmarks. |
format_observation | candidates · tags · captions · stuck_directions:ANY → observation:TEXT · candidate_ids:TEXT · blocks:ANY | Fuse RAM tags + SpatialBot captions into the per-direction Current Environment list (observe_view blocks, upstream's str(list) repr, '0'-first descending order); only candidate_ids (rendered as the dict_keys([…]) view) excludes blacklisted directions — the observation text keeps them, as upstream. |
review_history | (reads nav_history) → history_traj:TEXT | Flatten the history container to the "… -> …" string. |
navigator_prompt | 6 TEXT fields → system/user:TEXT | Assemble the verbatim MAPGPT_NAVIGATOR user prompt. |
build_images | views · candidates · stuck_directions:ANY → images:LIST[IMAGE] · image_labels:LIST[TEXT] | Per-candidate RGB tiles (stuck-filtered) + "Viewpoint N:" labels (1:1 with images). |
navigator_llm (llmCall) | system/user + rgb + labels → response | The MapGPT image navigator call (image_detail="high"). |
parse_navigator | llm_out:TEXT → pred_vp · pred_thought · completion_estimation | Verbatim parse of Thought / Prediction / Completion (move_to_next_vp_single). |
select_direction_observation | blocks · pred_vp → observation · direction_id | Pick the chosen direction's block (save_history). |
obs_summary_prompt · thought_summary_prompt | → summary llmCalls | Per-step OBSERVATION_SUMMARY / THOUGHT_SUMMARY compressors. |
build_history_entry | summaries + direction_id → entry:TEXT | Assemble the per-step {step,viewpoint,observation,thought} entry (DIRECTIONS-prefixed). Emits data only — judge_decide writes nav_history. |
judge_decide | completion · pred_vp · candidates · views · entry · idx · num_actions → angle · distance · stop · decision | The fat node. Gated judge VLM call (Yes only, detail="high" + per-image labels + image-desc block) + 4-ability decision rules + pointer/history state machine + env-action resolution. Pre-move writer of nav_history · current_action_idx · chosen_images_b64 · chosen_descriptions · move_stack · last_chosen_vp; clears stuck_directions on continue. |
observe_pose (env_habitat) | (trigger) → position:ANY | Iteration-start agent position for the stuck check. |
stuck_detect | position (+ reads previous_position · last_chosen_vp) → stuck_directions:ANY | Post-move stuck check (<0.1m ⇒ blacklist the last dir + pop the failed image/desc/history); emits the live blacklist that format_observation / build_images filter on. Sole writer of previous_position · stuck_directions. |
Single version. A Phase-2 decomposition (threestep2 — judge_decide split along the single-writer seam, proven byte-equivalent over 13 scenarios) existed until 2026-07-03 and was removed by decision: the monolith is already fine-grained (its 13 sibling nodes are forced apart by the ~4 graph-level llmCalls per step), and the judge's conditional LLM call cannot be a graph-level node in any split — the decomposition only relocated state-writing. The monolith is the sole version and the ground-truth I/O contract.
2.3 State & memory
| graph_state entry | Reducer · type · lifetime | Writer | Upstream counterpart |
|---|---|---|---|
current_action_idx | lastWrite · int · episode | judge_decide (sole) | current_action_idx local |
nav_history | lastWrite · list · episode | judge_decide (sole) | nav_history list (reset on continue) |
chosen_images_b64 | lastWrite · list · episode | judge_decide (pre-move append) · stuck_detect (post-move pop) | chosen_images sequence |
chosen_descriptions | lastWrite · list · episode | judge_decide (append) · stuck_detect (pop) | chosen_images_descriptions (the judge's image-desc block) |
move_stack | lastWrite · list · episode | judge_decide (sole) | env_actions_history (for backtrack) |
stuck_directions | lastWrite · list · episode | stuck_detect (add) · judge_decide (clear on continue) | stuck_directions set |
previous_position · last_chosen_vp | lastWrite · episode | stuck_detect · judge_decide | previous_position / last_chosen_vp locals |
action_list · landmark_list | iterIn init_* (persist) | extract_landmarks (pre-loop) | episode-fixed locals |
2.4 Boundary contract
The nodeset is reasoning-only — it imports no env runtime. It consumes env_habitat (reset / observe_panorama / step_hightolow / evaluate), the opennav_waypoint TRM predictor, the generic model_ram + vlm_spatialbot FM wrappers, and built-in llmCall — all over wires. The import-boundary test (app/test_import_boundary.py) holds. The same substrate serves Open-Nav itself; the waypoint/perception fixes shipped with this port also repaired Open-Nav.
2.5 Prompt assets
All navigator / decompose / summary prompts are verbatim module-level constants in _prompts.py, each citing its upstream source. A programmatic byte-diff (2026-06-22) confirmed ACTION_DETECTION, LANDMARK_DETECTION, OBSERVATION_SUMMARY, THOUGHT_SUMMARY, MAPGPT_NAVIGATOR (system 5278 chars) and DIRECTIONS are byte-identical to the upstream prompts.py dicts — including the "Font Left" typo at DIRECTIONS[1]. The judge prompt (JUDGE_SYSTEM + build_judge_user's enhanced_prompt: capability descriptions, degenerate AST logic blocks, in-context examples, format instructions) was byte-diff-verified against upstream make_informed_decision_with_capture (grill 2026-06-22) — see §3A. The last fragment — the prepended "Image sequence descriptions" block — was added in grill iter-2 (§3A), so the judge prompt is now byte-faithful in full.
3. Delta vs upstream — five buckets
Each row carries an equivalence icon (orthogonal to the bucket letter — the letter says why it differs, the mark says whether behaviour changed): 🟢 equivalent · 🟡 near-equivalent · 🟠 divergent. A genuine defect is a 🟠 row in bucket E with a severity, never an ❌.
A. Preserved verbatim
| Equiv. | Element | Upstream anchor | How preserved |
|---|---|---|---|
| 🟢 | Navigator / decompose / summary prompts | prompts.py dicts | Byte-identical (programmatic byte-diff 2026-06-22); MAPGPT system = 5278 chars exact. |
| 🟢 | DIRECTIONS incl. "Font Left" typo @ idx 1 | prompts.py:23-24 | Preserved byte-for-byte. |
| 🟢 | parse_navigator / parse_judge / string_to_decision / apply_decision_rules | spatialNavigator.py:141-194 · decision_agent.py:59-941 | Mirrored logic (cited per function). |
| 🟢 | 12-direction angle binning + HIGHTOLOW action bridge | base_il_trainer_llm.py:202-259, 849-856 | Same bins; radius_dict/distance_dict → (angle, distance). |
| 🟢 | should_stop_navigation (idx==last AND continue/stay, gated on Yes) | decision_agent.py:957-959 | Faithful; verified inside the completion=Yes block. |
| 🟢 | Judge enhanced_prompt (capability descriptions + AST logic blocks + in-context examples + format instructions) | decision_agent.py:674-784 · navigation_capabilities.py:182-235 | Byte-faithful (grill 2026-06-22): programmatic reconstruction matches upstream make_informed_decision_with_capture exactly. The per-capability "logic analysis" lines are reproduced as the upstream runtime degenerate (Operations: empty / Has conditions: False / Complexity: unknown) — verified that analyze_capability_logic hits IndentationError on the class-indented staticmethod source and falls to its except. |
| 🟢 | Judge per-image "Viewpoint {i}:" labels + detail="high" | api.py:154-177 | Grill 2026-06-22: judge vlm_complete now passes the sequence labels and high detail (matching gpt_infer_with_images). |
| 🟢 | Per-step observation summary input (strip to "Scene Description…") | spatialNavigator.py:42 | Grill 2026-06-22: observation_summary_prompt strips the "Direction {id} … Eye Level " prefix before summarising, as save_history does. |
| 🟢 | Navigator image detail="high" | api.py:177 | Grill iter-2 2026-06-22: added an image_detail config to the shared llmCall node (default "low" for all other ports) → vlm_complete; navigator_llm set to high in both graphs. |
| 🟢 | Judge "Image sequence descriptions" block | decision_agent.py:766-784 · base_il_trainer_llm.py:489-491,690-718 | Grill iter-2: chosen_descriptions accumulated 1:1 with the chosen images (initial seed + per-step 12-way direction phrase), prepended to the judge prompt exactly as upstream. Byte-equiv covers it (13/13). |
| 🟢 | stuck-direction detect + blacklist + filter + clear-on-continue | base_il_trainer_llm.py:655-664, 786, 873-910 | Grill iter-2: observe_pose + stuck_detect nodes reproduce the <0.1m no-move check, blacklist + failed-step pop, navigator-candidate filter, and clear-on-continue. Standalone-verified. Iter-4 (2026-07-02) scoped the filter exactly as upstream: candidate-ID list + images only — the observation TEXT keeps stuck directions (base_il_trainer_llm.py:624,666). |
| 🟢 | ENABLED_META_ABILITIES = ["continue", "stay"] | run_OpenNav.yaml:19 (run config overrides the code's full-4 default) | Grill iter-3 2026-06-22: both graphs' judge config set to continue,stay — matching the paper run (run.sh → run_OpenNav.yaml, gpt-5, 100 ep val_unseen). Backtrack/look-around stay implemented (as upstream) but disabled by config. Corrects the prior full-4 divergence (a flagged wander suspect). |
| 🟢 | Navigator random.choice fallback (dead at eval) | spatialNavigator.py:156-159 · base_il_trainer_llm.py:666,1069 | Cold pass (2026-06-25): upstream's malformed-output random fallback returns a 3-tuple while the trainer unpacks 4 → ValueError swallowed by the broad except → current_step -= 1 → the step silently re-runs. The random pick never drives a move; it is dead at eval, so the port's omission is faithful (absence of dead code = 🟢). |
| 🟢 | Judge History field includes the current step | base_il_trainer_llm.py:683→722→748 | Grill iter-4 (fixed 2026-07-02): upstream reviews the history after save_history appended this step's entry, so the judge sees it. Both judges now flatten [*prior, entry] internally (the pre-append history_traj wire into the judge was removed; entry is wired into judge_verdict). Runtime-confirmed against run 20260622_232311 where the old judge saw "Step 0 start position. ". |
| 🟢 | Step ID / image-desc step numbering 1-based | base_il_trainer_llm.py:600 (current_step increments before the observe) | Grill iter-4 (fixed 2026-07-02): ctx.step is 0-based — format_observation / build_history_entry / the "Step {N}:" image-desc phrase now render step+1. |
| 🟢 | Current Environment = str(list) of per-direction blocks, unfiltered | base_il_trainer_llm.py:624,666 · spatialNavigator.py:120 | Grill iter-4 (fixed 2026-07-02): upstream formats the raw observe LIST into the prompt — the port now emits the identical list repr over ALL candidates (was: stuck-filtered plain concatenation). |
| 🟢 | Candidate order: '0' first, then descending ids | Policy_ViewSelection.py:374-379 (ascending nonzero() angle-idx → 2π−… descending angles) · base_il_trainer_llm.py:202-259 | Grill iter-4 (fixed 2026-07-02): format_observation / build_images now emit upstream's insertion order (was: ascending numeric sort). |
| 🟢 | str(dict_keys([...])) candidate list | spatialNavigator.py:120 | Grill iter-4 (fixed 2026-07-02, moved from D): candidate_ids now renders the dict_keys([…]) view verbatim — with this the whole navigator user prompt is byte-identical to the upstream rendering (unit-verified end-to-end). |
| 🟢 | Completion gate strict == "Yes" on the RAW estimate | base_il_trainer_llm.py:728 | Grill iter-4 (fixed 2026-07-02): parse_navigator no longer normalises "Yes."/"yes" → "Yes" — upstream's judge does NOT fire on those, and now neither does the port's. |
| 🟢 | Observation-summary truncation at a repeated "Scene Description" | spatialNavigator.py:42 | Iter-5 (fixed 2026-07-02): upstream's split("Scene Description")[1] has no maxsplit — if the caption itself contains the marker, the summary input truncates at the second occurrence. The port used maxsplit=1 (kept the tail); now identical. |
| 🟢 | "Thought: " stripped from the thought-summary output | spatialNavigator.py:45-46 | Iter-5 (fixed 2026-07-02): upstream applies .replace("Thought: ", "") to the summary LLM's response before storing it in the history entry — the port dropped this entirely (history entries could carry a Thought: prefix into every later navigator/judge prompt). Now applied in build_history_entry. |
| 🟢 | [Code-Informed] judge-reasoning prefix | decision_agent.py:790 | Iter-5 (fixed 2026-07-02): upstream tags the parsed judge reasoning; the port's decision_thought output now carries the same prefix (log/debug surface only — not LLM-visible). |
| 🟢 | RGB camera 1024×1024 (ex-HIGH E row) | vlnce_task.yaml:13-16 (fork diff vs Open-Nav 3a8dcef) | Perception round (fixed 2026-07-03): per-graph rgb_resolution knob on env_habitat__reset (rebuilds the sim once per worker, re-seats the placed episode); both threestep graphs set 1024, depth stays 256. Log-verified 1024×1024 tiles in smoke 20260703_132457. |
| 🟢 | VLM image bytes = JPEG re-encodes of the raw render (ex-C PNG row) | base_il_trainer_llm.py:64-83 (image_to_base64) · decision_agent.py:806-815 | Fixed 2026-07-03: build_images + both judges decode the lossless env tile and re-encode JPEG (PIL default quality); vlm_complete grew a mime param and llmCall an image_mime config. JPEG byte chain byte-identical to the AST-executed upstream image_to_base64 (harness T9). |
| 🟢 | Waypoint path — full faithful rewrite (6 defects): DDPPO depth encoder silently never loaded (zero depth features in every prior run — vlnce_baselines unimportable in the server env + observation_space=None, both swallowed); no clockwise 12-view arrangement; no ImageNet Normalize on RGB; NMS on raw logits without softmax + circular wrap; strength-sorted first-wins candidates; per-tile min-max depth values | Policy_ViewSelection.py:232-380 · resnet_encoders.py · habitat_extensions/obs_transformers.py:160-162 · base_il_trainer_llm.py:202-259 | Fixed 2026-07-03: engine.predict transcribed from upstream (clockwise ra=(12−a)%12 · ResizerPerSensor mode="area" 1024→224 — the fork's ACTIVE path; the bilinear shim at Policy_ViewSelection.py:259-268 is dead code · ConvertImageDtype+Normalize · softmax+wrap+nms(5,(7,5))+unwrap · ascending-nonzero, last-wins binning · absolute [0,1] depth from depth_raw · float32 tensor angle math); vendored resnet_encoders.py file-level import, load failure now FATAL. threestepnav_waypoint_equiv.py: 6/6, features torch.equal, candidates exactly equal. |
| 🟢 | Perception layer — SpatialBot depth packing · RAM tags · generate args | api.py:260-277 · api.py:109-110 · base_il_trainer_llm.py:186-191 | Fixed 2026-07-03: packer now takes the per-tile-normalised uint8 depth (upstream's degenerate runtime input — R channel 0; was uint16 mm "improvement"), with explicit int16 promotion for NumPy ≥2 (NEP 50 raises on upstream's uint8//1024); RAM tags returned RAW with " | " separators (the old strip was LLM-visible); SpatialBot generate args mirror upstream exactly (use_cache=True, repetition_penalty=1.0, nothing injected). Harness T9 byte-checks + smoke: 56/56 tags with separators, 56/56 captions non-empty. |
B. Forced by substrate
| Equiv. | Upstream | Port | Why forced |
|---|---|---|---|
| 🟢 | _eval_llm monolithic loop | 13 method nodes | Typed graph; ~4 LLM calls/step force the prompt-builder/parser split. |
| 🟢 | manager object attributes | graph_state containers | Cross-step state must be a stateContainer, not a Python attribute. |
| 🟢 | pre-loop decompose locals | iterIn init_* ports (persist) | Episode-fixed values ride the loop's init slots. |
| 🟢 | variable-N landmark fan-out | extract_landmarks internal calls | A static graph can't express N per-sub-instruction llmCalls. |
| 🟢 | llmClient.gpt_infer_with_images (message/label assembly) | built-in llmCall + internal vlm_complete | Framework owns the LLM client, but the interleave shape now matches: text → per-image "Viewpoint {i}:" label → image. Judge + navigator image detail both reproduced (high) via the new image_detail config on the llmCall node (grill iter-2). Iter-5 harness byte-compares the full message payload (text items · type sequence · detail · system message) against the AST-executed upstream gpt_infer_with_images — identical. |
| 🟡 | backtrack steps the env TWICE in one iteration — the branch executes the reverse move, then falls through to the shared env-step block which ALSO executes the forward move to this step's pick (and re-appends it to env_actions_history) | one env action per graph step → reverse only | Iter-5 finding (2026-07-02): the graph loop emits exactly one step_hightolow per iteration. Config-dead — the faithful abilities [continue,stay] never fire backtrack. |
C. Forced by environment / cost
| Equiv. | Upstream | Port | Reason |
|---|---|---|---|
| 🟢 | R2R-CE val_unseen OpenNav_R2R-CE_100 (100 eps) | rand100 split | Verified identical: our rand100.json.gz has the same 100 episode_ids in the same order (v1-3 vs v1-2 preprocessing only; zero-shot LLM uses raw instruction text). |
| 🟡 | no max_tokens cap | max_tokens = 16000 | Reasoning models need a cap; 2000 starved the visible output (empty responses). |
| 🟠 | habitat (upstream) | env_habitat wrapper | Same HIGHTOLOW contract; perception runs as shared server singletons (RAM/SpatialBot/TRM). |
D. Intentional divergences
| Equiv. | Upstream | Port | Rationale (recorded) |
|---|---|---|---|
| 🟡 | look-around re-observes all viewpoints then moves | look-around ≡ normal move (dormant under [continue, stay]) | Implemented identically to upstream (which also disables it via run config); the graph already perceives every candidate each step (docstring). |
| 🟡 | env_actions_history + _create_reverse_action | per-episode move_stack reverse (dormant under [continue, stay]) | Same reverse-angle math, state-container form (docstring). Edge noted in iter-4: on backtrack the port pops the last chosen image whenever non-empty where upstream guards len>1 (keeps the seed) — dormant-path-only, same D rationale. |
| 🟢 | per-instruction cache_files/ | re-run Step 1 each episode | Faithfulness/simplicity tradeoff (docstring). |
| 🟡 | MAX_EPISODE_STEPS = 8 (run_OpenNav.yaml → vlnce_task.yaml; the env cap fires before the judge's dead current_step>=20) | step_budget = 8 faithful; 20 available as a gpt-5-mini crutch | 8 is the upstream effective horizon and is now the faithful eval value. The earlier 20 compensated for gpt-5-mini over-segmenting the instruction into more sub-instructions than 8 waypoint-steps can clear — a model-tier effect (bucket C), not a port choice. |
| 🟡 | navigator malformed-output: retry the VLM once on no Prediction: (spatialNavigator.py:142-148; the subsequent random.choice is dead — see §3A) | no retry; empty pred_vp → (0,0) no-op step → next-step stuck-detect | Deliberate (grill iter-1 / cold pass 2026-06-25): the retry is a conditional LLM re-fire not yet wired; the port instead takes a no-op step and lets stuck-detect handle it — arguably more robust (upstream's swallowed-ValueError re-run can loop on the same step). Low impact — the malformed-output path is rare with structured-output models. |
| 🟠 | SR/SPL hand-computed with no STOP gate (final distance ≤ 3 m; habitat measures stripped) — the paper's ruler | official stop-gated Success via evaluate, plus a post-hoc SR* recompute under the upstream rule | Dual-reporting policy, recorded 2026-07-03 (§4.1): benchmark claims → official SR; paper-reproduction claims → SR*. Anchors: base_il_trainer_llm.py:966,1309-1311 vs habitat/tasks/nav/nav.py:500-539. Re-filed from E after the aligned run |
E. Unexplained / defects
None open. The success-metric divergence found after the aligned run was re-filed to D (2026-07-03) — an eval-reporting decision with a recorded dual-reporting policy (§4.1), not a port defect. Earlier candidates were likewise re-filed: navigator random.choice fallback → A (dead at eval), retry-once → D, caption-500 → §4 known engine gaps.
Probed — five independent passes, each walking the upstream source cold (per-pass detail in the changelog):
- 2026-06-22 · grill iter 1–3 — full eval loop, both VLM call sites, capability-AST path, decision rules, stuck subsystem. Closed the navigator/judge
detail, image-descriptions and stuck-direction gaps; corrected the config premises (abilities[continue,stay], horizon 8). - 2026-06-25 · cold re-audit — three parallel inventories; all prompts re-confirmed byte-identical; found upstream's
random.choicefallback dead at eval (E→A). - 2026-07-02 · iter 4 — fresh upstream clone; six findings, five fixed same-day (judge post-append History · 1-based steps ·
str(list)Current Environment · '0'-first candidate order · strict== "Yes"gate) plusdict_keysD→A — the navigator user prompt became byte-identical end-to-end. - 2026-07-02 · iter 5 — upstream-import byte harness: the real upstream modules as oracle, compared function-by-function on identical inputs (79 checks). Three fixes (summary truncation ·
Thought:strip ·[Code-Informed]prefix); one new B row (backtrack double-step, config-dead). - 2026-07-03 · perception round — the fork's pixel path pinned end-to-end; ten fixes, heaviest a silently-never-loaded DDPPO depth encoder (zero depth features in all prior Open-Nav-family runs). Harness grown to 82 byte-checks / 0 mismatch; waypoint path 6/6 tensor-equal; 13/13 decomp gate; smoke in-vivo green.
4. Evaluation
Two success rulers appear below (definitions in §4.1): SR = official VLN-CE stop-gated Success; SR* = upstream's own rule (final distance ≤ 3 m, no STOP gate). The paper's numbers are SR*.
| Run | Config | SR | SR* | OSR | NE | TL | nDTW | Reading |
|---|---|---|---|---|---|---|---|---|
| Paper (Zheng et al.) | gpt-5 · 1024 px · horizon 8 · 100 ep val_unseen | — | 0.34 | 0.39 | 5.87 | 9.18 | 0.577 | upstream ruler only (SPL 0.29 inherits the loose success term) |
20260703_141242 | gpt-5 · 1024 px aligned stack · 8 · 25 ep rand100 | 0.08 | 0.24 | 0.28 | 6.62 | 9.25 | 0.535 | headline — compatible with the paper at n=25 (P(X≤6 | p=0.34) ≈ 0.20) |
20260702_193201 | gpt-5 · 224 px · 8 · 25 ep | 0.00 | — | 0.04 | 7.29 | 7.26 | 0.446 | pre-alignment — refuted the "model-tier" attribution, exposed the render-resolution gap |
20260622_232311 | gpt-5-mini · 224 px · 8 · 10 ep | 0.00 | — | 0.10 | 8.40 | 8.10 | 0.42 | post-grill config check — TL back to paper shape |
20260622_155609 | gpt-5 · 224 px · 20 steps · 50 ep | 0.16 | — | 0.24 | 6.95 | 14.64 | 0.41 | pre-grill baseline — wandering (20-step horizon, full-4 abilities) |
20260703_152124 + _170942 | gpt-5-mini · 1024 px aligned stack · 8 · 100 ep rand100 (merged 28 + 72) | 0.10 | 0.29 | 0.39 | 6.46 | 10.55 | 0.512 | full-split scale check at the mini tier — SR* within noise of the paper (P(X≤29 | p=0.34) ≈ 0.17), OSR equal to the paper's 0.39; model log-verified gpt-5-mini-2025-08-07 |
Bottom line (implementation frozen 2026-07-03): on the aligned stack the port reproduces the paper under the paper's own ruler — SR* 0.24 vs 0.34 is within sampling noise at n=25, and the trajectory-shape metrics agree (TL 9.25 vs 9.18 · OSR 0.28 vs 0.39 · nDTW 0.535 vs 0.577). The official stop-gated SR stays low (0.08) for a structural reason: Three-Step rarely decides to stop (2/25 judge-stops on the headline run — both scored SR 1), and the paper's number never required stopping. In-log mechanics on the headline run are clean: the judge fired on all 65 Yes gates, the six SR* successes finished 0.5–2.5 m from the goal, and 2/200 steps were lost to the known tag-singleton 500. The 100-episode mini-tier scale check corroborates the headline at 4× the sample: SR* 0.29 · OSR 0.39 (equal to the paper) · nDTW 0.512, with the official stop-gated SR again low (0.10) for the same stopping-behaviour reason.
4.1 The two SR definitions
- Official VLN-CE SR (habitat-lab
Success,habitat/tasks/nav/nav.py:500-539): success ⇔is_stop_calledand distance < 3 m — STOP is a hard requirement. This is the leaderboard definition and whatenv_habitat__evaluatereads; our judge-stop path maps to habitat STOP, so the port scores under it legitimately. - Upstream's SR: the trainer strips
TASK.MEASUREMENTSto[POSITION, STEPS_TAKEN](base_il_trainer_llm.py:1309-1311) — the official measures are never computed — and hand-computessuccess = 1 if distance[-1] ≤ 3(:966): no STOP gate. Forced by their design: the agent only emits HIGHTOLOW moves and the episode is cut at 8 steps, so official SR would be 0 by construction. The paper's 0.34 is this ruler.
The missing gate is fork-specific, not family convention: SmartWay's trainer — same lineage, same stripped-measures structure — keeps it (… and env_actions[i]['action']['action'] == 0, SmartWay base_il_trainer.py:547); the Three-Step fork's line drops that clause.
Comparability: OSR and TL are comparable across both stacks (neither OSR definition has a stop gate), nDTW methodologically so; SR and SPL are not. If the paper's baseline rows (Open-Nav 19 · SmartWay 29 · CA-Nav 25 · …) quote official numbers from the original papers, its table mixes rulers in the loose one's favour. Reporting policy (the §3D row): paper-reproduction claims quote SR* (recomputed post-hoc from distance_to_goal ≤ 3); benchmark claims quote official SR; every number above states its ruler.
Known engine gaps: high-worker caption-500 silent episode completion (SpatialBot singleton) — keep worker_count ≤ 5; the loop-body evaluate under-count (roadmap TODO #64) does not apply here (evaluate fires post-loop).
5. Usage
Load: POST /api/components/nodesets/threestepnav/load. Graph: workspace/graphs/vln/verified/threestepnav_ce.json. Indicative eval (graph-only form, since 2026-05-07):
/experiment:run threestepnav-ce threestepnav_ce \
episode_count=25 worker_count=5 split=rand100 step_budget=8 per_step_budget_sec=180
Requires opennav_waypoint (env smartway) + opennav_perception (env ac-ram, SpatialBot-3B weights at data/opennav/SpatialBot-3B) shared servers. Keep worker_count ≤ 5 (caption singleton). Faithful paper config = gpt-5-full + abilities [continue,stay] + step_budget=8 (run_OpenNav.yaml); dev smokes may drop to gpt-5-mini per node config. The graph pins the faithful aligned config (gpt-5 · rgb_resolution=1024 on reset · image_mime=image/jpeg on the navigator); 1024-px renders need per_step_budget_sec=180 and write ~0.7 GB of log.jsonl per episode.
6. What this nodeset is NOT
- Not an env or perception nodeset — it owns no habitat / RAM / SpatialBot / TRM code; those are
env_habitat/opennav_*reused from Open-Nav. - Not model-locked — the LLM profile is per-node config; the verified graph pins gpt-5 (paper config), dev smokes may drop to gpt-5-mini.
- Not two versions — the Phase-2 decomposition (
threestep2) was removed 2026-07-03; the monolith is the sole version (§2). - Not benchmark-verified — under the official stop-gated SR the aligned run scores 0.08 (2/25 judge-stops); the paper's 0.34 is reproduced only under its own distance-only ruler (SR* 0.24, compatible at n=25). See §4.1.
7. Source files
| Kind | Path |
|---|---|
| Nodeset | workspace/nodesets/method/threestepnav/{__init__.py,_prompts.py} |
| Graph | workspace/graphs/vln/verified/threestepnav_ce.json |
| Byte harnesses | tmp/verify/threestepnav_upstream_equiv.py (82 checks) · tmp/verify/threestepnav_waypoint_equiv.py (6/6) |
| Eval profile | workspace/architect/exp_profiles/threestepnav_ce.yaml · admission threestepnav-ce |
| Upstream | github.com/ZoeyZheng0/3-step-Nav @ 5cdbdcf (vlnce_baselines/common/navigator/ + base_il_trainer_llm.py) |