target_fps를 15에서 30으로 올리고 평가 자산을 전부 다시 뽑았다(GPU 약 55분). 결과를 적기 전에 제목의 뒷문장을 먼저 못 박아 둔다.

밴드 적중이 올랐지만 정확해진 것이 아니다

밴드 적중이 31.8%에서 49.8%로 오른다. 이 숫자만 보면 개선처럼 읽히는데 그렇지 않다.

임팩트를 더 정확히 찾아서가 아니라 다른 프레임을 고르게 되어서다. 15fps와 30fps는 서로 다른 격자에서 argmax를 취하므로 당연히 다른 답을 낸다. 어느 쪽이 실제 임팩트에 가까운지는 정답이 있어야 말할 수 있고, 그 정답은 없다 (미결 5번 보류).

얻은 것은 측정 타당성 회복이다. 15fps에서는 임팩트가 실제로 일어난 프레임이 격자에 아예 없는 경우가 많았다. 측정 자체가 성립하지 않던 상태에서, 적어도 후보 격자 안에는 들어오는 상태가 됐다. 그뿐이다.

얇은 argmax 마진은 그대로다. 미결 7번이 확정한 원인 중 어느 것도 fps를 올려서 사라지지 않는다.

비교 가능한 것만 옮긴다.

지표 15fps 30fps
총 프레임 5,404 10,705
프레임당 후보 5.70 5.73
B-1 baseline 58.1% 58.1%
B-1 B 70.9% 74.4%
A≠B 불일치 11.03% 12.45%
B-6 features_ok 111 106
B-6 등급 변화 0 0

switch_rate는 비교하지 않는다. 프레임 간격이 절반이 되면 프레임당 전환 확률이 기계적으로 내려간다 — 정의상 다른 척도다. B-4/B-5 검수 60건도 표본으로서 무효다. 라벨은 재매핑으로 살지만 그 60건은 15fps 불일치 596건의 상위에서 뽑은 것인데 모집단이 1,333건으로 바뀌었다.

프레임당 후보 밀도가 5.70 → 5.73으로 거의 안 움직인 것이 온전성 검사다. 프레임 수만 두 배가 되고 검출 특성은 그대로라는 뜻이다.

코드 사본이 예외 없이 다른 숫자를 냈다

재실행 도중에 걸렸다. 라벨 재매핑을 저장소의 targets.py에 넣고 B-1을 돌렸는데, 평가 스크립트가 /mnt/d에 있는 다른 targets.py를 읽고 있었다.

sys.path.insert(0, "/mnt/d/supersub-phaseA/labeling")
from targets import enumerate_targets, ...

그쪽 사본은 2026-08-27에 멈춰 있었다. 재매핑이 없으니 ratio로 프레임을 다시 유도했고, 그러면 반올림 때문에 117개 중 42개가 원본 기준 1프레임씩 어긋난다.

예외도 경고도 없었다. 숫자만 달랐다.

selector 옛 사본 저장소
A 70.9% 70.1%
B 73.5% 74.4%

조용히 틀리는 종류다. 로그를 봐도, 결과를 봐도 알 수 없다. 두 번 돌려서 값이 바뀌는 것을 보고서야 알았다.

eval_b3·design_b4·select_b4·selector_downstream은 targets뿐 아니라 eval_b2까지 그쪽에서 가져오고 있었다. selector 구현과 가중치의 “유일한 출처”가 갱신 안 되는 사본이었다는 뜻이다.

갈라진 시점을 확정할 수 있었던 이유

가장 급한 질문은 “지금 숫자가 틀렸나”가 아니라 “과거에 기록한 결론은 어느 코드로 나온 건가” 였다. 수치가 틀린 것보다 출처를 모르는 게 나쁘다.

확정할 수 있었다. 방법은 단순하다 — 오늘 작업 이전 커밋(5225d10)의 저장소판과 /mnt/d 사본을 파일마다 대조했다.

  • .py 38개 중 37개가 바이트 동일
  • 갈라진 것은 extract.py 하나뿐이고, 그것도 커밋 654fee7 판과 정확히 일치

extract.py의 두 차이도 확인했다. observe=False는 PoseResult를 다 만든 뒤 기록만 하고(_record_input_observation은 None 반환), r.frames → load_frames()는 JPEG 덤프 경로다. 둘 다 keypoints·objects·fps를 못 바꾼다.

