← Task 분류 (Finding / Resuming / Restoring / Routine)
TL;DR
- subgoal 스키마의 근거를 코드에서 확보했다. MolmoSpaces가 정책에 실제로 넣는 프롬프트는
pick/open/close/pick_and_place/packing 5종뿐이고, pick_and_place는 PICK+PLACE 한 덩어리다. 이 둘이 스키마를 결정한다.
- 핵심 단순화: 과거는 조건별, 현재는 공통. 메모리(과거) = blind / 텍스트로그 / 프레임 세 조건, 현재 상태 = 세 조건 모두 심볼릭 텍스트. 이러면 물리 롤아웃 없이 free-run이 되고 세 조건이 깨끗하게 비교된다. 첫 파일럿에서 시뮬레이터를 안 돌려도 된다.
- VLM 출력은 구조화 JSON, VLA 문구는 rule-base 렌더링. VLM에게 정책 문구를 직접 뱉게 하면 채점이 문자열 매칭 지옥이 된다.
- 메인 메트릭은 Target Accuracy — 첫
navigate 목적지 AND pick 대상 인스턴스. Finding에서 "서랍을 닫고 나왔는가"는 채점 대상이 아니다.
- D게이트가 진짜 게이트: blind ≪ 프레임 ≪ 텍스트로그(oracle) 갭이 골든 20개에서 안 보이면 그 task는 메모리를 안 재고 있는 것이다. v1이 두 번 놓친 지점.
0 확정된 것
출력 방식 = step-wise 1개씩. 매 턴 [현재상태 + 메모리 + 이미 실행한 trace]를 주고 다음 subgoal 1개만 받는다. 문제당 API 콜 4~6회.
메모리 입력 = 세 조건 전부. blind 메모리 없음(하한) / frames 프레임 샘플(실제) / textlog GT 이벤트 로그(oracle 상한).
대상 task = A1 본 이동 (Finding 중 가장 기본). 골든 20개.
1 근거 — MolmoSpaces 실측
~/repos/scenemem/molmospaces 코드에서 직접 추출. 층이 두 개고 서로 다르다는 게 핵심이다.
# (a) 정책(VLA)에 실제로 들어가는 프롬프트
# policy/learned_policy/utils.py:16-30 PromptSampler.DEFAULT_TEMPLATES_BY_TASK
"pick" : ["pick up the {}."]
"open" : ["open the {}."]
"close" : ["close the {}."]
"pick_and_place" : ["pick up the {} and place it on the {}."]
"packing" : ["pack container."]
# (b) get_task_description() — 사람이 읽는 설명 (평가·로깅용). (a)와 다르다!
Pick : "Pick up the {}"
Pick-and-place : "Pick up the {} and place it in or on the {}" ← (a)는 "on"만
Open : "Open the {}"
Navigate : "Navigate to the {}" / "Navigate to any {} ({N} available)"
Door : "Push the door open" / "Pull the door open"
| 발견 | 스키마에 미치는 영향 |
정책 프롬프트는 5종뿐. next_to·color·navigate·door는 템플릿이 없다 | subgoal 문법을 이 5개로 제한. 이전 계획의 "7/8 매핑"은 틀렸고 실제로는 5개다 |
pick_and_place가 한 덩어리 — "pick up the X and place it on the Y." | PICK과 PLACE를 따로 내면 정책이 OOD 문구를 먹는다 → place subgoal이 pick을 포함 |
navigate에 정책 프롬프트가 없다 | 오히려 N0(위치 리스트에서 고르기)를 정당화한다. 학습된 nav 정책이 필요 없으니 없어도 무방 |
오버라이드 가능한 건 pick/pick_and_place뿐 (utils.py:47) | open/close는 문구를 바꿀 수 없다. 그대로 쓴다 |
객체 이름 = ObjectMeta.short_descriptions(uid)[n-1] | 우리도 같은 함수를 호출한다. 직접 문자열을 만들면 문구가 어긋난다 |
⚠️ opening_tasks.py:41-43이 task_type 체크 없이 항상 "Open the {}" 반환 | close task 설명이 "Open the ..."로 나오는 버그. (a) 쪽을 쓰면 회피 |
2 핵심 단순화 — 과거는 조건별, 현재는 공통
step-wise + free-run을 하려면 매 턴 "현재 상태"가 갱신돼야 한다. 그런데 물리 롤아웃을 돌리면 첫 파일럿부터 시뮬레이터·정책이 필요해지고, 프레임을 미리 렌더해뒀다면 모델이 다른 경로로 갈 때 맞는 프레임이 없다.
해법: 시간축을 둘로 쪼갠다.
과거(메모리) = 조건별로 다르게 준다 ← 여기가 실험 변수
blind : 아무것도 안 줌
frames : 심기+개입 구간 샘플 프레임 N장
textlog : GT 이벤트 로그 텍스트
현재(관측) = 세 조건 모두 동일한 심볼릭 텍스트 ← 여기는 통제 변수
"당신은 Kitchen에 있다. 손은 비어 있다.
보이는 것: CounterTop_01 위의 mug_A, Sink_01, ...
Drawer_02·04는 닫혀 있다."
| 얻는 것 | 설명 |
| 물리 없이 free-run | 심볼릭 상태 전이기(open→열림, pick→손에 듦)만 있으면 된다. 50줄 남짓 |
| 세 조건이 깨끗하게 비교됨 | 현재 관측이 동일하므로 차이가 전부 메모리에서 온다 |
| 은폐가 저절로 구현됨 | 서랍이 닫혀 있으면 내용물을 관측 텍스트에 안 쓴다. "현재 관측에 없는 정보" 규칙이 코드 한 줄 |
| 첫 파일럿에 MuJoCo 불필요 | API 콜 + 채점기만 있으면 돈다 |
대가: 현재 관측이 텍스트라 지각을 안 잰다. 이건 의도된 것이다 — 첫 파일럿의 질문은 "메모리를 쓸 줄 아는가"지 "볼 줄 아는가"가 아니다. 현재 관측을 이미지로 바꾸는 건 Phase 2에서, 같은 골든 세트로 A/B 하면 된다.
3 Subgoal 스키마
// Layer 1 — VLM 출력. 매 턴 정확히 1개. JSON only.
{"action":"navigate", "target":"<receptacle_id|room_id>"}
{"action":"open", "target":"<articulated_id>"}
{"action":"close", "target":"<articulated_id>"}
{"action":"pick", "target":"<object_id>"}
{"action":"place", "target":"<object_id>", "receptacle":"<receptacle_id>"} // pick 포함
{"action":"done", "status":"success"|"impossible", "reason":"<str>"}
{"action":"ask", "question":"<str>"}
Layer 2 — 렌더러 (rule-base, 결정적)
| subgoal | MolmoSpaces task_type | 렌더 결과 |
open(t) | open | "open the {name(t)}." |
close(t) | close | "close the {name(t)}." |
pick(t) | pick | "pick up the {name(t)}." |
place(t, r) | pick_and_place | "pick up the {name(t)} and place it on the {name(r)}." |
navigate(t) | — (정책 프롬프트 없음) | N0: navmesh standing pose 조회 / N1·N2용: "Navigate to the {name(t)}" |
done / ask | 비물리 | 렌더 없음. 텍스트로 채점 |
name(id) = ObjectMeta.short_descriptions(uid)[prompt_object_word_num - 1]
← MolmoSpaces와 동일 함수 호출. 직접 문자열 만들지 않는다.
ID 규약
Kitchen/Drawer_04 <room>/<Type>_<kk> kk = 월드 (x,y) 정렬, 매니페스트 동결
Kitchen/Fridge_01/joint_02 관절은 로컬 높이 내림차순 (위가 00)
inst:obja:keychain_0031:0 이동 가능 물체. 옮겨져도 ID 불변
Kitchen 방 자체 (navigate 전용)
스키마 위반 처리: JSON 파싱 실패 / 없는 action / 존재하지 않는 ID → 재시도 없이 invalid로 기록하고 그 스텝을 실패 처리. 재시도를 넣으면 모델 간 비교가 오염된다. invalid_rate를 별도 메트릭으로 보고한다.
4 Step-wise 루프
state ← 에피소드 초기 상태 (질의 시점)
trace ← []
for step in 1..MAX_STEPS: # A1이면 MAX_STEPS = 8
obs_text ← render_observation(state) # 심볼릭. 닫힌 리셉터클 내용물은 숨김
sg ← VLM(system, inventory, memory, obs_text, trace, instruction)
if invalid(sg): log invalid; break
trace.append(sg)
if sg.action == "done" or "ask": break
state ← symbolic_step(state, sg) # open→열림 / pick→손에 듦 / ...
score(trace, gt)
두 모드
| trace에 무엇이 들어가나 | 재는 것 | 메트릭 |
| FR free-run | 모델 자신의 출력 | 실제 문제 해결력 (오류 누적 포함) | Target Accuracy · redundant_open |
| TF teacher-forced | GT prefix | 순수 판단력 (오류 누적 없음) | Step Accuracy |
FR을 메인으로, TF는 진단용. FR에서 실패했을 때 "어느 스텝에서 틀렸나"를 TF가 알려준다.
심볼릭 상태 전이기
state = {
robot_room, holding, # 로봇 위치 / 손에 든 것
open_joints: set[joint_id], # 열려 있는 관절
obj_loc: {obj_id -> (receptacle_id, "in"|"on")},
opened_by_agent: set[joint_id], # C1(원상복구)용, A1엔 불필요
}
navigate(t) → robot_room = room_of(t)
open(t) → open_joints += t ; opened_by_agent += t
close(t) → open_joints -= t
pick(t) → holding = t (선행조건: 같은 방 + 리셉터클이 열려있거나 open 불필요)
place(t,r) → holding = None ; obj_loc[t] = (r, "on")
관측 렌더 규칙 ★ : obj_loc[o]의 리셉터클이 닫혀 있으면 o를 관측에 쓰지 않는다
→ "현재 관측에 없는 정보" 규칙이 이 한 줄로 구현된다
5 프롬프트 v1
════════ SYSTEM ════════
You are a household robot's high-level planner.
Output the NEXT single subgoal as one JSON object. No prose, no markdown fence.
Allowed actions (use EXACTLY these forms):
{"action":"navigate","target":"<id>"}
{"action":"open","target":"<id>"}
{"action":"close","target":"<id>"}
{"action":"pick","target":"<id>"}
{"action":"place","target":"<id>","receptacle":"<id>"}
{"action":"done","status":"success"|"impossible","reason":"<short>"}
{"action":"ask","question":"<short>"}
Rules:
- Every <id> MUST be copied verbatim from SCENE INVENTORY. Never invent an id.
- "place" already includes picking the object up. Do not emit "pick" before "place".
- You must be in the same room as the target before pick/place/open/close.
- Objects inside a closed receptacle are NOT listed in CURRENT STATE.
If you believe an object is inside one, open it first.
- Emit "done" when the instruction is satisfied, or "impossible" if it cannot be.
════════ USER ════════
## SCENE INVENTORY // 씬 전체. 후보만 주면 힌트가 된다
rooms: Kitchen, Living, Bedroom, Bathroom
receptacles:
Kitchen/Drawer_01 (drawer) | Kitchen/Drawer_02 (drawer) | Kitchen/Drawer_04 (drawer)
Kitchen/Cabinet_01..08 (cabinet) | Kitchen/Fridge_01 (fridge, joints 00-02)
Living/CoffeeTable_01 (table, no drawer) | Bedroom/SideTable_01 (side table, drawer)
... (씬의 모든 리셉터클)
## MEMORY // ← 조건별로 여기만 달라진다
[blind] (생략)
[textlog] t=0:12 user placed "blue keychain" into Kitchen/Drawer_04
t=0:15 user closed Kitchen/Drawer_04
t=1:20 robot opened Kitchen/GarbageCan_01 ...
[frames] <image×N> (심기 구간 조밀 + 개입 경계)
## CURRENT STATE // 세 조건 공통
You are in Living. Your hand is empty.
Visible: Living/CoffeeTable_01 (on it: red keychain), Living/Sofa_01
Open: (none)
## EXECUTED SO FAR
(none)
## INSTRUCTION
"내 열쇠 어디 있지? 가져와"
SCENE INVENTORY를 주는 이유: 안 주면 VLM이 "kitchen drawer" 같은 임의 문자열을 뱉고 접지가 안 돼 fuzzy matching 지옥이 된다. 주되 씬의 모든 리셉터클을 준다 — 후보 3개만 주면 그게 정답 힌트다. blind 조건에도 동일하게 준다(공정성).
프롬프트 고정 원칙: 시스템 프롬프트를 조건별로 바꾸지 않는다. 세 조건에서 다른 건 ## MEMORY 블록 하나뿐이어야 한다. v1에서 "recipe leak"으로 두 번 데었으니, 프롬프트 diff를 자동 검사하는 테스트를 하나 넣는다.
6 채점
| 메트릭 | 정의 | 모드 |
| M1 | Target Accuracy ★ | 첫 navigate 목적지 == GT AND pick 대상 == GT 인스턴스 | FR |
| M2 | First-Goal Correctness | 첫 navigate 목적지만 | FR |
| M3 | redundant_open | 정답 리셉터클을 열기 전까지 헛되이 연 개수 | FR |
| M4 | Plan F1 | 필수 subgoal 집합 대비 precision/recall (순서 제약 별도) | FR |
| M5 | Step Accuracy | GT prefix 주고 다음 1개 정확도 | TF |
| M6 | invalid_rate | 파싱 실패 / 없는 ID / 잘못된 action | 둘 다 |
M1이 메인인 이유: Finding task의 본질은 어디로 가서 무엇을 집는가다. 서랍을 닫고 나왔는지, 사용자에게 갔는지는 부수적이라 채점하지 않는다. M1은 제어와 완전히 무관하게 메모리만 잰다.
D게이트 — 여기를 통과 못 하면 진행하지 않는다
골든 20개 × 3조건 측정 후 확인:
G1 blind M1 ≤ chance + ε
chance = 1 / (리셉터클 수) ≈ 5% (20개 기준)
→ 넘으면 상식 prior로 풀리는 문제 → 배치 재샘플
G2 textlog(oracle) M1 ≥ 0.80
→ 낮으면 메모리 문제가 아니라 스키마·프롬프트·채점기 문제다.
여기부터 고친다.
G3 blind < frames < textlog 갭이 실제로 존재
→ 없으면 그 task는 메모리를 안 재고 있다 ← v1이 두 번 놓친 지점
G4 invalid_rate < 5%
→ 넘으면 스키마 설명·few-shot 보강
7 골든 20개 스펙 (A1)
수작업으로 만든다. 여기서 형식이 진짜 검증된다 — 20개를 손으로 쓰다 보면 스키마의 빈틈이 다 드러난다.
| 변주 축 | 수준 | 개수 | 왜 |
| 동종 미끼 수 | 0 / 1 / 3 | — | 인스턴스 구분 압력 |
| 개입 base task 수 | 2 / 5 / 8 | — | 망각 압력 (프레임 조건에 특히 영향) |
| 은폐 방식 | (a) 닫힌 곳 / (b) 다른 방 | — | §규칙 2 |
| 반사실 짝 | 정답 인스턴스를 바꾼 쌍 | 8쌍 = 16 | prior로 찍는 모델을 0점으로 |
| 단독 | — | 4 | 극단 조건(미끼 3개 / 개입 8개) |
에피소드 JSON 1건 스키마
{
"id": "a1_007",
"scene": "val_640",
"inventory": { rooms, receptacles[], objects[] }, # 프롬프트 SCENE INVENTORY 원본
"plant": [ {t, actor:"user", verb:"place", args:[obj, recep]}, {t, verb:"close", args:[recep]} ],
"filler": [ {subgoal...} × N ], # 개입 (GT 실행됨)
"query": "내 열쇠 어디 있지? 가져와",
"init_state": { robot_room, holding, open_joints, obj_loc }, # 질의 시점 상태
"gt": {
"target_object": "inst:obja:keychain_0031:0",
"target_receptacle": "Kitchen/Drawer_04",
"plan": [ {navigate}, {open}, {pick}, {done} ], # 대표 정답 경로
"required": [ "open:Kitchen/Drawer_04", "pick:inst:obja:keychain_0031:0" ],
"forbidden": [ "pick:inst:obja:keychain_0117:0" ] # 미끼를 집으면 0점
},
"pair_of": "a1_008" # 반사실 짝
}
프레임 조건은 어떻게 만드나: 골든 20개는 아직 시뮬을 안 돌리므로 프레임이 없다. 두 갈래 — ① 20개만 MolmoSpaces로 실제 렌더(심기+개입 구간). ② Phase 1에서는 blind/textlog 두 조건만 먼저 돌리고 프레임은 Phase 2로. ②를 권한다 — G1·G2·G4는 프레임 없이도 검증되고, G3만 미룬다.
8 파일 구조 · 단계
scene_bench/
spec/
subgoal_schema.json # JSON Schema (검증용)
renderer.md # subgoal → MolmoSpaces 프롬프트 매핑 표
data/golden_a1/
a1_001.json … a1_020.json
src/
symbolic_sim.py # 상태 전이 + 관측 렌더 (~80줄)
prompt.py # 프롬프트 조립. MEMORY 블록만 조건별 분기
runner.py # step-wise 루프 + API 호출 + 캐시
score.py # M1~M6
render_molmo.py # name(id) — ObjectMeta.short_descriptions 호출
tmp/ # 실행 산출물 (프로젝트 규칙)
| 단계 | 내용 | 산출 | 게이트 |
| A | 스키마 + 렌더러 확정. molmospaces에서 short_descriptions 호출 경로 검증 | spec/*, render_molmo.py | 임의 id 10개에 대해 렌더 문구가 MolmoSpaces와 byte-identical |
| B | 골든 20개 수작업 (반사실 8쌍 + 단독 4) | data/golden_a1/ | 스키마 검증 통과 + 사람이 읽고 정답 확인 |
| C | 심볼릭 시뮬 + 프롬프트 + 러너 + 채점기 | src/* | GT plan을 그대로 넣으면 M1 = 1.0 (채점기 자체 검증) |
| D | 3조건(→ 우선 2조건) 측정 | 결과 표 | G1~G4 전부 통과. 실패 시 여기서 멈추고 원인 규명 |
| E | 프롬프트 반복 → 자동 생성 확장 → A3/A4로 | — | — |
C단계의 자체 검증이 중요하다. GT plan을 러너에 그대로 흘려넣어 M1=1.0이 안 나오면 채점기가 틀린 것이다. v1에서 순위가 두 번 뒤집힌 원인이 전부 측정 버그였으므로, 모델을 붙이기 전에 채점기를 먼저 증명한다.
비용 추정: step-wise 4~6콜 × 20문제 × 2조건 = 160~240콜. v1 실측 $0.019/콜 → 약 $3~5. 프롬프트를 10번 반복해도 $50 이하. 파일럿 단계에서 비용은 제약이 아니다.
9 열린 항목
| 항목 | 지금 가정 | 언제 확인 |
| 1 | ObjectMeta.short_descriptions가 objaverse 에셋에 실제로 다 채워져 있나 | 있다고 가정. 없으면 uid.split("_")[0] fallback | A단계 |
| 2 | prompt_object_word_num 기본값을 몇으로 둘까 | MolmoSpaces 기본값 추종 (확인 필요) | A단계 |
| 3 | Letsur 게이트웨이 이미지 입력 배열 상한 | 콜당 8장 (v1 실측) | C단계 |
| 4 | 질의를 한국어로 낼지 영어로 낼지 | 한국어 발화 + 영어 스키마. 실사용에 가깝고, 영어 지시문이면 모델이 문구에서 힌트를 얻을 여지가 준다 | D단계에서 A/B |
| 5 | MAX_STEPS를 넘겼을 때 부분점을 줄지 | 안 준다(M1은 이진). M4로 부분 관찰 | — |