MapGPT on AgentCanvas — paper analysis + mapgpt port
MapGPT (24.01; Chen et al., ACL 2024) is a zero-shot VLN agent: it embeds a linguistic-form topological map of explored/observed places directly in the LLM prompt and carries adaptive multi-step planning text across steps. Section 1 dissects how the upstream OneStagePromptManager pipeline actually works; Section 2 documents the AgentCanvas port — the 8-node mapgpt nodeset driving env_mp3d discrete panoramic navigation; Section 3 files every difference into the four delta buckets.
| At a glance | |
|---|---|
| Upstream | MapGPT (24.01), ACL 2024 — repo chen-judge/MapGPT pinned @ 7c642f4 (no upstream LICENSE; see fetch script) |
| Env consumed | env_mp3d — action space waypoint (step_waypoint), obs spaces navigable + panorama (gym-like contract) |
| FM consumed | built-in llmCall (multimodal, profile-driven; reference graph pins gpt-5-mini · temp 1.0 · max_tokens 6000 — upstream pinned gpt-4o JSON mode) |
| Nodes / state | 8 canvas nodes · graph_state entries topo_map / history / planning |
| Graph | workspace/graphs/vln/verified/mapgpt_mp3d.json (22 nodes, 49 edges) |
| Fidelity | faithful with justified deviations + 3 unexplained minor divergences (inventory-first audit 2026-06-11 against 7c642f4): system prompt byte-identical; map-fold / options / parse / budget semantics source-checked; bucket E earned — headline finding: upstream pins image detail:"low", our llmCall sends no detail param (§3E). |
| Status | Verified. Port closure 2026-05-08 (run 675821f9, gpt-4o, 100 ep: SR 0.55 vs paper 0.46). Post-migration re-eval 2026-06-13 (run 20260613_120854, gpt-5-mini, MapGPT72 20 ep): SR 0.55 · SPL 0.394 · nDTW 0.436 — reproduces the closure number on the rewired two-pivot topology; graph promoted to graphs/vln/verified/ (§4). |
1. Upstream method analysis (Chen et al. 2024)
Upstream MapGPT is a rollout script (vln/gpt_agent.py) around one stateful prompt manager (GPT/one_stage_prompt_manager.py). Per step:
- Observe. The simulator returns the current viewpoint, heading, and the navigable candidate viewpoints, each with a per-view panorama image keyed by
pointId. - Grow the map. The manager folds the current viewpoint and candidates into four parallel structures —
nodes_list(every place ever seen; index = Place ID),graph(adjacency, recorded on first visit only),trajectory(visited sequence),node_imgs(one cached image per place). - Serialize the map into language (
make_map_prompt, lines 124–165): aTrajectoryline of Place IDs, aMapblock of “Place i is connected with Places j, k” lines, andSupplementary Info— places seen but never visited (the backtracking menu). Returns"Nothing yet."when empty (line 163). - Build action options (
make_action_prompt/make_action_options): each candidate becomes a phrase"<direction> to Place i which is corresponding to Image i", where the direction word (“turn left”, “go forward”, “turn around”, “go up”…) is computed from the heading delta byget_action_concept(lines 16–39). Options are letter-prefixedA./B./C.…(make_action_options, lines 95–112); fromt ≥ stop_aftera barestopoption is prepended (lines 103–105). The canonical action list always carriesstopat index 0 (make_history, line 117), so while stop is not yet offeredparse_json_actioncompensates by adding +1 to the parsed index (lines 349–351) — the “index-shift trick”. - Assemble the 7-field user prompt (
make_r2r_json_prompts, lines 218–236):Instruction / History / Trajectory / Map / Supplementary Info / Previous Planning / Action options, plus every cached place image packed as"Image i:"-labelled inputs aligned to Place IDs (GPT/api.py:22–44). - Call GPT in JSON mode. The system prompt demands a 3-field JSON response:
{"Thought", "New Planning", "Action": "<letter>"}.parse_json_action(lines 336–352) reads the letter;New Planningis stored and echoed next step asPrevious Planning— this is the “adaptive planning” carry. - Act. Letter → candidate viewpoint; the executed action phrase is appended to
Historyas"step t: <phrase>"(make_history, lines 114–122). A hard budget aborts the episode with STOP when the packed image list exceeds 20 (vln/gpt_agent.py:133–136).
The row layout deliberately mirrors the port diagram in §2.1 — each upstream function sits where its corresponding canvas node sits, so the two figures can be compared box-by-box.
1.1 Upstream invariants worth marking
- Place i ↔ Image i alignment is load-bearing. The map text, the options text, and the image labels all reference the same index into
nodes_list. Any port that re-orders or re-keys places silently breaks the LLM's grounding. - Stop is a gated option, not a free action. Before the threshold the LLM cannot stop; after it,
stopis option A and everything shifts. The parse fallback (defaulting to index 0) therefore means “stop” late in the episode and “first candidate” early — intended behaviour, not a bug. - Adjacency is first-visit-frozen.
graph[vp]is written once, on the first visit tovp— revisits do not update it. - History records action phrases, not viewpoints. What enters
Historyis the chosen option's phrase (“turn left to Place 3 …”), the same string the LLM saw — keeping its self-model consistent. - The image budget is a hard abort. >20 cached images ⇒ forced STOP. Upstream's stated reason is an API limit — “GPT-4o currently does not support queries with more than 20 images” — though it also bounds prompt cost and episode length.
2. The AgentCanvas port
workspace/nodesets/method/mapgpt.py splits the monolithic prompt manager into a per-step pipeline of single-responsibility nodes. The split point that matters: direction phrases need both the current heading and each candidate's heading, which only coexist at the env boundary — so observe computes them immediately and freezes them into candidates_json; downstream nodes never need heading access. (An earlier split that deferred direction computation to a later stage lost the current heading and was abandoned — see the module docstring.)
2.1 Per-step flow
The loop is a standard iterIn/iterOut scope, drawn simplified: observations enter observe through iterIn's dual slots (init_* from seed_nav/seed_pano at step 0, iterout_* thereafter), and step.info triggers the loop_nav/loop_pano re-observations that iterOut collects for the next iteration. Stop: parse_action emits viewpoint_id="STOP", which step_waypoint answers with terminated=true, wired into iter_out.stop (since 2026-06-11; env_mp3d treats STOP as episode-done without moving). image_budget.done is the second writer to iter_out.stop — either signal ends the loop. evaluate now sits in the after-loop band, triggered once by iter_out.final_stop at termination.
2.2 Node inventory
| Node | In | Out | Role |
|---|---|---|---|
mapgpt__system_prompt | — | text:TEXT | Static emitter — verbatim JSON-mode system prompt. Not instantiated by the reference graph, which carries the byte-identical text in planner_llm.config.system_prompt instead. |
mapgpt__observe | viewpoint_id:TEXT, heading:TEXT, navigable_json:TEXT, views:LIST[IMAGE], view_meta:TEXT | current_vp:TEXT, candidates_json:TEXT, candidate_tiles:LIST[IMAGE] | Pure env→features transform: per-candidate direction phrase (heading delta) + candidate image picked as views[view_index] (nearest heading+elevation when the env didn't attach an index). |
mapgpt__update_map | current_vp, candidates_json, candidate_tiles | topo_snapshot:ANY | Sole writer of topo_map — appends trajectory, folds candidates into nodes_list/node_imgs (tile refresh on re-sight), first-visit adjacency; emits merged snapshot for same-iter consumers. |
mapgpt__build_options | topo_snapshot, candidates_json | options_text:TEXT, options_json:TEXT | Letter-prefixed options + machine-readable manifest [{letter, vp, phrase}]; prepends stop when ctx.step ≥ stop_after (slider, default 3). |
mapgpt__render_prompt | instruction, topo_snapshot, options_text | prompt:TEXT, image_list:LIST[IMAGE], image_labels:LIST[TEXT], image_count:TEXT | 7-field user prompt; reads history/planning state; packs cached tiles with "Image i:" labels aligned to Place IDs. |
mapgpt__parse_action | response:TEXT, options_json:TEXT | viewpoint_id:TEXT, action_phrase:TEXT, thought:TEXT, new_planning:TEXT, is_stop:BOOL | Two-tier parse (JSON → regex fallback); letter → manifest record; writes planning state; fallback = record 0 (upstream index-shift semantics); is_stop is unwired in the reference graph — stop reaches the env as viewpoint_id="STOP". |
mapgpt__update_history | action_phrase, thought? | history:TEXT | Appends "step t: <phrase>" to history state — upstream make_history payload shape. |
mapgpt__image_budget | image_count:TEXT | done:BOOL | Guard — emits done=True when count exceeds max_images (slider, default 20); omits the key otherwise so the done-scan doesn't fire early. |
2.3 State & memory
| graph_state entry | Reducer · type · lifetime | Writer(s) | Upstream counterpart |
|---|---|---|---|
topo_map | lastWrite · ANY · run | update_map (sole) | self.nodes_list / .graph / .trajectory / .node_imgs object attrs |
history | lastWrite · TEXT · run | update_history | self.history |
planning | lastWrite · TEXT · run (initial = upstream default-planning string) | parse_action | self.planning |
2.4 Boundary contract
Reasoning-only nodeset — no simulator calls, no model weights. Env side: env_mp3d via reset / observe_navigable / observe_panorama / step_waypoint / evaluate (waypoint action space, navigable + panorama obs spaces per the env contract). FM side: the built-in multimodal llmCall, model selected by LLM profile — no vendor lock-in in the nodeset. This page is boundary-compliant per TODO #56's method/foundation-model split.
2.5 Prompt assets
All prompt text lives as constants in mapgpt.py: the system prompt (verbatim from one_stage_prompt_manager.py:218–236), init-history / default-planning / no-supplementary strings (lines 189, 14, 163). The 7-field user prompt is assembled at runtime by render_prompt with upstream's exact field order and labels. The reference graph additionally duplicates the system prompt byte-identically into planner_llm.config.system_prompt (verified 2026-06-11) — when editing one copy, update the other.
3. Delta vs upstream — four buckets
A. Preserved verbatim
| Element | Upstream anchor | How preserved |
|---|---|---|
| JSON-mode system prompt | one_stage_prompt_manager.py:218–236 | Character-for-character (_SYSTEM_PROMPT) |
| Direction-phrase function | :16–39 (get_action_concept) | Verbatim port (_get_action_concept), radians in/out |
| Map / trajectory / supplementary serialization | :124–165 (make_map_prompt) | Verbatim port (_make_map_text), incl. "Nothing yet." |
History payload shape "step t: <phrase>" | :114–122 (make_history) | Same string; lookup unnecessary because parse_action forwards the exact phrase |
| Init constants (history / planning / no-supp) | :189, :14, :163 | Verbatim strings; planning initial value also declared in the graph's state container |
| Stop gating + index-shift fallback | options assembly + parse_json_action default | Same semantics: stop prepended after threshold; parse fallback resolves to record 0 |
| First-visit adjacency, tile cache, Place-ID indexing | map-update logic (lines ~54–87) | Same fold order in update_map |
"Image i:" label ↔ Place-ID alignment | GPT/api.py:22–44 | render_prompt emits aligned image_labels |
| Image-budget abort semantics | vln/gpt_agent.py:133–136 (>20 ⇒ stop) | image_budget node, default 20 |
B. Forced by substrate (script → typed graph)
| Upstream | Port | Why forced |
|---|---|---|
One OneStagePromptManager object | 4-node pipeline observe → update_map → build_options → render_prompt | Canvas nodes are single-responsibility; the split is dictated by data availability — heading pairs only coexist at the env boundary, so directions are frozen into candidates_json in observe |
| Python object attributes carry state | graph_state entries (topo_map / history / planning), declared in graph JSON | Cross-iteration state must live in visible, checkpointable containers, not node instances |
| Letter→viewpoint resolved inside the manager | options_json manifest wire from build_options to parse_action (STOP rides as sentinel vp="STOP") | Producer and consumer are separate nodes; the mapping must cross a wire as data |
Per-candidate image keyed by pointId crop | views[view_index] from the env's per-view panorama primitive (nearest heading+elevation fallback) | env_mp3d exposes the panorama as an indexed view list; no cropping primitive on the wire surface |
C. Forced by environment / cost
| Upstream | Port | Reason |
|---|---|---|
| Model pinned to gpt-4o (JSON mode) | Profile-driven llmCall; the reference graph pins gpt-5-mini @ temperature 1.0, max_tokens 6000 (gpt-5-family floor: temp 1.0 + max_tokens ≳ 2000, else silently empty replies) | Model availability + search-loop cost; gpt-5-family silently returns empty below these params |
| Headline eval = authors' 72-scene R2R sample | Same cut available as split MapGPT72 (216 ep); loop-progress signal uses val_unseen eps 0–49 | val_unseen front-loads easy scans (a 50-ep prefix overstates SR by 18–22 pp) — slices must be disclosed (§4) |
| Simulator batched upstream | env_mp3d wrapper pinned batch=1, subprocess-per-worker (ADR-server-003) | Our MatterSim wrapper parallelises at the worker level, not the sim-batch level |
D. Intentional divergences
| Upstream | Port | Rationale |
|---|---|---|
JSON-only response parsing (parse_json_action, :336–352) | Two-tier parse: JSON (with markdown-fence / embedded-JSON tolerance) → regex letter fallback | Profile-driven LLMs drift to free-form answers once the JSON instruction leaves recent context; tier 2 recovers the letter instead of burning the step on fallback |
Free-text prompt variant (make_r2r_prompts, :169–187) for gpt-4-vision | Omitted — JSON path only | We never target gpt-4-vision-preview; one path is simpler and the variant is recoverable from upstream |
| Stop threshold + image budget hard-coded | stop_after / max_images exposed as ConfigField sliders (defaults 3 / 20) | Search-loop knobs; defaults preserve upstream behaviour exactly |
E. Unexplained / defects
Inventory-first audit, 2026-06-11 — three findings, none with recorded intent (hence E, not D):
| Element | Upstream | Observed in port | Severity | Tracking |
|---|---|---|---|---|
| Image fidelity sent to the LLM | "detail": "low" pinned on every image (api.py:40) | llmCall has no detail parameter — provider default (auto, typically high) | Moderate — the port's model sees higher-fidelity images than the paper's; changes token cost and is an inflationary confound candidate for the +9 pp closure margin (§4) | Decide: record as intent (→ D) or add a detail knob to llmCall and pin low — candidate TODO |
| Place-tile refresh on re-sight | Unconditional overwrite node_imgs[idx] = cc['image'] (one_stage_prompt_manager.py:76) | update_map refreshes only when the resolved tile is not None — a failed view resolution retains the stale tile | Low — divergent only when observe fails to resolve a candidate view | Note in code review of update_map |
| History write on the stop step | Rollout breaks before make_history on stop (gpt_agent.py:171–174) — history never logs the stop | update_history appends "step t: stop" in the final iteration | None — post-terminal state write, never read by a subsequent prompt | — |
Probed and cleared (adjudicated non-issues): ① previous_angle is the agent's heading at prompt time (set from fresh obs at gpt_agent.py:93/:167) — the port's cand_heading − cur_heading is equivalent despite the misleading upstream name; ② upstream stores plannings as a list but every prompt reads only [-1] (:200/:202, :285) — the port's lastWrite string is behaviour-equivalent; ③ make_history is live on the json path (:174); ④ packed image_list = all cached place images with None skipped — matches render_prompt; ⑤ upstream loop budget max_action_len=15 (parser.py:16) maps to the eval-side step budget, not a nodeset constant. No mapgpt equivalence test exists to run (unlike SmartWay) — eval-parity is the only executable check.
4. Evaluation
| What | Number | Evidence |
|---|---|---|
| Paper-reported (R2R val_unseen, the paper's 72-scene subset) | SR 0.46 | Chen et al. 2024, as recorded in the PortBench method checklist |
| Our port, closure run (gpt-4o, 100 ep, R2R val_unseen) | SR 0.55 | run 675821f9, 2026-05-08 — archived as outputs/archive/mapgpt_r2r_100ep_2026-05-08/ (pruned from disk 2026-05-21 — the run_id is the surviving citation); stop_after=3, step_budget=30, 10 workers. Checklist's own caveat: +9 pp sits within 100-ep sample variance plus the tier-2 regex-parser advantage; the run also pre-dates the 2026-05-11 stratification finding, so a val_unseen 100-ep prefix likely runs high (see callout). |
| MapGPT72 full 216-ep (AAS iter_3 evolved graph, gpt-5-mini era) | SR 0.500 (95% CI 0.435–0.565) | 2026-05-11 stratification study; same study: val_unseen eps 0–199 = 0.455 |
Slice discipline. R2R val_unseen is ordered easy-first: eps 0–49 run ~18–22 pp above the full split. Quote headline numbers on MapGPT72 (216 ep, the paper's own random sample — split auto-discovered from R2R_MapGPT72.json); never quote a val_unseen prefix without naming the ep range.
Comparability caveat (from §3E): upstream sends every image at detail:"low"; our llmCall sends no detail parameter, so the port's model sees higher-fidelity images than the paper's. Treat the +9 pp closure margin as partially confounded until the detail divergence is resolved.
Known gap — closed structurally (2026-06-11): the loop-body evaluate pattern that under-counted SR after the terminal STOP step (roadmap TODO #64) was eliminated by the two-sided-iterOut refactor: evaluate is now after-loop, fed exactly once at termination via final_stop, so the harvested output_port__metrics reflects the terminal step by construction. The 2026-06-13 re-eval confirms it: run 20260613_120854 harvested SR 0.55 (11/20) with every episode logging real decision steps and zero step_count=0 completions.
Current status: mapgpt_mp3d.json was promoted to workspace/graphs/vln/verified/ on 2026-06-13 after the post-migration re-eval (run 20260613_120854: gpt-5-mini, MapGPT72 20 ep, SR 0.55 reproducing the closure number) confirmed the gym-rewired surface (observe_navigable / observe_panorama / step_waypoint) and the two-pivot harvest behave correctly. A full 216-ep MapGPT72 run remains the headline-number target.
5. Usage
# load (canvas: NodeSet Manager page, or REST)
POST /api/components/nodesets/mapgpt/load # local mode; env_mp3d loads in server mode
# graph
workspace/graphs/vln/verified/mapgpt_mp3d.json # open in canvas, or submit via eval
# batch eval — graph-only form: /experiment:run <profile> <graph_name> [key=value ...]
# (the legacy "profile -- command" form was removed 2026-05-07)
/experiment:run perf_mapgpt_mp3d mapgpt_mp3d split=MapGPT72 episode_count=216 worker_count=4
6. What this nodeset is NOT
- Not the metric-grid MapGPT (roadmap M2): that variant builds an occupancy map from depth. This nodeset is the paper's linguistic-topo-map method, used for PortBench v1.
- Not env logic: no MatterSim calls, no episode control — all simulator interaction goes through
env_mp3dwires. - Not model-locked: the upstream gpt-4o pin became an LLM-profile choice; the prompt contract is the only model-facing assumption.
7. Source files
| File | Purpose |
|---|---|
workspace/nodesets/method/mapgpt.py | The nodeset — 8 nodes, verbatim constants, helpers (983 lines; module docstring is the design note) |
workspace/graphs/vln/verified/mapgpt_mp3d.json | The agent graph (22 nodes / 49 edges / 1 state container) |
workspace/nodesets/_upstream/mapgpt/fetch_upstream.sh | Clones upstream @ 7c642f4 into a gitignored upstream/ for diffing (no LICENSE upstream — fair-use attribution) |
workspace/nodesets/env/env_mp3d/__init__.py | The env nodeset this method drives (doc) |