‹ Index
Scene-Mem · Runtime Mechanics · Traced 2026-07-28

파이프라인이 실제로 어떻게 도는가 — 완전 해부

비디오가 언제 어떻게 샘플링돼 API로 들어가고, 출력이 언제 어떻게 메모리가 되고, 회상 때 무엇이 꺼내지는가.
코드 정독 + 실제 시나리오 계측 트레이스(tmp/trace_pipeline.py, API 콜 없이 요청 전량 기록)로 검증한 수치만 기재.

목차

  1. ⚠️ 현재 실행 불가 상태 (선행 확인)
  2. 전체 데이터 흐름 한 장
  3. 비디오 → 프레임: 샘플링 전 과정
  4. API 콜 1회 해부 (바이트 단위)
  5. 시간 동기화 — 인덱스 산술
  6. walk 시퀀스: 2모드 × 4변형
  7. 메모리 5종 — 저장 메커니즘
  8. 회상 시점(final call) 구성
  9. 출력 파싱 → 채점
  10. 캐시 · 재개 · 비용 회계
  11. 부록: 파일 책임 · 플래그 매트릭스

0 ⚠️ 선행 확인 — 지금 이 코드는 실행되지 않는다

이 보고서를 쓰려고 실제 트레이스를 돌리다가 발견한 사항입니다. 디스크 상태가 문서(claude/context.md)와 다릅니다.

발견 1 — DATA_ROOT가 이미 879 신 릴리즈를 가리킨다

config.py:4~/Dataset/scene-mem-benchmark/data를 가리키는데, 07-27에 이 경로가 879 릴리즈로 교체(승격)됐습니다. context.md에는 "현재 DATA_ROOT가 가리키는 곳 = 구 858"이라 적혀 있으나 실제와 다릅니다.

$ ls ~/Dataset/ scene-mem-benchmark/ ← DATA_ROOT. 879 시나리오, eval_spec에 task 필드 有 scene-mem-benchmark.bak/ ← 구 858 완본 (fine_subtasks.json 포함) — 모든 발표 수치의 기준 scene-mem-benchmark_fine_subtasks_backup_260727.tar.gz (scene-mem-benchmark-879/ 는 존재하지 않음 — main으로 이동된 것) # 같은 시나리오 ID, 완전히 다른 에피소드 combo_002_L29_S48_0006 DATA_ROOT : n_tasks=5 frames= 8882 segs= 9 task필드=find_target .bak : n_tasks=6 frames=10230 segs=11 task필드=없음

발견 2 — 현재 DATA_ROOT에서는 coarse walk조차 못 돈다

879 릴리즈에는 fine_subtasks.json이 0개입니다. 알려진 블로커는 "--recall-walk fine이 안 된다"였는데, 실제로는 더 넓습니다 — segment_goal()이 GOAL 텍스트를 fine_subtasks.jsonper_test[i].instruction에서 읽기 때문에 모든 walk 콜이 첫 스텝에서 assert로 죽습니다.

segment_goal() → _per_test_entry() → _fine_subtasks() → _read_json(videos/fine_subtasks.json) AssertionError: missing .../combo_002_L29_S48_0006/videos/fine_subtasks.json → target=memory / target=recall, coarse / fine 전부 동일하게 차단
조용한 위험: 캐시 키는 (model, method, scenario_id, variant)뿐이고 데이터 버전을 포함하지 않습니다. 만약 신 데이터에 annotation을 채워 넣고 그냥 돌리면, ID가 같은 시나리오는 구 에피소드로 측정된 캐시 row가 그대로 반환됩니다. 신 데이터로 갈 때는 캐시를 분리하거나 비워야 합니다.

본 보고서의 모든 트레이스 수치는 재현 가능한 기준인 .bak(구 858, annotation 포함)에서 측정했습니다. 데이터셋 디렉토리는 읽기만 했고 아무것도 수정하지 않았습니다.

