ExploreEQA on AgentCanvas — paper analysis + explore_eqa port
Explore until Confident (Ren et al. 2024; repo Stanford-ILIAD/explore-eqa) is a zero-shot embodied-QA agent: a Prismatic VLM scores token likelihoods — answer letters A/B/C/D and a Yes/No relevancy signal — every step, a semantic-value TSDF turns VLM "which direction would you explore?" answers into frontier weights, and a post-hoc weighted vote over the whole trajectory emits the final letter. This page documents the 8-node explore_eqa method nodeset plus the vendored TSDFPlanner driving env_hmeqa in explore_eqa_hmeqa.json, with all four per-step VLM calls decoupled onto vlm_prismatic__score_tokens wires.
| At a glance | |
|---|---|
| Upstream | Explore until Confident (Ren et al. 2024) — repo Stanford-ILIAD/explore-eqa pinned @ 18381da. Core rollout: run_vlm_exp.py (411 lines) + src/tsdf.py + cfg/vlm_exp.yaml. |
| Env consumed | env_hmeqa — reset (metadata: question/choices/answer/tsdf_bnds/per-episode budget), observe_egocentric (RGB-D + cam_pose_matrix/pose_normal/angle/intrinsics), step_pose (teleport JSON; env appends floor height), evaluate (gym-like contract) |
| FM consumed | vlm_prismatic__score_tokens × 4 instances (pred / rel / LSV / GSV), Prismatic prism-dinosiglip+7b; degraded mode = uniform distribution when the VLM isn't loadable |
| Method nodeset | explore_eqa (8 nodes, server-mode in the ac-hmeqa conda env, co-located with env_hmeqa) + vendored _explore_eqa_tsdf.py (TSDFPlanner, BSD-2) |
| State | Subprocess-local module dicts _TSDF_PLANNERS / _SCORE_HISTORY keyed by on-wire episode_id — not graph_state containers (§2.3) |
| Graph | workspace/graphs/eqa/verified/explore_eqa_hmeqa.json (26 nodes, 79 edges; step_budget 200 failsafe — real budget is env-derived int(√scene_size·3)) |
| Fidelity | faithful with justified deviations + 2 unexplained minor divergences (inventory-first audit 2026-06-11 against 18381da): all four prompts byte-identical; scoring math and vote formulas source-checked; headline: on a black frame the port skips only the TSDF integration where upstream skips the whole scoring block and pins low-relevancy placeholders (§3E) |
| Status | Verified. Loop rebuilt 2026-06-13 (3 fixes — the graph was silently loop-broken since the iterIn + gym refactors, never re-run; §4). Confirm run 20260613_151701 (50 ep HM-EQA val): SR 0.42 (95% CI 0.28–0.56), reproducing the 0.44 baseline (run 20260525_160347); every episode harvested a verdict and stopped at its scene-natural horizon. Also carries the final_fire incident history (§4). |
1. Upstream method analysis (Ren et al. 2024)
Upstream is a single rollout script, run_vlm_exp.py, around a TSDFPlanner (src/tsdf.py) and a Prismatic VLM exposing get_loss(image, prompt, tokens) → probs. Per episode it formats the question, derives a scene-size step budget, and loops:
- Question formatting (
:84–89):vlm_question = question + "\nA. <choice>" …for the fixed candidates A/B/C/D. - Budget + TSDF init (
:132–145):num_step = int(√scene_size · max_step_room_size_ratio)(ratio 3 incfg/vlm_exp.yaml);TSDFPlanner(vol_bnds, voxel_size=0.1, init_clearance=0.5·2). - Black-frame gate (
:179–182): if black pixels ≥0.5·W·H(robot outside the floor mesh) the entire scoring + mapping block is skipped and the step is recorded aspred = uniform(4),rel = [0.01, 0.99](:306–309) — a near-zero-weight placeholder in the final vote. - TSDF fusion (
:185–193):integrate(rgb, depth, cam_intr, cam_pose_tsdf, obs_weight=1.0, margin_h=0.6·H, margin_w=0.25·W). - Answer + relevancy scoring (
:197–211): pred =get_loss(rgb, vlm_question + "\nAnswer with the option's letter from the given choices directly.", [A,B,C,D]); rel =get_loss(rgb, "…Are you confident about answering the question with the current view? Answer with Yes or No.", [Yes,No]). - Frontier visual prompting (
:213–263):find_prompt_points_within_viewreturns candidate pixels; if at leastmin_num_prompt_points(2) are found, grey circles (radius 18, fill(200,200,200), outline width 3) with black letters (OpenSans 30, anchormm) are drawn on a copy of the view. - LSV + GSV (
:269–294): LSV =get_loss(annotated, "…Which direction (black letters on the image) would you explore then? Answer with a single letter.", letters[:n]) · n/3; GSV =exp(P(Yes)/gsv_T)/gsv_Fwithgsv_T=0.5, gsv_F=3, asked on the unannotated image.sv = lsv·gsvis written into the value volume viaintegrate_sem(sv, radius=1.0)(:297–301). - Next pose (
:312–321):find_next_pose(pts, angle, flag_no_val_weight = step < min_random_init_steps, **cfg.planner)— frontier-weighted sampling (unexplored-rate · unoccupied-rate ·exp(val/T)weights, Gaussian-smoothed value map); the first 2 steps ignore value weights (random-walk init). The agent is teleported (agent.set_state). - Post-hoc vote (
:337–354): weighted matrix rowsP(Yes)_t · pred_t;success_weighted= argmax of the per-letter max over steps;success_max= argmax of the weighted row at the highest-relevancy step. Both variants are reported.
1.1 Upstream invariants worth marking
- Everything is token likelihood, never generation. All four calls go through
get_loss(image, prompt, tokens)— letters, Yes/No. There is no free-form decoding anywhere in the loop. - The black frame is an outside-the-floor sentinel. It skips the whole block — no fusion, no scoring, no frontier work — and the step enters the vote as
pred=uniform, rel=[0.01,0.99], i.e. with ~1% weight (:306–309). - Answering is free. The final letter comes from a post-hoc vote over stored per-step probs (
:337–354); no extra exploration or VLM call happens at episode end. - The yaml overrides the code.
tsdf.pyfunction defaults differ fromcfg/vlm_exp.yaml(e.g.unexplored_T0.5 vs 0.2); upstream always passes**cfg.planner/**cfg.visual_prompt, so the yaml values are what actually ran. - The step budget is scene-derived:
int(√scene_size · 3)— small rooms get short episodes. The paper's conformal-prediction stopping ("until confident") is not in the released code; the rollout always runs the full budget. init_clearanceis doubled at the call site (:144): the yaml says 0.5, the planner receives 1.0.
2. The AgentCanvas port
The port splits the rollout into 8 reasoning/mapping nodes and moves every VLM call onto wires: each of the four get_loss call sites becomes a vlm_prismatic__score_tokens canvas-node instance sitting between a pre node (emits image / prompt / tokens) and a post node (consumes probs). The nodeset runs server-mode in the ac-hmeqa conda env, co-located with env_hmeqa, because the vendored TSDFPlanner holds numba-JIT'd volumes that cannot cross JSON IPC. Movement is the upstream teleport, executed env-side by env_hmeqa__step_pose.
2.1 Per-step flow
Drawn simplified in four places. ① The pre/post pairs are collapsed: vlm_score_pre fans the raw view into two score_tokens instances (pred and rel) whose probs land in vlm_score_post, which appends them to _SCORE_HISTORY[episode_id]; frontier_pre does the same for LSV (annotated image) and GSV (base image) into frontier_post. ② The loop carry is heavy, the opposite of SmartWay: iter_out persists rgb / depth / cam_pose / pose_normal / angle / step_index / episode_id / choices / answer_gt from loop_obs (a second observe_egocentric triggered by env_step.info); run-invariants (question / cam_intr / tsdf_bnds …) ride iter_in init ports (persist=true). ③ The loop ends at the env’s per-scene exploration horizon: env_step sets truncated=true when step_index ≥ num_step, wired as the single iter_out.stop feeder (two-pivot model, no separate termination node). Rebuild note (2026-06-13): three loop bugs from the iterIn+gym refactors were fixed — the body’s observation inputs are now dual-wired (init_* for iteration 0 + iterout_* after, init ports persist=false); iter_out’s five loop_obs-sourced carry ports are marked required so the boundary waits for the end-of-body observation instead of firing early on env_step’s partial output; and the num_step horizon stop (lost when the gym migration split the old done into error-only terminated) was restored via truncated. ④ Post-loop, iter_out.final_episode_id / final_choices / final_answer_gt fire aggregate_answer, whose pred_letter then fires evaluate — a pivot-rooted cascade, which is the sound shape (contrast the final_fire incident in §4). One wart: next_pose.step_index is double-wired from both iter_in.iterout_step_index and iter_in.step (§3E).
2.2 Node inventory
| Node | In | Out | Role |
|---|---|---|---|
explore_eqa__build_question | question, choices | vlm_question | Format Q + A/B/C/D lines (run_vlm_exp.py:84–89). |
explore_eqa__vlm_score_pre | rgb, vlm_question, question | 2 × (image/prompt/tokens) for pred + rel | Build both scoring bundles; prompts byte-identical to :197–211. |
explore_eqa__vlm_score_post | pred_probs, rel_probs, episode_id | pass-through probs | Append per-step probs to _SCORE_HISTORY; empty-probs fallback = [0.25×4] / [0.01,0.99] (upstream's placeholder values). |
explore_eqa__tsdf_update | rgb, depth, cam_pose, cam_intr, pose_normal, episode_id, tsdf_bnds | episode_id, ok | Builds the per-episode TSDFPlanner on first call (init_clearance·2 preserved) and integrates RGB-D; black-frame check skips integration only (§3E). |
explore_eqa__frontier_pre | rgb, question, episode_id, cam_intr, cam_pose, pose_normal | annotated_image, 2 × (image/prompt/tokens), num_candidates, skip | Frontier candidate pixels + letter drawing (all upstream constants); below min_num_prompt_points emits empty token lists so the score_tokens nodes short-circuit (upstream gate :241). |
explore_eqa__frontier_post | lsv_probs, gsv_probs, num_candidates, skip, episode_id | frontier_scores, ok | sv = (lsv·n/3) · exp(P(Yes)/0.5)/3, then integrate_sem(sv, radius=1.0) — formula and constants source-checked. |
explore_eqa__next_pose | episode_id, pose_normal, angle, step_index | action (JSON), next_pose_normal, next_angle | find_next_pose with flag_no_val_weight = step < 2; emits {"position_normal":[x,y],"angle":θ} — floor height appended env-side. |
explore_eqa__aggregate_answer | episode_id, choices, answer_gt? | pred_letter, pred_text, success_weighted, success_max, num_steps_scored, metrics | Drains _SCORE_HISTORY; both upstream vote variants reproduced exactly (:337–354). |
2.3 State & memory
| State | Where · lifetime | Writer(s) | Upstream counterpart |
|---|---|---|---|
_TSDF_PLANNERS[episode_id] | module dict, ac-hmeqa subprocess · retained until subprocess shutdown | tsdf_update (build), frontier_post (integrate_sem), next_pose (read) | tsdf_planner local in the rollout |
_SCORE_HISTORY[episode_id] | module dict · retained until subprocess shutdown | vlm_score_post (append), aggregate_answer (read) | result["step_t"]["smx_vlm_*"] |
This nodeset deliberately does not use graph_state containers: TSDFPlanner holds numba-JIT'd volumes and lambda-captured state that cannot cross the JSON IPC boundary containers use in server mode. The on-wire episode_id plays the container-key role instead. Entries are retained across episodes — a recorded race fix (2026-05-03): a global .clear() in the planner builder used to wipe sibling episodes' history under parallelism="shared" with worker_count > 1. Bounded growth ≈ 30 MB × ≤50 episodes per run, dropped at subprocess teardown.
2.4 Boundary contract
Three subprocesses, all under the ac-hmeqa env ($HMEQA_PYTHON): env_hmeqa (habitat-sim, ×N workers), explore_eqa (this nodeset, singleton, co-located TSDF), vlm_prismatic (Prismatic VLM, singleton). Env verbs consumed: reset (metadata incl. tsdf_bnds and the per-episode budget surfaced through the framework's step_budget resolver — num_step = int(√scene_size·3) computed env-side, env_hmeqa/__init__.py:466), seed + loop observe_egocentric, step_pose (teleport; env appends floor_height and converts normal→habitat frame), evaluate. Foundation-model boundary: method nodes never load a VLM — any nodeset exposing score_tokens(image, prompt, tokens) → probs can replace vlm_prismatic; its degraded mode (uniform probs when the model can't load) keeps the graph structurally runnable without a GPU.
2.5 Prompt assets
All four prompts are inline f-strings in explore_eqa.py, byte-checked against upstream this audit: pred (run_vlm_exp.py:197–200), rel (:208 — split across a Python string concatenation in the port, rendered output identical), LSV (:270), GSV (:285). Drawing constants _DRAW_LETTERS / _CIRCLE_RADIUS=18 / _FONT_SIZE=30 mirror :235–263 + cfg.visual_prompt.circle_radius, including upstream's odd no-op font_size=12 argument alongside the size-30 font object.
3. Delta vs upstream — four buckets
A. Preserved verbatim
| Element | Upstream anchor | How preserved |
|---|---|---|
| All four scoring prompts (pred/rel/LSV/GSV) | run_vlm_exp.py:197–200, :208, :270, :285 | Byte-identical rendered strings (checked 2026-06-11) |
Question formatting "\nA. <choice>" | :84–89 | Verbatim in build_question |
LSV scale ·n/3; uniform fallback; GSV exp(P(Yes)/0.5)/3; sv=lsv·gsv; integrate_sem(radius=1.0) | :277, :279–281, :288–293, :297–301 + yaml gsv_T/gsv_F | Verbatim in frontier_post (_GSV_T=0.5, _GSV_F=3.0) |
| Both vote variants (max-pool argmax; best-relevancy-step argmax) | :337–354 | Verbatim numpy in aggregate_answer |
Visual-prompt drawing (r18, grey fill, outline 3, OpenSans 30, anchor mm, dead font_size=12) | :235–263 + yaml | Verbatim constants in frontier_pre |
TSDF margins/ratios (0.6·H, 0.25·W), black-pixel ratio 0.5, voxel 0.1, init_clearance·2, min_random_init_steps=2, min prompt points 2 | :144, :182, :185–193, :241, :316 + yaml | Defaults in node configs; ·2 preserved at the builder (explore_eqa.py:152) |
| TSDFPlanner core (integrate, integrate_sem, frontier finding, next-pose sampling) | src/tsdf.py + src/geom.py | Vendored into _explore_eqa_tsdf.py (BSD-2); semantic diff = matplotlib stripping + lazy imports + yaml-pinned defaults (D) |
B. Forced by substrate (script → typed graph)
| Upstream | Port | Why forced |
|---|---|---|
In-process vlm.get_loss calls | Pre/post node pairs around 4 vlm_prismatic__score_tokens instances | Foundation models must be swappable, wire-visible primitives (TODO #50, closed 2026-05-05); Prismatic cannot live in the method subprocess |
Script locals (tsdf_planner, result) | episode_id-keyed module dicts in a co-located subprocess | numba volumes can't cross JSON IPC; containers unusable for live objects (§2.3) |
| Matplotlib debug figures returned by planner fns | Stripped from the vendored planner; frontier_pre re-draws the annotation with PIL onto a dedicated annotated_image port | Long-lived headless subprocess; figures leak |
geom.get_scene_bnds / get_cam_intr | Re-homed into env_hmeqa | They need the habitat pathfinder / sensor spec — env-side knowledge |
Loop variables (pts, angle, cnt_step) | iterOut persist ports (rgb…step_index) | Cross-iteration values must ride the loop pivots |
C. Forced by environment / cost
| Upstream | Port | Reason |
|---|---|---|
| Sequential per-question script, sim rebuilt per episode | Batch eval via JobScheduler profiles (perf_explore_eqa_hmeqa, 100-ep HM-EQA val; worker_count up to 4) | Throughput; per-episode budget delivered through the env→graph resolver chain instead of a script variable |
| Local HM3D-semantic train scenes path | data/hm3d/hmeqa/ layout incl. the OpenSans font shipped with the dataset | Box-local data layout (ADR-platform-005) |
D. Intentional divergences
| Upstream | Port | Rationale (recorded where) |
|---|---|---|
tsdf.py code defaults ≠ yaml (yaml passed via **cfg) | Vendored signatures re-pinned to the yaml values | Inline comments per parameter citing cfg/vlm_exp.yaml line numbers (_explore_eqa_tsdf.py) — the values that actually ran are now the defaults |
| — | Per-episode state retained across episodes until subprocess shutdown | Race fix 2026-05-03, recorded in the module docstring + DO NOT add a global .clear() comment (parallel workers share the singleton) |
| — | vlm_prismatic degraded mode (uniform probs, no GPU) | Recorded in the vlm_prismatic page; structural smoke tests without model weights |
| Crash on planner absence | next_pose fallback action (rotate in place +0.1 rad) | Defensive port extra, logged via _self_log("error") — keeps the episode harvestable instead of wedging the loop |
E. Unexplained / defects
| # | Finding | Upstream | Port | Severity |
|---|---|---|---|---|
| E1 | Black-frame step only partially skipped | Black frame skips fusion and all four VLM calls and frontier work; the step votes as pred=uniform, rel=[0.01,0.99] (run_vlm_exp.py:179–182, :306–309) | tsdf_update skips only the integration (ok=false, no gate wire); pred/rel are scored on the black image and written to history with their real probabilities, and frontier scoring still runs against the stale volume. No recorded intent found. | Low–Moderate — a black step enters the vote with arbitrary instead of ~1% weight; frequency is low (outside-floor poses). Candidate fix: wire tsdf_update.ok as a gate into vlm_score_pre/frontier_pre, or pin the placeholder in vlm_score_post when ok=false. |
| E2 | next_pose.step_index double-wired | cnt_step is a single loop variable | Both iter_in.iterout_step_index (env step count, persisted from the previous iter) and iter_in.step (pivot pulse) feed the same input port; which value wins depends on arrival order, an off-by-one around the step < 2 random-init gate. No recorded intent. | Low — affects at most which two early steps random-walk. |
Probed and cleared: rel prompt byte-equality across the port's string split; VLMScorePost empty-probs fallback reproducing upstream's exact placeholder values; vote formulas against :337–354 (incl. the subtle per-letter max-pool); init_clearance·2; drawing constants incl. the dead font_size=12; vendored find_next_pose / find_prompt_points_within_view semantic diff (matplotlib stripping + yaml-pinned defaults only); integrate_sem body identical. No port equivalence test exists — eval parity (§4) is the only executable check.
4. Evaluation
| What | Number | Evidence |
|---|---|---|
| Paper-reported | ≈ 0.58 | Full 500-ep HM-EQA val, same model + max-step protocol, as cited from the paper's Fig. 7a in the 2026-05-05 revision of this page (not re-verified against the PDF this audit) |
| Locked baseline (100 ep HM-EQA val) | SR 0.44 · avg 37.9 steps | run 20260525_160347 (myloop v0 iter_0 no-patch baseline, perf_explore_eqa_hmeqa, success_weighted) |
| Early 50-ep run | 0.42 weighted / 0.44 max | archived 2026-05-05 as outputs/archive/explore_eqa_hmeqa_50ep_2026-05-05/28570a4e.json — pruned from disk 2026-05-21 (archive_outputs --prune); numbers per the 2026-05-05 page revision. Wilson 95% CI [0.28, 0.56] vs paper 0.58 |
Incident history (the final_fire lesson): commit bc6aa8f (2026-05-11) set final_fire=true on env_hmeqa__evaluate. The executor's _final_fire_pass re-fires flagged nodes alone, without their upstream nodes — so evaluate re-fired with pred_letter=None and SR collapsed 0.43 → 0.0 silently for 9 days (2026-05-11 → 2026-05-20). Fixed by removing the flag. The current graph uses the sound shape instead: the post-loop chain is rooted at the iterOut pivot (final_* ports → aggregate_answer → evaluate), so every link re-fires with real inputs.
Current status: explore_eqa_hmeqa.json was promoted to workspace/graphs/eqa/verified/ on 2026-06-13 after the loop rebuild + confirm run (20260613_151701, 50 ep, SR 0.42). The graph had been silently loop-broken since the iterIn + gym refactors (marked verified then but never re-run); the rebuild restored a correctly-iterating loop that harvests a verdict on every episode and stops at the scene-natural horizon (§2.1 rebuild note). The smartway-class TODO #64 harvest miss does not apply (evaluate is post-loop, not step-triggered), now confirmed by the clean harvest. Profile caveat: perf_explore_eqa_hmeqa’s declared 10 workers crashed at cold start (10 simultaneous habitat GL contexts); 5 workers ran clean — the 10-worker calibration needs revisiting.
5. Usage
# load — method + env + foundation model (all server-mode, hmeqa env)
POST /api/components/nodesets/explore_eqa/load?mode=server
POST /api/components/nodesets/env_hmeqa/load?mode=server
POST /api/components/nodesets/vlm_prismatic/load?mode=server
# graph
workspace/graphs/eqa/verified/explore_eqa_hmeqa.json
# batch eval — graph-only form: /experiment:run <profile> <graph_name> [key=value ...]
/experiment:run perf_explore_eqa_hmeqa explore_eqa_hmeqa # 100 ep HM-EQA val
/experiment:run smoke_explore_eqa_hmeqa explore_eqa_hmeqa episode_count=3
6. What this nodeset is NOT
- Not "until confident". The paper's conformal-prediction calibrated stopping is not in the upstream release either; both upstream and the port run the scene-derived fixed budget. The name describes the paper, not the shipped code.
- Not a Prismatic wrapper. Method nodes emit
(image, prompt, tokens)bundles; anyscore_tokensprovider (LLaVA, Qwen-VL, …) slots in without touching method code. - Not env logic. Teleport execution, floor-height handling, scene bounds, and the per-episode budget all live in
env_hmeqa. - Not an answer action. The letter comes from the post-hoc vote; nothing is "spoken" during the episode (faithful to upstream).
7. Source files
| File | Purpose |
|---|---|
workspace/nodesets/method/explore_eqa.py | The 8-node method nodeset (905 lines) |
workspace/nodesets/method/_explore_eqa_tsdf.py | Vendored TSDFPlanner + geometry (838 lines, BSD-2; underscore = skipped by registry scan) |
workspace/nodesets/env/env_hmeqa/ | Env side — scene bounds, budget formula (:466), teleport, evaluate |
workspace/nodesets/model/vlm_prismatic.py | Foundation-model side — score_tokens + degraded mode |
workspace/graphs/eqa/verified/explore_eqa_hmeqa.json | Reference agent graph (this page's §2.1 authority) |
workspace/nodesets/_upstream/explore-eqa/fetch_upstream.sh | Clones upstream @ 18381da into a gitignored upstream/ |
outputs/design_runs/{myloop,aflow,adas-subagent}/explore_eqa_hmeqa/ | AAS search runs on this graph (baseline evidence in §4) |