tmp/trace_pipeline.py, API 콜 없이 요청 전량 기록)로 검증한 수치만 기재.
이 보고서를 쓰려고 실제 트레이스를 돌리다가 발견한 사항입니다. 디스크 상태가 문서(claude/context.md)와 다릅니다.
DATA_ROOT가 이미 879 신 릴리즈를 가리킨다config.py:4는 ~/Dataset/scene-mem-benchmark/data를 가리키는데, 07-27에 이 경로가 879 릴리즈로 교체(승격)됐습니다. context.md에는 "현재 DATA_ROOT가 가리키는 곳 = 구 858"이라 적혀 있으나 실제와 다릅니다.
DATA_ROOT에서는 coarse walk조차 못 돈다879 릴리즈에는 fine_subtasks.json이 0개입니다. 알려진 블로커는 "--recall-walk fine이 안 된다"였는데, 실제로는 더 넓습니다 — segment_goal()이 GOAL 텍스트를 fine_subtasks.json의 per_test[i].instruction에서 읽기 때문에 모든 walk 콜이 첫 스텝에서 assert로 죽습니다.
(model, method, scenario_id, variant)뿐이고 데이터 버전을 포함하지 않습니다. 만약 신 데이터에 annotation을 채워 넣고 그냥 돌리면, ID가 같은 시나리오는 구 에피소드로 측정된 캐시 row가 그대로 반환됩니다. 신 데이터로 갈 때는 캐시를 분리하거나 비워야 합니다.본 보고서의 모든 트레이스 수치는 재현 가능한 기준인 .bak(구 858, annotation 포함)에서 측정했습니다. 데이터셋 디렉토리는 읽기만 했고 아무것도 수정하지 않았습니다.
┌─ 시나리오 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() 훅으로 교체).
실측 대상: combo_002_L29_S48_0006 (.bak 기준).
| camera | frames | fps | duration | 해상도 | 파일 | stride |
|---|---|---|---|---|---|---|
| robot0_agentview_left | 10,230 | 20.0 | 511.5 s | 256×256 | 7.5 MB | 10 |
| robot0_agentview_right | 10,230 | 20.0 | 511.5 s | 256×256 | 7.5 MB | 10 |
| robot0_eye_in_hand | 10,230 | 20.0 | 511.5 s | 256×256 | 11.0 MB | 10 |
sample_episode() — 디코딩과 합성video.py:41. 카메라마다 전체를 순차 디코딩하면서 idx % stride == 0인 프레임만 보관합니다. seek을 쓰지 않고 전 프레임을 읽습니다(정확도 우선, 시나리오당 3파일 완전 디코딩).
global_idx가 원본 프레임 번호라서 subtasks.json의 frame_start/end와 직접 비교 가능합니다. 별도 좌표 변환이 없습니다._by_idx)에서 임의 시점 이미지를 꺼내므로 "8분 전 프레임"을 다시 볼 수 있는 것입니다.cap_uniform() — 콜당 8장으로 줄이기video.py:82. 윈도 안 프레임이 8장을 넘으면 균일 인덱싱으로 8장만 남깁니다(자르기가 아니라 솎아내기 — 윈도의 시간 범위는 유지되고 간격만 벌어집니다).
parts 조립 — 순서가 고정돼 있다runner._unified_call() (walk) 와 run_scenario_recall() Phase 2 (final) 둘 다 동일한 3단 구조입니다.
_to_messages() — 실제 전송 형태clients/openai.py:37. 시스템 메시지 1개 + user 메시지 단 1개이고, parts 전체가 그 user 메시지의 content 배열에 순서대로 들어갑니다. 이미지는 base64 data URI로 인라인됩니다(URL 참조 아님).
| call | 이미지 | 이미지 바이트 | 텍스트 파트 | 텍스트 문자 | system 문자 | schema |
|---|---|---|---|---|---|---|
| call 0 | 8 | 641 KB | 1 (메모리 아직 없음) | 341 | 1,596 | StepOut |
| call 1 | 8 | 641 KB | 2 | 753 | 1,596 | StepOut |
| call 2 | 8 | 663 KB | 2 | 787 | 1,596 | StepOut |
| call 3 | 8 | 615 KB | 2 | 821 | 1,596 | StepOut |
| call 4 | 8 | 536 KB | 2 | 690 | 1,596 | StepOut |
| call 5 | 8 | 757 KB | 2 | 789 | 1,596 | StepOut |
| call 27 (마지막) | 8 | 753 KB | 2 | 1,565 ← 로그가 길어짐 | 1,596 | StepOut |
step_user_text)GOAL 문자열 = fine_subtasks.json의 per_test[i].segments[j].instruction. 여기에 "place it on any shelf in the freezer"가 그대로 들어 있는 것이 next-subtask 모드의 recipe leak입니다 — 정답이 질문에 적혀 있습니다.
recall_user_text)try-except가 없습니다(프로젝트 규칙). 네트워크/게이트웨이 오류는 openai SDK의 max_retries=5가 처리하고, 그래도 실패하면 예외가 그대로 올라와 런이 죽습니다 — 부분 결과를 조용히 남기지 않기 위함입니다. 실제로 429 COST limit이 이런 식으로 런을 중단시킨 적이 있습니다.
| 좌표 | 단위 | 출처 | 변환 |
|---|---|---|---|
| 원본 프레임 | 0 … 10,229 | mp4 | 기준 |
| 실시간 | 초 | — | t = idx / 20.0 |
| 샘플 인덱스 | 0 … 1,022 | sample_episode | global_idx = 샘플번호 × 10 |
| 세그먼트 경계 | 원본 프레임 | subtasks.json | 변환 없음 — 직접 비교 |
slice_segment(frames, lo, hi)는 lo <= f.global_idx < hi로 필터합니다. 세그먼트 경계와 프레임 인덱스가 같은 좌표계라 오프셋 버그 여지가 없습니다 — 과거 렌더러에서 냈던 ×10 실수는 이 성질을 착각한 것이었습니다.
| # | 종류 | task | 프레임 범위 | 길이 | K(fine) | 샘플 수 |
|---|---|---|---|---|---|---|
| 0 | TASK | MoveFridgeToFreezer | [0, 724) | 36.2 s | 4 | 73 |
| 1 | TRANSIT | Transit | [724, 1003) | 13.9 s | 1 | 28 |
| 2 | TASK | RestockBowls | [1003, 2470) | 73.3 s | 1 | 146 |
| 3 | TRANSIT | Transit | [2470, 2680) | 10.5 s | 1 | 21 |
| 4 | TASK | OvenBroilFish | [2680, 3406) | 36.3 s | 3 | 73 |
| 5 | TRANSIT | Transit | [3406, 3617) | 10.6 s | 1 | 21 |
| 6 | TASK | SimmeringSauce | [3617, 4846) | 61.5 s | 6 | 123 |
| 7 | TRANSIT | Transit | [4846, 5038) | 9.6 s | 1 | 19 |
| 8 | TASK | ArrangeUtensilsByType | [5038, 7512) | 123.7 s | 8 | 248 |
| 9 | TRANSIT | Transit | [7512, 7796) | 14.2 s | 1 | 28 |
| 10 | TASK | ClearSink | [7796, 10230) | 121.7 s | 1 ← | 243 |
세그먼트를 K개 fine chunk로 프레임 수 비례 균등 분할합니다(fine별 실제 경계 데이터가 없어서 쓰는 근사).
여기가 프로젝트 전체를 뒤집은 지점입니다. 두 정렬 방식이 무엇을 STATE로 주는지:
실측 — seg0을 fine walk로 돌 때 각 콜이 실제로 받는 윈도:
| 관측 대상 | STATE 윈도 | 프레임 | 길이 |
|---|---|---|---|
| fine #0 | 직전 세그먼트 tail → 첫 세그먼트라 opening chunk 폴백 | f[0, 181) | 9.1 s |
| fine #1 | chunk[0] | f[0, 181) | 9.1 s |
| fine #2 | chunk[1] | f[181, 362) | 9.1 s |
| fine #3 | chunk[2] | f[362, 543) | 9.1 s |
부작용: 첫 세그먼트에서 fine#0과 fine#1이 같은 윈도를 받습니다(폴백 때문). 그리고 chunk[3] f[543,724)는 어떤 콜에도 STATE로 안 들어갑니다 — leadup은 구조적으로 마지막 chunk를 관측에서 제외합니다.
--target memory (next-subtask) — 테스트마다 독립 walk실측(MoveFridgeToFreezer, tgt_idx=0, K=4, tfi=1): chunk = 2콜(k=0,1) / leadup = 1콜(chunk[0] 하나로 바로 채점). 이 테스트는 타깃이 seg0이라 선행 세그먼트가 없습니다.
run_scenario가 루프 안에서 make_policy 호출). 즉 시나리오의 3 테스트가 walk를 공유하지 않고 각자 처음부터 돕니다.--target recall — walk 1회를 3 테스트가 공유| 변형 | Phase 1 walk | Phase 2 final | 총 콜 | 배수 |
|---|---|---|---|---|
| recall coarse | 11 (세그먼트당 1) | 3 | 14 | 1.0× |
| recall fine | 28 (= ΣK) | 3 | 31 | 2.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 테스트가 같은 메모리를 보므로 테스트별 맞춤 적재는 불가능합니다.
모두 StepPolicy의 3+2 훅 구현입니다: observe()(적재) / context_parts()(walk 중 제시) / recall_parts()(회상 시 제시, 기본은 context_parts 위임) / summary()(기록) / walk_system()·walk_schema()(ours만 오버라이드).
| method | observe가 저장하는 것 | 저장 형태 | walk 중 제시 | 회상 시 제시 | 용량 한계 |
|---|---|---|---|---|---|
| control | 아무것도 안 함 | — | 없음 | 없음 | — |
| M1 | 윈도 프레임 전부 append | FrameRef 리스트 | 균일 8장 | 균일 8장 | 무제한 저장 / 8장 제시 |
| M2 | keyframe_positions → global_idx | int 리스트 (중복 허용) | 선택 ≤8장 | 선택 ≤8장 | cap 8 (최근편향) |
| M3 폐기 | memory 문자열로 덮어쓰기 | str 1개 | 텍스트 1개 | 텍스트 1개 | 1개 — 장기 망각 |
| M3v2 | raw를 append | str 리스트 | 번호매긴 전문 | 번호매긴 전문 | 무제한 |
| ours | keyframe_tags 인라인 | idx→태그 dict + 후보 int | 선택 ≤8장 | 질의 매칭 top-k 태그+이미지 | 이미지 8 / 태그 무제한 |
memory/cluster.py. 저장하는 건 이미지가 아니라 정수 인덱스입니다. 이미지는 회상 때 _by_idx(에피소드 전체 디코딩 dict)에서 꺼냅니다 — 그래서 "8분 전 프레임"을 되볼 수 있습니다.
eviction 실측 증거 — 트레이스에서 M2가 최종 보관한 8개가 어느 세그먼트인지:
reps[-8:]는 시간상 최근 8클러스터만 남기므로, 에피소드 초반 사건은 클러스터가 8개를 넘는 순간 반드시 버려집니다. 용량을 16으로 올린 게 --m2-force지만, 강제 지목한 중앙 프레임이 배치 순간이 아니면 노이즈라 이득이 제한적이었습니다(place 0.179→0.231).실측 런에서는 sparse nomination까지 겹쳐 더 적었습니다 — 예: combo_014 M2 최종 보관 6개 [90, 150, 190, 290, 390, 6360].
모델이 emit한 raw 문장을 그대로 덧붙입니다. 회상 때는 번호를 매겨 통째로 넣고 모델이 알아서 찾게 합니다(인덱스 없음).
walk가 진행될수록 이 텍스트가 길어집니다(실측: 콜0 341자 → 마지막 콜 1,565자). 그래도 이미지가 없어 비용은 가장 쌉니다.
walk 프롬프트 자체를 바꿉니다(walk_system() → OURS_STEP_SYSTEM, walk_schema() → OursStepOut). 한 콜에서 다음 sub-task와 keyframe 태그를 동시에 받습니다.
실측 — ours만 정답을 맞힌 row (combo_014, 질의 "Show me where the cup with liquid is placed.", GT Place the cup in the microwave):
| method | kind | 저장 규모 |
|---|---|---|
| control | control | (없음) |
| M1 | m1 | 224 frames 누적 → 제시는 균일 8장 |
| M2 | keyframe | selected 8개 (cap 8) — 전부 후반 세그먼트 |
| M3v2 | subtask_log | 28줄 (= fine walk 콜수와 동일) |
| ours | tagged_keyframe | 이미지 8장 예산 / 태그 27개 — 텍스트는 cap 없음 |
Phase 2에서 테스트마다 정확히 1콜. STATE는 마지막 세그먼트를 균일 8장으로 훑은 것이고 3 테스트가 이걸 공유합니다.
| method | STATE 이미지 | 메모리 이미지 | 메모리 텍스트 | 콜당 총 이미지 |
|---|---|---|---|---|
| control | 8 | 0 | 0 | 8 |
| M1 | 8 | 8 | 0 | 16 |
| M2 | 8 | 8 | 0 | 16 |
| M3v2 | 8 | 0 | 1 | 8 |
| ours | 8 | 3 (top-k) | 1 | 11 |
비용 차이(스윕 실측: m3v2/control $6.20 vs m1 $11.92 vs m2 $10.42)가 이 이미지 수에서 그대로 나옵니다.
채점 대상은 StepOut.raw(자연어 문장) 하나입니다. 구조화 필드(action/object/place)는 채점에 쓰지 않고, raw를 다시 파싱해서 씁니다 — GT도 같은 파서를 통과시켜 대칭을 맞춥니다.
action_acc는 무의미합니다. RECALL_SYSTEM이 action='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×로 정정됐습니다.
재개 단위는 (시나리오, 테스트)입니다. recall 모드는 시나리오의 테스트가 전부 캐시에 있으면 sample_episode와 walk 전체를 건너뜁니다(run_scenario_recall 초입의 all-hit 체크). 일부만 있으면 walk를 다시 돌고 미캐시 테스트만 final call을 합니다 — 부분 캐시는 walk 비용을 아끼지 못합니다.
scenario_id면 영상이 바뀌어도 구 row가 반환됩니다. §0의 879 전환에서 실제 위험입니다.토큰 단가 추정을 폐기하고, 게이트웨이가 응답에 실어주는 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=…)를 출력합니다.
| 파일 | 책임 | 핵심 심볼 |
|---|---|---|
| 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.py | structured output 스키마, Part 타입 | StepOut, OursStepOut, KeyframeTag(At), FrameRef |
| memory/base.py | 메모리 5종 정책 | StepPolicy, Control/M1/M2/M3/M3v2/OursPolicy |
| memory/cluster.py | MemER Algorithm 1 | cluster_indices, median_low, select_keyframes |
| runner.py | walk 오케스트레이션·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) |
| 플래그 | 값 | 영향 지점 | 현행 정본 |
|---|---|---|---|
| --target | memory / recall | walk 구조 전체 | recall |
| --state-align | chunk / leadup | memory 모드 채점 STATE 위치 | leadup |
| --recall-style | execute / report | final call 시스템 프롬프트 | report |
| --recall-walk | coarse / fine | Phase 1 적재 입도 | fine |
| --m2-force | flag | M2 강제 지목 + cap 16 | off |
| --ours-retrieval | r1 / r2 | ours 회상: 전체 덤프 / 검색 | r2 |
| --ours-topk | int | 매칭 태그 surface 개수 | 3 |
기본값은 전부 back-compat(chunk/execute/coarse)이라, 플래그를 안 주면 폐기된 측정 설정으로 돕니다. 정본 커맨드는 4개 플래그를 모두 명시해야 합니다.
segment_goal이 fine_subtasks.json을 요구)..bak에 보존돼 있어 과거 결과 재현은 가능.