1 전체 데이터 흐름 한 장

 ┌─ 시나리오 1개 (에피소드 ~8.5분) ───────────────────────────────────────────────┐
 │                                                                              │
 │  mp4 ×3 (agentview_left / agentview_right / eye_in_hand)  각 10,230f @20fps   │
 │        │                                                                     │
 │        │  sample_episode()  stride=round(fps/2.0)=10 → 2Hz                    │
 │        ▼                                                                     │
 │  타임스텝별 3-cam 세로 concat → 1536×512 → JPEG(q85)                        │
 │  = FrameRef(global_idx=원본 프레임번호, jpeg=bytes) × 1,023개  (총 88.9MB)     │
 │        │                                                                     │
 │        │  subtasks.json 의 frame_start/end 로 세그먼트 분할 (11개)             │
 │        │  fine_subtasks.json 의 per_test[i].segments[j].fine_subtasks 로 K    │
 │        ▼                                                                     │
 │  ┌── WALK (메모리 적재) ─────────────────────────────────────────────┐         │
 │  │  각 스텝:  slice_segment(lo,hi) → cap_uniform(8) → 8장 선택           │
 │  │           ┌─────────────────────────────────────────────┐        │         │
 │  │           │ system = STEP_SYSTEM (또는 OURS_STEP_SYSTEM) │        │         │
 │  │           │ user   = [ GOAL텍스트 ]                      │        │         │
 │  │           │          [ STATE 이미지 ×8 ]  ← 이번 윈도     │        │         │
 │  │           │          [ 메모리 parts ]     ← policy       │        │         │
 │  │           └─────────────────────────────────────────────┘        │         │
 │  │                       │ structured output (pydantic)             │         │
 │  │                       ▼                                          │         │
 │  │              StepOut{action,object,place,raw,                    │         │
 │  │                      keyframe_positions,memory}                  │         │
 │  │                       │                                          │         │
 │  │                       └──→ policy.observe(out, window)메모리가 여기서 커짐 │
 │  └──────────────────────────────────────────────────────────────────┘         │
 │        │                                                                     │
 │        ▼  (recall 모드) 에피소드 끝 · 테스트마다 1회 · observe 안 함              │
 │  ┌── FINAL CALL ────────────────────────────────────────────────────┐         │
 │  │  system = RECALL_SYSTEM                                          │         │
 │  │  user   = [ LANG 질의 ] + [ 마지막 세그먼트 STATE ×8 ]              │         │
 │  │           + policy.recall_parts(lang)  ← 메모리를 여기서 꺼냄      │         │
 │  └──────────────────────────────────────────────────────────────────┘         │
 │        │  StepOut.raw                                                        │
 │        ▼                                                                     │
 │  parse_subtask() → (action,object,place) 정규화 → GT와 slot 비교 → row 저장     │
 └──────────────────────────────────────────────────────────────────────────────┘

핵심 대칭: 메모리는 observe()로만 커지고, context_parts()/recall_parts()로만 꺼내진다. 메서드 5종은 이 두 훅의 구현만 다르고, 프롬프트·윈도·채점은 완전히 동일합니다(ours만 walk 프롬프트를 walk_system() 훅으로 교체).

2 비디오 → 프레임: 샘플링 전 과정

실측 대상: combo_002_L29_S48_0006 (.bak 기준).

2.1 원본 3카메라

cameraframesfpsduration해상도파일stride
robot0_agentview_left10,23020.0511.5 s256×2567.5 MB10
robot0_agentview_right10,23020.0511.5 s256×2567.5 MB10
robot0_eye_in_hand10,23020.0511.5 s256×25611.0 MB10

2.2 sample_episode() — 디코딩과 합성

video.py:41. 카메라마다 전체를 순차 디코딩하면서 idx % stride == 0인 프레임만 보관합니다. seek을 쓰지 않고 전 프레임을 읽습니다(정확도 우선, 시나리오당 3파일 완전 디코딩).

stride = max(1, round(fps / rate_hz)) = round(20.0 / 2.0) = 10 보관 프레임 = idx ∈ {0, 10, 20, 30, ...} ← global_idx = 원본 프레임 번호 그대로 카메라별로 _resize_to_width(frame, max_side=512): 256×256 → 512×512 (INTER_AREA) 3대 모두에 존재하는 타임스텝만 교집합으로 유지 ← 길이 불일치 방어 cv2.vconcat([left, right, eye_in_hand]) → 1536×512 (H×W) cv2.imencode('.jpg', composite, JPEG_QUALITY=85) → bytes 결과: FrameRef(global_idx, jpeg) × 1,023
샘플 타임스텝
1,023
global_idx 간격
10 (= 0.5 s)
composite 크기
1536 × 512
JPEG 평균
86,933 B
JPEG 전체
88.9 MB
메모리 상주
전량 (list)
중요한 성질 3가지
  • global_idx원본 프레임 번호라서 subtasks.jsonframe_start/end직접 비교 가능합니다. 별도 좌표 변환이 없습니다.
  • 세로 concat이므로 모델은 한 장의 이미지 안에서 3시점을 동시에 봅니다. 카메라별 라벨은 붙지 않습니다.
  • 에피소드 전체 JPEG 88.9 MB가 프로세스 메모리에 상주합니다(시나리오 단위로 해제). M2/ours는 이 dict(_by_idx)에서 임의 시점 이미지를 꺼내므로 "8분 전 프레임"을 다시 볼 수 있는 것입니다.

2.3 cap_uniform() — 콜당 8장으로 줄이기

video.py:82. 윈도 안 프레임이 8장을 넘으면 균일 인덱싱으로 8장만 남깁니다(자르기가 아니라 솎아내기 — 윈도의 시간 범위는 유지되고 간격만 벌어집니다).

