AgentCanvas / Pages / Developer Guide / Nodesets / Method / Three-Step Nav

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 paperThree-Step Nav (arXiv 2604.26946) — claims zero-shot SOTA on R2R-CE / RxR-CE val_unseen
Pinned commitgithub.com/ZoeyZheng0/3-step-Nav @ 5cdbdcf
Env spaces consumedenv_habitatreset · observe_panorama (RGB-D ×12) · step_hightolow (angle, distance) · evaluate
FM nodes consumedopennav_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
Nodes14 method nodes · 34-node graph
Graphworkspace/graphs/vln/verified/threestepnav_ce.json — promoted from unverified/ 2026-07-03; pins the faithful config (gpt-5 · 1024 px · JPEG)
Status vs paperReproduced 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 verdictfaithful 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:

Step 1 — global (pre-loop) detect_actions · detect_landmarksaction_list · landmark_list Step 2 — local (per step) policy.net waypoint (TRM) construct_image_dicts (12-dir) observe_environment (RAM+SpatialBot) move_to_next_vp_singleMapGPT image navigator→ pred_vp · completion Completion? Yes?gate make_informed_decisioncontinue/stay/backtrack/look-around manager object attributes current_action_idx · nav_history chosen_images · env_actions_history decision drives the pointer / history envs.step — HIGHTOLOWaction 4 (angle, distance) next obs stop_flagYes @ last sub-instr eval metricsSR · SPL · NE · nDTW for current_step in range(MAX_EPISODE_STEPS=8) — next step env / sim method LLM / VLM gate / stop state rollout loop

1.1 Upstream invariants worth marking

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

Step 1 — global (pre-loop) detect_actions_llm · extract_landmarks→ iterIn init: action_list · landmark_list Step 2 — local (per step) panorama_rgbd · waypoint_predict bin_to_directions (12-dir) ram_tag · spatial_caption format_observation · navigator_prompt navigator_llm→ parse_navigator completion · pred_vp judge_decidegated judge LLM (Yes) + decision rules+ pointer/history machine + env actionSOLE WRITER of all 4 state keys graph_state (episode) current_action_idx · nav_history chosen_images_b64 · move_stack stateContainer — lastWrite step_hightolowenv_habitat (angle, distance) step.info iter_out — stopstop · final_stop stop final_stop evaluate→ metrics graphOut iterOut → iterIn — next step (step_budget, default 8 mono / 20 calibration) env_habitat / FM threestep node llmCall loop control graph_state state access loop-back

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.stopiter_out.stop ends the loop; iter_out.final_stop fires evaluate once.

2.2 Node inventory