따라서 오늘 이전 기록된 B-1~B-6 결론은 어느 사본으로 돌렸든 같다. 갈라짐은 내가 오늘 저장소 쪽을 고치면서 처음 생겼고, 같은 세션 안에서 드러났다. 추정이 아니라 확정이다.

/mnt/d의 .py 38개를 지웠다. 코드는 저장소, 데이터는 /mnt/d. 조치 후 B-1/B-2를 다시 돌려 이번 재실행 산출물과 바이트 동일함을 확인했다.

곁다리 하나 — /mnt/d의 extract.py는 지금 실행하면 그냥 죽는다. 미결 9번 D-1이 PoseResult.frames를 없앴는데 그 사본은 r.frames를 쓴다. 사본은 낡기만 하는 게 아니라 틀리게 낡는다.

features_ok 6건은 A-1을 되돌린 값이다

features_ok가 111 → 106으로 줄었다. 분해하면 이렇다.

실패 사유 15fps 30fps
품질 게이트 84 83
경계 규칙 0 6

품질 게이트 실패는 오히려 줄었고, 순감소 5는 전부 경계 규칙이다. 15fps에서는 경계 실패가 한 건도 없었다.

원인을 캐시에서 직접 봤다. gg5xRWjw3f8, 5개 selector 전부 실패:

  f297 f298 f299
팔꿈치각 15.5 1.2 83.1
usable ✗ ✗ ✓

f299의 각속도가 |83.1 − 1.2| = 81.9로 클립 전체 최대가 되어 argmax를 이긴다. 1.2는 측정 불가 프레임 f298의 쓰레기 값이다. 정상 프레임의 속도가 망가진 이웃 때문에 부풀려지는 것 — 8월 31일에 A-1로 고치려다 되돌린 바로 그 문제다.

15fps에서는 마지막 세 프레임이 전부 usable이 아니어서 분석 구간이 0~146으로 줄었고, 그 쓰레기가 탐색 범위에 아예 들어오지 못했다. 30fps에서 f299만 usable이 되면서(손목 신뢰도 0.95) 구간이 0~299로 늘어나 오염이 노출됐다.

A-1을 적용해 보면 argmax가 f299 → f84로 옮겨가 통과한다.

그래도 되돌린 결정은 유지한다

이 6건은 REVERT 근거를 흔들지 않는다. 되돌린 이유는 “오염이 없다”가 아니었다. 오염은 처음부터 실재한다고 적었다 — 잔차가 자연 변동의 85배, 과잉 제거 1.7%. 되돌린 이유는 야구 impact 84.3% 이동의 정당성을 검증할 정답이 없다는 것이었고, 그건 오늘도 그대로다.

오히려 이 6건은 드러나게 실패한다. 경계 검사가 반려하니 산출이 없고, 없다는 사실이 기록된다. A-1을 켜서 얻는 건 그 6건이 통과하는 것인데, 그렇게 나온 값이 옳은지는 여전히 확인할 수 없다. 조용히 틀린 값을 내는 것보다 낫다.

되돌릴 때 “남는 문제”로 적어 둔 것이 실제로 그대로 나타났을 뿐이다. 정정할 것은 없다. 다만 이제 비용이 숫자로 있다 — 나중에 팔 종목 릴리스 정답 25~60클립이 확보되면, 이 6건이 A-1 계열 처방을 판정할 첫 시험대가 된다. 물을 것은 하나다. A-1이 옮긴 f84가 실제 릴리스에 가까운가, 아니면 f299 반려가 옳았는가.

지금은 둘 다 말할 수 없다.

남긴 것

미결 10번(서비스/평가 이중화)을 해소했다. pose.DEFAULT_TARGET_FPS를 단일 진실원으로 두고 평가 12곳·CLI 2곳이 import한다. 리터럴 30을 뿌리는 방안은 택하지 않았다 — 지금은 맞지만 다음에 값을 옮길 때 또 갈라진다. 값은 한 곳, 의존은 눈에 보이게.

새 항목 둘을 열었다. 17번 — np.gradient의 끝단은 단측차분이라 이웃 하나가 망가지면 희석 없이 그대로 속도가 된다. 경계 실패 6건이 전부 클립 끝단에서 난 게 우연이 아니다. 18번 — 코드 사본은 없앴지만 /mnt/d 데이터 경로 상수는 13개 스크립트에 그대로다.

max_frames는 300 그대로 뒀다. 평가셋 필요값이 299라 손댈 이유가 없었다.