step = n / k [ frames[min(n-1, round(i*step))] for i in range(k) ] 예) 마지막 세그먼트 ClearSink f[7796,10230) = 243 샘플 → 8장 선택된 global_idx = [7800, 8100, 8410, 8710, 9020, 9320, 9620, 9930] → 실제 간격 약 15초 (0.5초 간격이 아님!)
"recent window"라는 이름이 오해를 부릅니다. 윈도가 긴 세그먼트를 덮으면 8장은 최근 8프레임이 아니라 그 구간 전체를 15초 간격으로 솎아낸 것입니다. 위 예에서 8장은 121초를 커버합니다. 짧은 fine chunk(9.1초)에서는 1.1초 간격이 됩니다 — 즉 시간 해상도가 윈도 길이에 따라 10배 넘게 출렁입니다.

3 API 콜 1회 해부 (바이트 단위)

3.1 parts 조립 — 순서가 고정돼 있다

runner._unified_call() (walk) 와 run_scenario_recall() Phase 2 (final) 둘 다 동일한 3단 구조입니다.

parts = [ TextPart(step_user_text(goal)) ] ① 텍스트 1개 — GOAL + STATE 안내 + [ ImagePart(f.jpeg) for f in win ] ② 이미지 8장 — 이번 STATE 윈도 + policy.context_parts() ③ 메모리 — 메서드마다 다름 (0개~8장/1텍스트) client.generate(system = policy.walk_system() or STEP_SYSTEM, parts = parts, schema = policy.walk_schema() or StepOut)

3.2 _to_messages() — 실제 전송 형태

clients/openai.py:37. 시스템 메시지 1개 + user 메시지 단 1개이고, parts 전체가 그 user 메시지의 content 배열에 순서대로 들어갑니다. 이미지는 base64 data URI로 인라인됩니다(URL 참조 아님).

[ {"role":"system", "content": "You are the high-level policy of a long-horizon household robot..."}, {"role":"user", "content":[ {"type":"text", "text":"GOAL: There's a steak in the fridge that needs to be moved..."}, {"type":"image_url", "image_url":{"url":"data:image/jpeg;base64,/9j/4AAQ..."}}, ← STATE 1/8 ... ×8 ... {"type":"text", "text":"SUB-TASKS YOU HAVE EXECUTED SO FAR ..."} ← 메모리(m3v2) ]} ] client.beta.chat.completions.parse(model=..., messages=..., response_format=StepOut) ↑ pydantic 스키마 = structured output 강제

3.3 실측 페이로드 (fine walk, m3v2, 앞 6콜)

call이미지이미지 바이트텍스트 파트텍스트 문자system 문자schema
call 08641 KB1 (메모리 아직 없음)3411,596StepOut
call 18641 KB27531,596StepOut
call 28663 KB27871,596StepOut
call 38615 KB28211,596StepOut
call 48536 KB26901,596StepOut
call 58757 KB27891,596StepOut
call 27 (마지막)8753 KB21,565 ← 로그가 길어짐1,596StepOut
페이로드는 이미지가 지배합니다. 콜당 raw JPEG ~600–760 KB → base64로 ×4/3 ≈ 800 KB–1 MB가 매 요청 본문에 실립니다. 게이트웨이 실측 prompt 토큰이 ~9.1k/콜인데 텍스트는 2 KB 남짓이므로 토큰의 대부분이 이미지입니다. m3v2가 가장 싼 이유(메모리가 텍스트라 이미지를 안 붙임)와 m1/m2가 비싼 이유(메모리로 이미지 8장을 추가로 붙여 콜당 16장이 됨)가 여기서 나옵니다.

3.4 실제로 전송되는 텍스트 전문

walk user text (step_user_text)

GOAL: There's a steak in the fridge that needs to be moved to the freezer for freezing. Pick up the steak from the fridge, place it on any shelf in the freezer, and close the fridge and freezer doors when done. STATE: the following images are the current and recent observation frames, in temporal order. Decide the single next sub-task now.

GOAL 문자열 = fine_subtasks.jsonper_test[i].segments[j].instruction. 여기에 "place it on any shelf in the freezer"가 그대로 들어 있는 것이 next-subtask 모드의 recipe leak입니다 — 정답이 질문에 적혀 있습니다.

recall final user text (recall_user_text)

RECALL QUESTION: Show me where the steak is placed. STATE: the following frames are the LATEST observation (end of episode), for reference only. Based on what you observed EARLIER in the episode, report where the asked-about object was placed. Do not describe a next action — describe the past placement as a single 'Place the <object> in/on the <place>' sub-task.

3.5 응답 처리