NodeIn → OutRole
detect_actions_promptinstruction:TEXTsystem/user:TEXTAssemble ACTION_DETECTION prompt (Step 1a, pre-loop).
extract_landmarksactions:TEXTaction_list/landmark_list:ANYSplit sub-instructions + one internal LANDMARK_DETECTION LLM call per sub-instruction (variable-N fan-out — not a static llmCall).
select_subinstructionaction_list/landmark_list:ANYcurrent_action · next_instruction · action_idx · num_actionsRead current_action_idx → emit current/next sub-instruction + landmarks.
format_observationcandidates · tags · captions · stuck_directions:ANYobservation:TEXT · candidate_ids:TEXT · blocks:ANYFuse 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:TEXTFlatten the history container to the "… -> …" string.
navigator_prompt6 TEXT fields → system/user:TEXTAssemble the verbatim MAPGPT_NAVIGATOR user prompt.
build_imagesviews · candidates · stuck_directions:ANYimages: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 → responseThe MapGPT image navigator call (image_detail="high").
parse_navigatorllm_out:TEXTpred_vp · pred_thought · completion_estimationVerbatim parse of Thought / Prediction / Completion (move_to_next_vp_single).
select_direction_observationblocks · pred_vpobservation · direction_idPick the chosen direction's block (save_history).
obs_summary_prompt · thought_summary_prompt→ summary llmCallsPer-step OBSERVATION_SUMMARY / THOUGHT_SUMMARY compressors.
build_history_entrysummaries + direction_id → entry:TEXTAssemble the per-step {step,viewpoint,observation,thought} entry (DIRECTIONS-prefixed). Emits data only — judge_decide writes nav_history.
judge_decidecompletion · pred_vp · candidates · views · entry · idx · num_actions → angle · distance · stop · decisionThe 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:ANYIteration-start agent position for the stuck check.
stuck_detectposition (+ reads previous_position · last_chosen_vp) → stuck_directions:ANYPost-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 (threestep2judge_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 entryReducer · type · lifetimeWriterUpstream counterpart
current_action_idxlastWrite · int · episodejudge_decide (sole)current_action_idx local
nav_historylastWrite · list · episodejudge_decide (sole)nav_history list (reset on continue)
chosen_images_b64lastWrite · list · episodejudge_decide (pre-move append) · stuck_detect (post-move pop)chosen_images sequence
chosen_descriptionslastWrite · list · episodejudge_decide (append) · stuck_detect (pop)chosen_images_descriptions (the judge's image-desc block)
move_stacklastWrite · list · episodejudge_decide (sole)env_actions_history (for backtrack)
stuck_directionslastWrite · list · episodestuck_detect (add) · judge_decide (clear on continue)stuck_directions set
previous_position · last_chosen_vplastWrite · episodestuck_detect · judge_decideprevious_position / last_chosen_vp locals
action_list · landmark_listiterIn 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.ElementUpstream anchorHow preserved
🟢Navigator / decompose / summary promptsprompts.py dictsByte-identical (programmatic byte-diff 2026-06-22); MAPGPT system = 5278 chars exact.
🟢DIRECTIONS incl. "Font Left" typo @ idx 1prompts.py:23-24Preserved byte-for-byte.
🟢parse_navigator / parse_judge / string_to_decision / apply_decision_rulesspatialNavigator.py:141-194 · decision_agent.py:59-941Mirrored logic (cited per function).
🟢12-direction angle binning + HIGHTOLOW action bridgebase_il_trainer_llm.py:202-259, 849-856Same bins; radius_dict/distance_dict(angle, distance).
🟢should_stop_navigation (idx==last AND continue/stay, gated on Yes)decision_agent.py:957-959Faithful; 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-235Byte-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-177Grill 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:42Grill 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:177Grill 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" blockdecision_agent.py:766-784 · base_il_trainer_llm.py:489-491,690-718Grill 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-continuebase_il_trainer_llm.py:655-664, 786, 873-910Grill 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.shrun_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,1069Cold pass (2026-06-25): upstream's malformed-output random fallback returns a 3-tuple while the trainer unpacks 4ValueError swallowed by the broad exceptcurrent_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 stepbase_il_trainer_llm.py:683→722→748Grill 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-basedbase_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, unfilteredbase_il_trainer_llm.py:624,666 · spatialNavigator.py:120Grill 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 idsPolicy_ViewSelection.py:374-379 (ascending nonzero() angle-idx → 2π−… descending angles) · base_il_trainer_llm.py:202-259Grill iter-4 (fixed 2026-07-02): format_observation / build_images now emit upstream's insertion order (was: ascending numeric sort).
🟢str(dict_keys([...])) candidate listspatialNavigator.py:120Grill 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 estimatebase_il_trainer_llm.py:728Grill 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:42Iter-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 outputspatialNavigator.py:45-46Iter-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 prefixdecision_agent.py:790Iter-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-815Fixed 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 valuesPolicy_ViewSelection.py:232-380 · resnet_encoders.py · habitat_extensions/obs_transformers.py:160-162 · base_il_trainer_llm.py:202-259Fixed 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 argsapi.py:260-277 · api.py:109-110 · base_il_trainer_llm.py:186-191Fixed 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.UpstreamPortWhy forced
🟢_eval_llm monolithic loop13 method nodesTyped graph; ~4 LLM calls/step force the prompt-builder/parser split.
🟢manager object attributesgraph_state containersCross-step state must be a stateContainer, not a Python attribute.
🟢pre-loop decompose localsiterIn init_* ports (persist)Episode-fixed values ride the loop's init slots.
🟢variable-N landmark fan-outextract_landmarks internal callsA static graph can't express N per-sub-instruction llmCalls.
🟢llmClient.gpt_infer_with_images (message/label assembly)built-in llmCall + internal vlm_completeFramework 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 onlyIter-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.UpstreamPortReason
🟢R2R-CE val_unseen OpenNav_R2R-CE_100 (100 eps)rand100 splitVerified 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 capmax_tokens = 16000Reasoning models need a cap; 2000 starved the visible output (empty responses).
🟠habitat (upstream)env_habitat wrapperSame HIGHTOLOW contract; perception runs as shared server singletons (RAM/SpatialBot/TRM).

D. Intentional divergences

Equiv.UpstreamPortRationale (recorded)
🟡look-around re-observes all viewpoints then moveslook-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_actionper-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 episodeFaithfulness/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 crutch8 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-detectDeliberate (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 rulerofficial stop-gated Success via evaluate, plus a post-hoc SR* recompute under the upstream ruleDual-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):

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*.

RunConfigSRSR*OSRNETLnDTWReading
Paper (Zheng et al.)gpt-5 · 1024 px · horizon 8 · 100 ep val_unseen0.340.395.879.180.577upstream ruler only (SPL 0.29 inherits the loose success term)
20260703_141242gpt-5 · 1024 px aligned stack · 8 · 25 ep rand1000.080.240.286.629.250.535headline — compatible with the paper at n=25 (P(X≤6 | p=0.34) ≈ 0.20)
20260702_193201gpt-5 · 224 px · 8 · 25 ep0.000.047.297.260.446pre-alignment — refuted the "model-tier" attribution, exposed the render-resolution gap
20260622_232311gpt-5-mini · 224 px · 8 · 10 ep0.000.108.408.100.42post-grill config check — TL back to paper shape
20260622_155609gpt-5 · 224 px · 20 steps · 50 ep0.160.246.9514.640.41pre-grill baseline — wandering (20-step horizon, full-4 abilities)
20260703_152124 + _170942gpt-5-mini · 1024 px aligned stack · 8 · 100 ep rand100 (merged 28 + 72)0.100.290.396.4610.550.512full-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

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

7. Source files

KindPath
Nodesetworkspace/nodesets/method/threestepnav/{__init__.py,_prompts.py}
Graphworkspace/graphs/vln/verified/threestepnav_ce.json
Byte harnessestmp/verify/threestepnav_upstream_equiv.py (82 checks) · tmp/verify/threestepnav_waypoint_equiv.py (6/6)
Eval profileworkspace/architect/exp_profiles/threestepnav_ce.yaml · admission threestepnav-ce
Upstreamgithub.com/ZoeyZheng0/3-step-Nav @ 5cdbdcf (vlnce_baselines/common/navigator/ + base_il_trainer_llm.py)
AgentCanvas docs