AgentCanvas / Pages / Developer Guide / Nodesets / Method / ExploreEQA
2026-06-13

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
UpstreamExplore 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 consumedenv_hmeqareset (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 consumedvlm_prismatic__score_tokens × 4 instances (pred / rel / LSV / GSV), Prismatic prism-dinosiglip+7b; degraded mode = uniform distribution when the VLM isn't loadable
Method nodesetexplore_eqa (8 nodes, server-mode in the ac-hmeqa conda env, co-located with env_hmeqa) + vendored _explore_eqa_tsdf.py (TSDFPlanner, BSD-2)
StateSubprocess-local module dicts _TSDF_PLANNERS / _SCORE_HISTORY keyed by on-wire episode_idnot graph_state containers (§2.3)
Graphworkspace/graphs/eqa/verified/explore_eqa_hmeqa.json (26 nodes, 79 edges; step_budget 200 failsafe — real budget is env-derived int(√scene_size·3))
Fidelityfaithful 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)
StatusVerified. 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:

  1. Question formatting (:84–89): vlm_question = question + "\nA. <choice>" … for the fixed candidates A/B/C/D.
  2. Budget + TSDF init (:132–145): num_step = int(√scene_size · max_step_room_size_ratio) (ratio 3 in cfg/vlm_exp.yaml); TSDFPlanner(vol_bnds, voxel_size=0.1, init_clearance=0.5·2).
  3. 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 as pred = uniform(4), rel = [0.01, 0.99] (:306–309) — a near-zero-weight placeholder in the final vote.
  4. 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).
  5. 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]).
  6. Frontier visual prompting (:213–263): find_prompt_points_within_view returns candidate pixels; if at least min_num_prompt_points (2) are found, grey circles (radius 18, fill (200,200,200), outline width 3) with black letters (OpenSans 30, anchor mm) are drawn on a copy of the view.
  7. 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_F with gsv_T=0.5, gsv_F=3, asked on the unannotated image. sv = lsv·gsv is written into the value volume via integrate_sem(sv, radius=1.0) (:297–301).
  8. 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).
  9. Post-hoc vote (:337–354): weighted matrix rows P(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.
habitat-sim rollout RGB-D + sensor poseget_sensor_observations TSDF integrateblack gate :182 · :185 frontier pointsfind_prompt_points :216 draw A–D lettersr18 · OpenSans 30 :235 VLM pred + relget_loss :202 · :210 VLM LSVletters · ×n/3 :277 VLM GSVexp(P/0.5)/3 :288 integrate_semsv=lsv·gsv · r=1.0 :297 raw view annotated view sv agent.set_stateteleport + rotate find_next_posefrontier-weighted :313 value volume next pose result["step_t"] smx_vlm_pred · smx_vlm_rel per-step probs, Python dict in-process step budgetnum_step = √size·3 weighted votepost-hoc :337–354 successweighted · max variants letter for cnt_step in range(num_step) — next step re-observes simulator side planner / script fn VLM get_loss rollout guard in-process state data flow state read/write rollout loop (next t)

1.1 Upstream invariants worth marking

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

env_hmeqa (seed + loop observe) observe_egocentricrgb·depth·pose·intr tsdf_updateblack skip → ok=false episode_id frontier_preletters + 2 bundles build_questionvlm_question A–D score_tokens ×2pred A–D · rel Yes/No score_tokens LSVannotated · letters score_tokens GSVbase · Yes/No frontier_postsv=lsv·gsv → TSDF vlm_score_pre bundles probs step_poseteleport · env_hmeqa next_posefind_next_pose JSON action _SCORE_HISTORY · _TSDF_PLANNERS keyed by episode_id (wire TEXT) module dicts in the hmeqa subprocess iterOut · stoptruncated @ num_step aggregate_answerweighted vote post-loop evaluateenv_hmeqa · success final_episode_id · choices · answer_gt pred_letter iterOut → iterIn — rgb·depth·cam_pose·pose_normal·angle·step_index persist across steps env_hmeqa node explore_eqa node vlm_prismatic call loop control subprocess state data wire state access loop-back (iterOut → iterIn)

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

NodeInOutRole
explore_eqa__build_questionquestion, choicesvlm_questionFormat Q + A/B/C/D lines (run_vlm_exp.py:84–89).
explore_eqa__vlm_score_prergb, vlm_question, question2 × (image/prompt/tokens) for pred + relBuild both scoring bundles; prompts byte-identical to :197–211.
explore_eqa__vlm_score_postpred_probs, rel_probs, episode_idpass-through probsAppend per-step probs to _SCORE_HISTORY; empty-probs fallback = [0.25×4] / [0.01,0.99] (upstream's placeholder values).
explore_eqa__tsdf_updatergb, depth, cam_pose, cam_intr, pose_normal, episode_id, tsdf_bndsepisode_id, okBuilds 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_prergb, question, episode_id, cam_intr, cam_pose, pose_normalannotated_image, 2 × (image/prompt/tokens), num_candidates, skipFrontier 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_postlsv_probs, gsv_probs, num_candidates, skip, episode_idfrontier_scores, oksv = (lsv·n/3) · exp(P(Yes)/0.5)/3, then integrate_sem(sv, radius=1.0) — formula and constants source-checked.
explore_eqa__next_poseepisode_id, pose_normal, angle, step_indexaction (JSON), next_pose_normal, next_anglefind_next_pose with flag_no_val_weight = step < 2; emits {"position_normal":[x,y],"angle":θ} — floor height appended env-side.
explore_eqa__aggregate_answerepisode_id, choices, answer_gt?pred_letter, pred_text, success_weighted, success_max, num_steps_scored, metricsDrains _SCORE_HISTORY; both upstream vote variants reproduced exactly (:337–354).

2.3 State & memory

StateWhere · lifetimeWriter(s)Upstream counterpart
_TSDF_PLANNERS[episode_id]module dict, ac-hmeqa subprocess · retained until subprocess shutdowntsdf_update (build), frontier_post (integrate_sem), next_pose (read)tsdf_planner local in the rollout
_SCORE_HISTORY[episode_id]module dict · retained until subprocess shutdownvlm_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

ElementUpstream anchorHow preserved
All four scoring prompts (pred/rel/LSV/GSV)run_vlm_exp.py:197–200, :208, :270, :285Byte-identical rendered strings (checked 2026-06-11)
Question formatting "\nA. <choice>":84–89Verbatim 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_FVerbatim in frontier_post (_GSV_T=0.5, _GSV_F=3.0)
Both vote variants (max-pool argmax; best-relevancy-step argmax):337–354Verbatim numpy in aggregate_answer
Visual-prompt drawing (r18, grey fill, outline 3, OpenSans 30, anchor mm, dead font_size=12):235–263 + yamlVerbatim 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 + yamlDefaults 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.pyVendored into _explore_eqa_tsdf.py (BSD-2); semantic diff = matplotlib stripping + lazy imports + yaml-pinned defaults (D)

B. Forced by substrate (script → typed graph)

UpstreamPortWhy forced
In-process vlm.get_loss callsPre/post node pairs around 4 vlm_prismatic__score_tokens instancesFoundation 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 subprocessnumba volumes can't cross JSON IPC; containers unusable for live objects (§2.3)
Matplotlib debug figures returned by planner fnsStripped from the vendored planner; frontier_pre re-draws the annotation with PIL onto a dedicated annotated_image portLong-lived headless subprocess; figures leak
geom.get_scene_bnds / get_cam_intrRe-homed into env_hmeqaThey 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

UpstreamPortReason
Sequential per-question script, sim rebuilt per episodeBatch 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 pathdata/hm3d/hmeqa/ layout incl. the OpenSans font shipped with the datasetBox-local data layout (ADR-platform-005)

D. Intentional divergences

UpstreamPortRationale (recorded where)
tsdf.py code defaults ≠ yaml (yaml passed via **cfg)Vendored signatures re-pinned to the yaml valuesInline 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 shutdownRace 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 absencenext_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

#FindingUpstreamPortSeverity
E1Black-frame step only partially skippedBlack 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.
E2next_pose.step_index double-wiredcnt_step is a single loop variableBoth 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

WhatNumberEvidence
Paper-reported≈ 0.58Full 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 stepsrun 20260525_160347 (myloop v0 iter_0 no-patch baseline, perf_explore_eqa_hmeqa, success_weighted)
Early 50-ep run0.42 weighted / 0.44 maxarchived 2026-05-05 as outputs/archive/explore_eqa_hmeqa_50ep_2026-05-05/28570a4e.jsonpruned 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_answerevaluate), 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

7. Source files

FilePurpose
workspace/nodesets/method/explore_eqa.pyThe 8-node method nodeset (905 lines)
workspace/nodesets/method/_explore_eqa_tsdf.pyVendored 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.pyFoundation-model side — score_tokens + degraded mode
workspace/graphs/eqa/verified/explore_eqa_hmeqa.jsonReference agent graph (this page's §2.1 authority)
workspace/nodesets/_upstream/explore-eqa/fetch_upstream.shClones 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)
AgentCanvas docs