completion = client.beta.chat.completions.parse(...) _accumulate(completion) ← 파싱 성공 여부와 무관하게 먼저. 과금은 이미 됐으므로 usage.calls += 1 usage.prompt_tokens / completion_tokens += ... usage.cost_usd += float(completion.estimated_cost.amount) ← 게이트웨이가 계산한 USD if not completion.choices: return None if message.refusal is not None: return None → status="parse_fail" if message.parsed is None: return None return message.parsed → StepOut / OursStepOut 인스턴스

try-except가 없습니다(프로젝트 규칙). 네트워크/게이트웨이 오류는 openai SDK의 max_retries=5가 처리하고, 그래도 실패하면 예외가 그대로 올라와 런이 죽습니다 — 부분 결과를 조용히 남기지 않기 위함입니다. 실제로 429 COST limit이 이런 식으로 런을 중단시킨 적이 있습니다.

4 시간 동기화 — 인덱스 산술 전부

4.1 좌표계 3개와 변환

좌표단위출처변환
원본 프레임0 … 10,229mp4기준
실시간t = idx / 20.0
샘플 인덱스0 … 1,022sample_episodeglobal_idx = 샘플번호 × 10
세그먼트 경계원본 프레임subtasks.json변환 없음 — 직접 비교

slice_segment(frames, lo, hi)lo <= f.global_idx < hi로 필터합니다. 세그먼트 경계와 프레임 인덱스가 같은 좌표계라 오프셋 버그 여지가 없습니다 — 과거 렌더러에서 냈던 ×10 실수는 이 성질을 착각한 것이었습니다.

4.2 실측 세그먼트 구조 (combo_002_L29_S48_0006)

#종류task프레임 범위길이K(fine)샘플 수
0TASKMoveFridgeToFreezer[0, 724)36.2 s473
1TRANSITTransit[724, 1003)13.9 s128
2TASKRestockBowls[1003, 2470)73.3 s1146
3TRANSITTransit[2470, 2680)10.5 s121
4TASKOvenBroilFish[2680, 3406)36.3 s373
5TRANSITTransit[3406, 3617)10.6 s121
6TASKSimmeringSauce[3617, 4846)61.5 s6123
7TRANSITTransit[4846, 5038)9.6 s119
8TASKArrangeUtensilsByType[5038, 7512)123.7 s8248
9TRANSITTransit[7512, 7796)14.2 s128
10TASKClearSink[7796, 10230)121.7 s1 243
K가 데이터 품질에 좌우됩니다. seg2 RestockBowls는 73초짜리 조작 세그먼트인데 K=1(fine 분해 실패), seg10 ClearSink도 121초에 K=1입니다. 이런 세그먼트는 fine walk를 켜도 여전히 1콜로 뭉개지고, 그 1콜의 8장은 121초를 15초 간격으로 훑습니다. m3v2 로그에서 "peach place 줄이 없다"류 누락의 구조적 원인이 여기입니다.

4.3 K-분할과 leadup shift — 정확한 산술

세그먼트를 K개 fine chunk로 프레임 수 비례 균등 분할합니다(fine별 실제 경계 데이터가 없어서 쓰는 근사).

chunk[k] = [ seg.start + round(k*L/K), seg.start + round((k+1)*L/K) ) seg0: start=0, L=724, K=4 → 경계 [0, 181, 362, 543, 724] chunk[0] f[ 0, 181) 9.1s chunk[1] f[181, 362) 9.1s chunk[2] f[362, 543) 9.1s chunk[3] f[543, 724) 9.1s

여기가 프로젝트 전체를 뒤집은 지점입니다. 두 정렬 방식이 무엇을 STATE로 주는지:

chunk 정렬 (구버전) fine #k 를 물을 때 STATE = chunk[k] → chunk[k]의 마지막 프레임 = 그 동작이 끝난 직후 → 모델은 정상적으로 k+1을 답함 (= over-advance) leadup 정렬 (현행) fine #k 를 물을 때 STATE = chunk[k-1] → 마지막 프레임 = 동작 직전 → 모델의 "다음 sub-task" = k윈도 span(9.1s)은 그대로, 위치만 한 칸 당김 — 변수 격리 k=0 이면 직전 세그먼트 tail 8장 (첫 세그먼트면 opening chunk로 폴백)

실측 — seg0을 fine walk로 돌 때 각 콜이 실제로 받는 윈도:

관측 대상STATE 윈도프레임길이
fine #0직전 세그먼트 tail → 첫 세그먼트라 opening chunk 폴백f[0, 181)9.1 s
fine #1chunk[0]f[0, 181)9.1 s
fine #2chunk[1]f[181, 362)9.1 s
fine #3chunk[2]f[362, 543)9.1 s

부작용: 첫 세그먼트에서 fine#0과 fine#1이 같은 윈도를 받습니다(폴백 때문). 그리고 chunk[3] f[543,724)는 어떤 콜에도 STATE로 안 들어갑니다 — leadup은 구조적으로 마지막 chunk를 관측에서 제외합니다.

4.4 콜 케이던스 vs 실제 동작 속도

5 walk 시퀀스: 2모드 × 4변형

5.1 --target memory (next-subtask) — 테스트마다 독립 walk

for seg in segs[:tgt_idx]: # 타깃 이전 세그먼트: 세그먼트당 1콜, 관측만 win = cap_uniform(slice_segment(frames, seg.start, seg.end), 8) _unified_call(...) # 타깃 세그먼트 chunk 정렬 : k = 0..tfi 관측, k==tfi 의 출력을 채점 → 콜수 = tgt_idx + (tfi+1) leadup 정렬: k = 0..tfi-2 관측, 그 뒤 chunk[tfi-1] 1콜을 채점 → 콜수 = tgt_idx + tfi ※ tfi 이후 chunk는 아예 호출하지 않음 (메모리에도 안 쓰이므로 순수 낭비)

실측(MoveFridgeToFreezer, tgt_idx=0, K=4, tfi=1): chunk = 2콜(k=0,1) / leadup = 1콜(chunk[0] 하나로 바로 채점). 이 테스트는 타깃이 seg0이라 선행 세그먼트가 없습니다.

메모리 모드는 policy가 테스트마다 새로 생성됩니다(run_scenario가 루프 안에서 make_policy 호출). 즉 시나리오의 3 테스트가 walk를 공유하지 않고 각자 처음부터 돕니다.

5.2 --target recall — walk 1회를 3 테스트가 공유

Phase 1 (메모리 적재, 시나리오당 1회) for seg in segs: # 전 세그먼트, transit 포함 coarse: 세그먼트 전체 윈도로 1콜 fine : _accumulate_fine() → K개 fine chunk를 leadup 윈도로 관측 Phase 2 (회상, 테스트마다 1회) last_win = cap_uniform(마지막 세그먼트 전체, 8) # 3 테스트 공통 for t in tests: parts = [LANG] + last_win(8장) + policy.recall_parts(lang) out = client.generate(RECALL_SYSTEM, parts, StepOut) ※ policy.observe() 를 호출하지 않음 → 3 테스트가 완전히 동일한 메모리 상태를 봄

5.3 실측 콜 수 (combo_002_L29_S48_0006, 세그먼트 11개 / 테스트 3개)

변형Phase 1 walkPhase 2 final총 콜배수
recall coarse11 (세그먼트당 1)3141.0×
recall fine28 (= ΣK)3312.55×

세그먼트별 fine 콜 분포: [4, 1, 1, 1, 3, 1, 6, 1, 8, 1, 1] — transit(K=1)은 안 늘고 조작 세그먼트만 쪼개집니다. K=1로 남은 seg2·seg10이 fine의 이득을 못 받는 구간입니다.

walk 공유가 비용의 핵심입니다. 테스트마다 walk를 새로 돌면 3×가 되는데(메서드당 $18 대), 공유로 ΣK + 3에 묶었습니다. 대신 3 테스트가 같은 메모리를 보므로 테스트별 맞춤 적재는 불가능합니다.

6 메모리 5종 — 저장 메커니즘

모두 StepPolicy의 3+2 훅 구현입니다: observe()(적재) / context_parts()(walk 중 제시) / recall_parts()(회상 시 제시, 기본은 context_parts 위임) / summary()(기록) / walk_system()·walk_schema()(ours만 오버라이드).

6.1 한눈에

methodobserve가 저장하는 것저장 형태walk 중 제시회상 시 제시용량 한계
control아무것도 안 함없음없음
M1윈도 프레임 전부 appendFrameRef 리스트균일 8장균일 8장무제한 저장 / 8장 제시
M2keyframe_positions → global_idxint 리스트 (중복 허용)선택 ≤8장선택 ≤8장cap 8 (최근편향)
M3 폐기memory 문자열로 덮어쓰기str 1개텍스트 1개텍스트 1개1개 — 장기 망각
M3v2rawappendstr 리스트번호매긴 전문번호매긴 전문무제한
ourskeyframe_tags 인라인idx→태그 dict + 후보 int선택 ≤8장질의 매칭 top-k 태그+이미지이미지 8 / 태그 무제한

6.2 M2 (MemER) — 클러스터링과 eviction

memory/cluster.py. 저장하는 건 이미지가 아니라 정수 인덱스입니다. 이미지는 회상 때 _by_idx(에피소드 전체 디코딩 dict)에서 꺼냅니다 — 그래서 "8분 전 프레임"을 되볼 수 있습니다.

observe: for p in step_out.keyframe_positions: if 1 <= p <= len(window): candidates.append(window[p-1].global_idx) # 중복 제거 안 함 = 투표 가중 _refresh() _refresh (매 스텝 전체 재계산): clusters = 1D single-linkage, 간격 <= d(=5) 면 같은 클러스터 reps = [median_low(c) for c in clusters] selected = reps[-memory_length:] # ← 최근 8개만. 여기서 초반이 evict

eviction 실측 증거 — 트레이스에서 M2가 최종 보관한 8개가 어느 세그먼트인지:

idx 5350 (t=267.5s) → seg[8] ArrangeUtensilsByType idx 5660 (t=283.0s) → seg[8] ArrangeUtensilsByType idx 5970 (t=298.5s) → seg[8] ArrangeUtensilsByType idx 6280 (t=314.0s) → seg[8] ArrangeUtensilsByType idx 6590 (t=329.5s) → seg[8] ArrangeUtensilsByType idx 6900 (t=345.0s) → seg[8] ArrangeUtensilsByType idx 7440 (t=372.0s) → seg[8] ArrangeUtensilsByType idx 7720 (t=386.0s) → seg[9] Transit → 스테이크 배치(seg0, f<724, t<36s)는 단 한 장도 살아남지 못함
M2가 "steak이 어디 있냐"에 답할 수 없는 구조적 이유가 이것입니다. reps[-8:]는 시간상 최근 8클러스터만 남기므로, 에피소드 초반 사건은 클러스터가 8개를 넘는 순간 반드시 버려집니다. 용량을 16으로 올린 게 --m2-force지만, 강제 지목한 중앙 프레임이 배치 순간이 아니면 노이즈라 이득이 제한적이었습니다(place 0.179→0.231).

실측 런에서는 sparse nomination까지 겹쳐 더 적었습니다 — 예: combo_014 M2 최종 보관 6개 [90, 150, 190, 290, 390, 6360].

6.3 M3v2 — append 로그

모델이 emit한 raw 문장을 그대로 덧붙입니다. 회상 때는 번호를 매겨 통째로 넣고 모델이 알아서 찾게 합니다(인덱스 없음).

context_parts() = "SUB-TASKS YOU HAVE EXECUTED SO FAR (oldest -> newest, one per prior step): 1. Open the microwave door 2. Place the cup in the microwave 3. Close the microwave door ... 30. Place the cup on the dining counter The entries above are sub-tasks that have ALREADY been emitted ... emit the SINGLE next sub-task ... Do not re-emit ... do not skip ahead."

walk가 진행될수록 이 텍스트가 길어집니다(실측: 콜0 341자 → 마지막 콜 1,565자). 그래도 이미지가 없어 비용은 가장 쌉니다.

6.4 ours — 인라인 태그 + 질의 검색

walk 프롬프트 자체를 바꿉니다(walk_system()OURS_STEP_SYSTEM, walk_schema()OursStepOut). 한 콜에서 다음 sub-task와 keyframe 태그를 동시에 받습니다.

observe: for kt in step_out.keyframe_tags: # KeyframeTagAt{position,objects,place,action} gi = window[kt.position-1].global_idx candidates.append(gi) # 이미지 예산용 (M2와 동일 클러스터링) _order.setdefault(gi, len(_order)) # 이벤트 순번 t_order _tags[gi] = KeyframeTag(objects, place, action) # ← 텍스트라 cap 없음 recall_parts(query): matched = [태그 중 objects 하나라도 query에 등장하는 것] 매칭 규칙: obj 전체가 질의에 포함 OR obj의 마지막 단어가 질의에 포함 if not matched: return 전체 태그 dump # R1 폴백 matched.sort(-t_order)[:topk(=3)] # 최신 우선 return [태그 텍스트] + [해당 keyframe 이미지 ×k] # R2

실측 — ours만 정답을 맞힌 row (combo_014, 질의 "Show me where the cup with liquid is placed.", GT Place the cup in the microwave):

저장된 태그 16개 중 object 매칭 3개 → 최신 top-3 surface: [t3] gi=300 objects=['cup'] place=microwave action=place ← 이게 근거 [t1] gi= 90 objects=['cup','bottle'] place=counter action=pick [t0] gi= 60 objects=['cup','bottle'] place=counter action=pick + 위 3장의 keyframe 이미지 동봉 → PRED: "Place the cup in the microwave" place_ok=True ✓ 같은 row, m3v2 (로그 30줄 flat dump): 'cup' 포함 줄 = [2, 27, 29] 2. Place the cup in the microwave ← 정답 사실이 있었음 27. Pick up the cup from the counter 29. Place the cup on the dining counter ← 더 최근 = 모델이 이걸 집음 → PRED: "Place the cup with liquid on the counter" place_ok=False ✗
검색의 가치가 정확히 여기서 보입니다. m3v2는 정답을 갖고 있었지만 30줄 평면 덤프에서 최신 편향으로 엉뚱한 줄을 집었습니다. ours는 object로 먼저 좁힌 뒤 최신을 골라 microwave에 도달했습니다.
단 공정하게: ours가 이긴 데는 커버리지 운도 있습니다 — ours의 태그 16개에는 후반 counter 배치가 안 잡혀 방해 후보가 애초에 없었습니다.

6.5 walk 종료 시점 메모리 상태 (실측 트레이스)

methodkind저장 규모
controlcontrol(없음)
M1m1224 frames 누적 → 제시는 균일 8장
M2keyframeselected 8개 (cap 8) — 전부 후반 세그먼트
M3v2subtask_log28줄 (= fine walk 콜수와 동일)
ourstagged_keyframe이미지 8장 예산 / 태그 27개 — 텍스트는 cap 없음

7 회상 시점(final call) 구성

Phase 2에서 테스트마다 정확히 1콜. STATE는 마지막 세그먼트를 균일 8장으로 훑은 것이고 3 테스트가 이걸 공유합니다.

STATE global_idx = [7800, 8100, 8410, 8710, 9020, 9320, 9620, 9930] = ClearSink 세그먼트 f[7796,10230) 를 약 15초 간격으로 8장 LANG = "Show me where the steak is placed."

메서드별 final call 페이로드 (실측)

methodSTATE 이미지메모리 이미지메모리 텍스트콜당 총 이미지
control8008
M188016
M288016
M3v28018
ours83 (top-k)111

비용 차이(스윕 실측: m3v2/control $6.20 vs m1 $11.92 vs m2 $10.42)가 이 이미지 수에서 그대로 나옵니다.

미해결 설계 이슈 — 마지막 프레임 anchor. 회상 질문인데도 STATE로 "지금 화면"(마지막 세그먼트)을 8장 넣습니다. 모델이 현재 보이는 fixture(예: sink)로 답을 끌려가는 경향이 관측됐습니다. 순수 회상을 재려면 STATE를 빼거나 중립화해야 하는데 아직 미적용입니다.

8 출력 파싱 → 채점

채점 대상은 StepOut.raw(자연어 문장) 하나입니다. 구조화 필드(action/object/place)는 채점에 쓰지 않고, raw를 다시 파싱해서 씁니다 — GT도 같은 파서를 통과시켜 대칭을 맞춥니다.

pred_raw ─┐ ├→ parse_subtask() → StepOut(action, object, place) gt_str ─┘ │ ├─ 정규식 5종(navigate/pick/place/open/close) 매칭, 실패 시 action='other' ├─ _reduce_place: "shelf in the freezer" → "freezer" ├─ _noisy 필터: 마침표/and/then/5단어 초과 → other로 강등 └─ normalize: lower → 관사제거 → 공백정리 → 보수적 단수화 → aliases 치환 score(): action_ok = p.action == g.action place_ok = p.place == g.place # None==None 도 True obj_ok = normalize(p.object) == normalize(g.object) slot_match = action_ok ∧ place_ok ∧ obj_ok exact = pred.lower().strip() == gt.lower().strip()
recall-report 모드에서 action_acc는 무의미합니다. RECALL_SYSTEMaction='place', raw='Place the <obj> in/on the <place>'를 강제하므로 GT도 전부 place → 실측 4개 셀 모두 action_acc = 1.000입니다. 이 모드의 유효 지표는 place_acc(주)와 slot_acc/exact입니다.

파서 버그 이력: 모델이 템플릿의 in/on을 문자 그대로 출력하는 일이 잦은데, 초기 정규식이 이 슬래시 연결어를 몰라 action='other'로 깎였습니다. 현재는 (?:in/on|on/in|into|onto|inside|in|on)으로 수정됐습니다(templates.py:15). 이 수정으로 이미지 메서드가 전반 상향됐고(m2 0.179→0.308), 이전에 보고된 "M3v2가 M2를 3.1× 압도"는 1.8×로 정정됐습니다.

row 스키마 (결과 JSON에 저장되는 것)

{ scenario, test_id, model, method, lang, # recall 모드만 pred, gt, status, # ok | parse_fail | no_target_frames | ambiguous_target memory, # policy.summary() — 메서드별 저장 전량 스냅샷 slot_match, exact, action_ok, place_ok } ※ obj_ok 는 저장되지 않음 (slot 계산에만 사용) ※ _summary 의 분모는 status 무관 전체 n — 실패 row도 자동 오답으로 평균에 포함

9 캐시 · 재개 · 비용 회계

9.1 캐시 레이아웃 — 변형마다 격리

cache/<model>/<method>/<scenario>/[<variant>/]recall__<test_id>.json variant 조립 규칙: memory 모드 : chunk → (없음, back-compat) leadup → "align_leadup" (+ "_m2force") recall 모드 : "recall" + ("_report") + ("_rwalkfine") + ("_m2force") + ("_r2"/"_r1") 예) cache/gemini-3.5-flash/ours/combo_014.../recall_report_rwalkfine_r2/recall__PlaceMicrowave....json

재개 단위는 (시나리오, 테스트)입니다. recall 모드는 시나리오의 테스트가 전부 캐시에 있으면 sample_episode와 walk 전체를 건너뜁니다(run_scenario_recall 초입의 all-hit 체크). 일부만 있으면 walk를 다시 돌고 미캐시 테스트만 final call을 합니다 — 부분 캐시는 walk 비용을 아끼지 못합니다.

캐시 키에 데이터 버전이 없습니다. 같은 scenario_id면 영상이 바뀌어도 구 row가 반환됩니다. §0의 879 전환에서 실제 위험입니다.

9.2 비용 — 게이트웨이 값이 ground truth

토큰 단가 추정을 폐기하고, 게이트웨이가 응답에 실어주는 estimated_cost.amount를 콜마다 누적합니다. 파싱 실패해도 과금은 됐으므로 None 반환 이전에 누적합니다.

단위실측
콜 1회≈ $0.0192 · prompt ~9.1k tok (대부분 이미지) · completion ~620 tok
시나리오 1개$0.23 (memory 모드) / $0.44 (recall fine)
diverse12 스윕control $6.20 · m3v2 $6.20 · m2 $10.42 · m1 $11.92 (322콜 기준)

주의: ours 스윕의 기록은 $2.07/52콜인데, 이는 대부분 시나리오가 앞선 스모크에서 이미 캐시돼 새로 부른 콜만 집계됐기 때문입니다. 메서드 간 비용 비교에 그대로 쓰면 안 됩니다.

초기 토큰 기반 추정이 6–8× 과소였던 이력이 있어(추정 $38 → 실비 ~$300), 지금은 모든 런이 종료 시 COST $X (calls=… prompt_tok=… completion_tok=…)를 출력합니다.

10 부록

10.1 파일별 책임

파일책임핵심 심볼
config.py샘플링·런 파라미터, 경로SamplingConfig, RunConfig, DATA_ROOT
video.py디코딩·2Hz 샘플·3-cam 합성·윈도 축소sample_episode, slice_segment, cap_uniform
dataset.py세그먼트/fine/타깃/GOAL 해석load_segments, target_fine_info, recall_target_info, segment_fine_counts, segment_goal
prompts.py시스템·유저 프롬프트 전문STEP_SYSTEM, RECALL_SYSTEM, OURS_STEP_SYSTEM, CAPTION_SYSTEM
schemas.pystructured output 스키마, Part 타입StepOut, OursStepOut, KeyframeTag(At), FrameRef
memory/base.py메모리 5종 정책StepPolicy, Control/M1/M2/M3/M3v2/OursPolicy
memory/cluster.pyMemER Algorithm 1cluster_indices, median_low, select_keyframes
runner.pywalk 오케스트레이션·CLI·집계_unified_call, _walk_chunk/_walk_leadup, _accumulate_fine, run_scenario_recall
clients/openai.py메시지 조립·structured parse·usage_to_messages, generate, _accumulate
templates.py / metrics.py파싱·정규화 / 슬롯 채점parse_subtask, normalize, score
cache.py(시나리오,테스트) 단위 재개Cache(target=variant)

10.2 플래그 매트릭스

플래그영향 지점현행 정본
--targetmemory / recallwalk 구조 전체recall
--state-alignchunk / leadupmemory 모드 채점 STATE 위치leadup
--recall-styleexecute / reportfinal call 시스템 프롬프트report
--recall-walkcoarse / finePhase 1 적재 입도fine
--m2-forceflagM2 강제 지목 + cap 16off
--ours-retrievalr1 / r2ours 회상: 전체 덤프 / 검색r2
--ours-topkint매칭 태그 surface 개수3

기본값은 전부 back-compat(chunk/execute/coarse)이라, 플래그를 안 주면 폐기된 측정 설정으로 돕니다. 정본 커맨드는 4개 플래그를 모두 명시해야 합니다.

10.3 재현

KEY=$(grep '^Letsur_qualcomm:' ~/envs/api_keys.txt | cut -d: -f2- | tr -d ' ') OPENAI_API_KEY=$KEY OPENAI_BASE_URL=https://gw.letsur.ai/v1 \ python -m scene_mem_api.runner --model gemini-3.5-flash \ --method ours --target recall --recall-style report --recall-walk fine \ --stage val --val-list tmp/diverse12.txt # 본 보고서의 트레이스 (API 콜 0, 요청 전량 기록) python tmp/trace_pipeline.py combo_002_L29_S48_0006 → tmp/trace_out.json

10.4 이 보고서에서 새로 드러난 것