항목은 올린 사람의 브랜치별 구역으로 나눠 적습니다 — 자기 구역에만 추가하면 두 사람이 동시에 올려도 충돌하지 않습니다. 번호는 구역 안에서만 세므로 구역이 다르면 겹쳐도 됩니다. 규칙은 CLAUDE.md 「미결 항목」 절에 있습니다.

내 할 일을 찾는 방법. 이 페이지를 위에서 아래로 읽지 마세요 — 담당은 제기자 구역에 흩어져 있습니다. 저장소에서 grep -n '담당.*<내 이름>' jekyll/pages/pending.markdown 을 돌리거나, 브라우저에서 Ctrl+F 로 담당: <이름> 을 찾으세요. 제목에 ✅ 해소·⛔ 가 붙은 항목은 닫힌 것이고, 근거를 남기려고 지우지 않고 둡니다.

해소된 지난 항목은 미결 항목 (보관) 에 있습니다.

항목 찾아보기 — 구역과 제목만 펼쳐 봅니다

ho (정상호)

1. EXAONE 라이선스 — 상업적 이용 가능 여부

EXAONE은 EXAONE AI Model License Agreement 1.2 - NC 로 배포된다. NC는 비상업(Non-Commercial)을 뜻하며, 연구·교육 목적의 이용은 허용되지만 상업적 이용에는 LG AI Research와의 별도 라이선스 계약이 필요하다.

Super-Sub은 수익 모델을 전제한 서비스이므로 이 항목이 해결되지 않으면 시장 및 수익 모델과 시스템 설계 4)절의 전제가 함께 흔들린다.

  • 확인 필요: LG AI Research 상업 라이선스 조건 및 문의 절차
  • 대안 검토: 상업 이용이 불가할 경우 Apache-2.0 / MIT 계열 한국어 지원 모델로 교체
  • 영향 범위: 모델 교체 시에도 루브릭·스키마·파이프라인은 그대로 재사용 가능하며, 교체 대상은 판정 백엔드 한 곳으로 한정된다
  • 담당: 정상호 · 기한: 개발 착수 전 → 서비스 상업 오픈 전 (아래 결정 참고)

결정 — 지금 단계는 급하지 않다 (2026-09-08, 박민호)

범위: 지금 10주 개발 기간(프로토타입·제안서 제출까지) 동안만. 이 기간에는 매출이 없는 개발·검증 단계이므로 EXAONE의 NC(비상업) 라이선스가 걸리지 않는다. 실제 서비스를 유료로 열거나 사용자에게 요금을 받는 시점부터는 다시 막힌다 — NC 라이선스는 보통 “이 기능만 무료로 두면 된다”가 아니라 “매출이 나는 서비스 안에 모델이 들어가면 그 자체로 상업적 이용”으로 보는 경우가 많아서, AI 평가만 무료로 제공해도 전체 서비스가 유료라면 여전히 걸릴 수 있다. 이 부분은 실제 오픈 시점에 라이선스 원문으로 다시 확인해야 하며 지금 판단으로 미리 안전하다고 단정하지 않는다.

이 결정으로 LG AI Research 문의는 보류한다 — 서비스 오픈 계획이 잡히기 전까지 급하지 않다. 같은 구역 미결 15번이 이미 같은 경계선(“EC2가 외부 사용자에게 서비스를 제공하는 순간”)을 지적해 뒀으므로, 그 항목이 열리는 시점과 이 항목이 다시 급해지는 시점은 같다.

🔴 다만 정상호가 이 결정과 별개로 대체 후보 조사를 이미 끝내 뒀다(아래 「대체 후보 조사」). 급하지 않다고 조사 자체가 무의미해지는 것은 아니고, 오히려 나중에 실제로 필요해졌을 때 처음부터 다시 찾지 않아도 되는 자료다. 후보를 고르는 실행(포즈 GPU 피크 실측 등)까지 지금 서두를 필요는 없다.

🗂 대체 모델 조사 (2026.09.08) — 후보·뺀 것·GPU 예산 실측. 펼쳐 봅니다

대체 후보 조사 (2026.09.08, 정상호) — 아직 고르지 않았다

라이선스는 각 모델 카드에서 직접 확인했다. 결론부터: 지금 설정을 안 건드리고 갈 수 있는 것은 Qwen3-1.7B 하나뿐이고, 한국어에 유리해 보이는 둘은 GPU 예산 실측이 선행되어야 한다.

  EXAONE 4.0 1.2B (현재) Qwen3-1.7B Kanana 1.5 2.1B Mi:dm 2.0 Mini 2.3B
라이선스 NC 🔴 Apache-2.0 Apache-2.0 MIT
만든 곳 LG AI Research 알리바바 카카오 KT
fp16 가중치 ~2.4GB ~3.4GB ~4.2GB ~4.6GB
5.4GB에서 KV 여유 ~3.0GB ~2.0GB ~1.2GB ~0.8GB
컨텍스트 65,536 32,768 32K(YaRN 128K) 미확인
언어 한·영·스페인 100개 이상 한·영 이중언어 한·영 중심
추론 모드 하이브리드 하이브리드(기본 켬 🔴) 미확인 미확인

한국어는 국산 둘이 유리할 공산이 크다(둘 다 한국어를 주 언어로 학습, Mi:dm은 Base에서 가지치기·증류한 경량판). 대신 크기 값을 치른다 — 아래 예산 절 참고.

🔴 Qwen3를 쓸 때의 함정 하나: enable_thinking 기본값이 True다. judge.py 의 guided_json 경로가 먹으면 스키마가 첫 토큰부터 강제되어 무해하지만, 서버가 그 필드를 거부해 대체 경로로 내려가면 모델이 <think> 부터 쓰기 시작해 max_new_tokens=256 을 추론에 다 쓰고 JSON 에 도달하지 못한다. 쓴다면 요청 본문에 chat_template_kwargs: {enable_thinking: false} 를 함께 넣는다.

1번 조건에 위배되어 뺀 것 — 재조사하지 않는다

모델 왜
HyperCLOVA X SEED (네이버) 라이선스가 hyperclovax-seed 커스텀이다. Apache/MIT 가 아니고 동의 클릭이 필요하다
Gemma 3 (구글) Apache-2.0 이 아니다. 자체 약관 + 금지 사용 정책 + 재배포 시 약관 전달 의무
Llama 3.x (메타) 커뮤니티 라이선스 — MAU 조건·이름 표기 의무
A.X 4.0 Light (SKT) 라이선스는 Apache-2.0 으로 문제없다. 7B(fp16 ~14GB)라 지금 구성에 안 들어가서 뺐다 — 아래 (나) 결정이 뒤집히면 후보로 돌아온다

🔴 진짜 제약은 라이선스가 아니라 GPU 예산 5.4GB 다

deploy/serve_vllm.sh 가 --gpu-memory-utilization 0.35 로 돈다. T4 15360MiB 를 vLLM 과 포즈 모델(RT-DETR + ViTPose)이 나눠 쓰기 때문이다 — vLLM 은 잡은 몫을 놓지 않아서 기본값 0.9 로 두면 포즈 추출이 OOM 으로 죽는다. --dtype float16 도 선택이 아니다(T4 는 SM 7.5 라 vLLM 이 bf16 을 거부한다).

✅ 포즈 피크를 쟀다 (2026.09.08) — 예산이 크게 남는다

0.35 는 계산이 아니라 “1.2B 가 들어가고 포즈가 안 죽더라”에서 나온 값이었다. 실측했다. 원본과 절차는 agent/eval/pending1_gpu_budget/.

조건 피크 (MiB)
vLLM 정상 상태 (EXAONE 1.2B, fraction 0.35) 5441
포즈 단독 · 1920×1080 (업로드 상한) 903
포즈 단독 · 2160×3840 (세로 4K) 905
포즈 단독 · 3840×2160 (가로 4K) 905
동시 실행 (vLLM + 포즈) 6341
놀고 있는 용량 9019

5441 + 900 = 6341 로 정확히 더해진다. 200ms 간격 샘플링, T4 15360MiB.

포즈에 배정한 약 10GB 중 실제로 쓰는 것은 0.9GB다. 넉넉히 4배(3.6GB)를 남겨도 vLLM 에 11GB 이상 줄 수 있다 → (가) 만으로 충분하고 (나)는 하지 않는다.

후보 fp16 가중치 지금 5.4GB fraction=0.70(10.7GB)
Qwen3-1.7B 3.4GB ✅ ✅
Kanana 1.5 2.1B 4.2GB ⚠️ 빠듯 ✅ 들어온다
Mi:dm 2.0 Mini 2.3B 4.6GB ⚠️ 매우 빠듯 ✅ 들어온다
Qwen3-4B 8.0GB ❌ ✅
A.X 4.0 Light 7B ~14GB ❌ ❌ 여전히 안 된다

🔴 해상도는 VRAM 을 올리지 않는다. 4K 두 편이 1080p 와 같은 905MiB 다. 프로세서가 추론 전에 고정 크기로 줄인다(RT-DETR 자체 리사이즈, ViTPose 는 사람 박스를 잘라 쓴다). 미결 9번(4K host RAM)이 왜 host RAM 문제인지가 이걸로 설명된다 — 4K 가 비싼 것은 CPU 메모리에 프레임을 쌓는 쪽이고, 두 항목은 같은 자원을 다투지 않는다.

주의: --enforce-eager 가 켜져 있어 1~3GB 를 아끼는 중이다(끄면 vLLM 이 더 쓴다) · 200ms 보다 짧은 스파이크는 못 잡는다 · GPU_FRACTION 은 아직 안 올렸다 — EXAONE 1.2B 는 더 줄 이유가 없고, 쓸 모델이 정해질 때 함께 바꾼다.

  무엇 크기 features 가 바뀌나
(가) 예산 재분배 — SUPERSUB_GPU_FRACTION 을 올리고 vLLM 재시작 작다. env 한 줄 + 재시작 ❌ 안 바뀐다 → B-6 재실행 없음
(나) 포즈가 쓰는 양 자체를 줄임 — fp16 적재·해상도 축소 크다 🔴 바뀐다 → B-6 재실행을 부른다

(나)까지 갈 일은 없어 보인다. pose.py 를 보면 RT-DETR(~42M)+ViTPose-base(~86M) 로 가중치는 fp32 로도 0.5GB 정도고, 추론은 프레임 하나씩(배치 1) 돈다. 포즈의 10GB 배정은 가중치가 아니라 큰 프레임 하나의 활성화 + 여유이므로, 업로드 상한 1920×1080 에서는 실제 피크가 한참 낮을 것이다.

다음 한 걸음 — 이 순서로 (서비스 오픈 계획이 잡히면 시작)

  1. 포즈 피크 실측 ✅ 끝났다 (2026.09.08, 위 절). 여유가 9019MiB 다
  2. ✅ 후보 셋이 다 살아 있다 — Qwen3-1.7B · Kanana 1.5 2.1B · Mi:dm 2.0 Mini 2.3B. 뒤 둘은 GPU_FRACTION 을 0.70 쯤으로 올리는 것이 함께 간다(env 한 줄)
  3. (나)는 하지 않는다. 포즈 코드를 건드릴 이유가 사라졌다
  4. 고르기 전에 합격선을 먼저 고정한다(사전 등록). eval/pending23_evidence/ 의 rejudge.py(저장된 features 로 문장만 다시 생성) + check_evidence.py (등급 표기·지어낸 수치·역방향 문장)로 같은 절차를 모델만 바꿔 돌리면 비교가 된다. 🔴 다만 미결 23번의 「같은 19문장으로 3회차를 바로 돌리지 않는다」가 걸린다 — 그 19건에 맞춰 고르는 것이 되지 않게 문장 수를 늘리거나 위험을 명시하고 간다

교체 범위는 좁다 — judge.py 의 MODELS 한 줄, vllm.env 의 SUPERSUB_SERVED_NAME·SUPERSUB_MODEL_DIR, 그리고 가중치. 등급은 코드가 정하므로(scoring.Criterion.grade_for) 모델이 바뀌어도 점수는 안 흔들린다.

대체 시 재검증 체크리스트 — 위 3후보 외의 것을 검토할 때 쓰는 일반 기준

“영향 범위가 판정 백엔드 한 곳”이라는 말이 교체가 쉽다는 뜻은 아니다. 시스템 설계 4)절이 EXAONE 4.0 1.2B를 고를 때 실측으로 확인한 조건들을, 대체 후보도 똑같이 통과해야 후보가 된다. 후보를 정할 때 아래를 하나씩 재확인한다 — 순서를 건너뛰면 무엇이 부족해서 탈락했는지 판별할 수 없다.

확인 항목 EXAONE 4.0 1.2B가 통과한 근거 대체 후보에서 다시 봐야 할 것
transformers 네이티브 지원 3.5는 trust_remote_code라 상류 버전 변경에 깨져 탈락했다(6장) 원격 코드 의존이면 같은 문제 재발 — 네이티브 통합 여부 우선 확인
8GB VRAM (bf16) 적재 1.2B가 2.4GB만 써 5.7GB 여유(6장 실측 표) 한국어 특화 소형(1~3B) Apache/MIT 모델은 선택지가 더 적어 규모를 올리면 여유를 넘을 수 있다
outlines 제약 디코딩 호환 강제 없이는 마크다운 코드펜스로 감싸 JSON 파싱이 실패함을 실측으로 확인(6장) 신규 모델 아키텍처에서 제약 디코딩 라이브러리가 동작하는지 별도 확인 필요
comparison 필드로 경계값 비교 정확도 확보 이 필드 없이는 141.7이 140~165 안인지도 틀렸다(6장·_posts/2026-08-25-실클립…) 같은 보완 전략(항목별 분해 호출·앵커 예시·프리픽스 캐싱)이 다시 필요할 수 있고, 처음부터 재실측해야 함
한국어 근거 문장(evidence) 품질 EXAONE은 한국어 특화 모델 Apache/MIT 계열 중 한국어 특화는 상대적으로 드묾 — 골든셋(미결 2번) 없이는 자연스러움을 비교 판단할 방법이 없다

결론: 위 3후보(Qwen3-1.7B·Kanana 1.5 2.1B·Mi:dm 2.0 Mini)는 이미 이 조건들을 검토한 상태다. 이 표는 그 셋 외의 새 후보가 나왔을 때 다시 쓰는 일반 틀이다.

  • 담당: 정상호 · 기한: 서비스 상업 오픈 전 (조사는 2026.09.08에 끝냈고, 후보 확정 실행(포즈 GPU 피크 실측 등)은 서비스 오픈 계획이 잡힌 뒤다)

2. 골든셋 라벨링 주체 확보

채점 정확도를 측정하려면 지도자가 라벨링한 골든셋 50~100건이 필요하다. 골든셋이 없으면 루브릭 개선 여부를 판별할 수 없으므로 개발 착수 시점의 선행 작업이다.

🔴 「50~100건」을 한 덩어리로 잡지 마세요 (2026.09.08 갱신). 필요한 것이 성격이 다른 두 가지이고 시작 조건이 반대입니다 — 자세한 근거는 min 구역 4번 회신에 있습니다.

  (A) 임계값 검수 (B) 정답 라벨
지도자가 하는 일 루브릭의 구간을 정한다 클립을 보고 잘했는지 매긴다
필요한 것 지도자 1명. 클립 수는 부차적 표본 수(8번 340건 · 5번 25~60클립)
적은 수로 시작 ✅ 가능 — 실측 분포를 놓고 묻는 자리다 🔴 무의미 — 12건으로 해 봤더니 신뢰구간이 [15.2%, 64.6%]였다
막고 있는 항목 20 · 22 · 23(나) · 6 · draft 루브릭 3개 5 · 8

(A)만 먼저 여는 것을 권합니다. 6개 항목이 거기 걸려 있고, (B)는 촬영·수집이 따라오는 별건이라 같은 일정에 묶으면 (A)까지 늦어집니다.

  • 라벨링을 수행할 지도자 섭외 경로 미정
  • 라벨링 보상 방식 미정
  • 담당: 박민호 · 기한: 개발 착수 전 → 🔴 섭외는 안 하기로 했습니다 (2026.09.17) — 이 항목 맨 아래 「검수 없이 갑니다」 절이 정본입니다. 아래 본문은 그 결정 이전에 쓴 것이라 「지도자가 온다」를 전제합니다 — 기록으로 남겨 두는 것이고 지금 상태가 아닙니다

확인 (2026-09-08, 박민호): ho 브랜치에 라벨 관련 자료가 있어 이 항목이 해소됐는지 먼저 확인했다 — 아니다, 다른 층의 라벨이다.

ho 브랜치 jekyll/pages/labels.markdown (2026-09-04, 정상호)이 라벨을 4개 층으로 나눠 두었다 — 층 1(분석 대상)· 층 2(사지 판별)·층 3(릴리스/임팩트 프레임)·층 4(채점) = 이 항목. phaseA/labeling/labels.json(117프레임/39클립)과 pending6_side/labeling/ (12건)은 전부 층 1~2 자료이고, 그마저도 정답 등급은 D(AI 단독 판독, “부정적 결론의 근거로만” 쓸 수 있음)다. 문서 자체가 층 4를 “라벨 0건, 지도자 미섭외”로 적어 두었다 — 지도자 채점 골든셋은 여전히 없다.

덧붙여, 39클립이 전부 야구 타격인데 타격 루브릭 자체가 아직 없다 (active는 baseball_pitching·basketball_jump_shot·football_instep_shot뿐). 골든셋을 받아도 어느 (종목, 동작)에 먼저 쓸지부터 정해야 한다.

결론: 미해소, 담당·기한 그대로. 남은 작업은 지도자 섭외 경로·보상 방식 확정이며, ho 브랜치 자료로 대체되지 않는다.

섭외 진행 계획 (2026-09-08, 박민호) — 아직 실행 전, 방향만 정함

필요한 것은 축구 인스텝슈팅·야구 투구·농구 점프슛(현재 active 루브릭 셋) 각각을 볼 수 있는 지도자다. 한 사람이 세 종목을 다 볼 필요는 없다 — 종목별로 다른 지도자 1~2명씩 구하는 쪽이 현실적이다.

우선순위 순서로 접근한다:

  1. 지인·학교 네트워크(1순위, 무료) — 팀원 주변의 축구/야구/농구 코치, 체육교육과·스포츠과학과 재학생·조교에게 “캡스톤 프로젝트 검수”로 요청
  2. 아마추어 클럽 코치(2순위) — 조기축구회·리틀야구단·동네 농구클럽 코치. 소액 사례(커피·기프티콘 수준)로 협조 가능한 경우가 많음
  3. 유료 매칭(3순위, 예산 필요 시) — 크몽·숨고 등에서 스포츠 지도사 자격증 보유자에게 건당 사례비 지급. 속도는 빠르나 비용 발생

규모는 한 번에 50~100건을 다 모으지 않는다. 종목 하나(축구)로 먼저 10~20건 시범 진행 → 소요 시간·서식(층 4 라벨 형식, 위 확인 참고)을 검증 → 나머지 종목·건수로 확장한다. 소요 시간이 “미측정”이라 적혀 있는 리스크를 시범 라운드에서 먼저 줄인다.

  • 아직 실제 지도자 접촉·확정은 안 됐다 — 이 계획대로 섭외가 되면 이 항목에 누가·몇 건·어떤 방식으로 확정됐는지 남긴다

✅ 지도자에게 내밀 종이를 만들었습니다 (2026.09.09, 정상호)

섭외 계획은 서 있는데 내밀 것이 없었습니다. 지도자가 와도 「무엇을 봐 주세요」가 준비 안 돼 있으면 그 시간이 낭비됩니다. 그래서 (A) 임계값 검수 서식을 만들었습니다 — agent/eval/pending2_bands/, 루브릭 6개 전부.

cd agent && uv run python eval/pending2_bands/build_review_packet.py --write

클립을 한 편도 안 보고 답할 수 있게 만들었습니다. 항목마다 이렇게 묻습니다: 무엇을 재는지(사람 말로·단위와 함께) → 지금 선은 어디인지 → 우리 클립에서 실제로 나온 값이 어떻게 퍼져 있는지 → 그 선이 낸 결과 → 「이 선이 맞습니까? 아니면 어디로?」

🔴 계획을 한 군데 조정하셔야 할 것 같습니다

「축구로 먼저」는 맞습니다 — 실측이 그것을 뒷받침합니다. 다만 이유가 계획서에 적힌 것과 다릅니다.

루브릭 상태 참고 분포
축구 · 인스텝 슈팅 active 18편 ← 유일하게 분포다운 분포
야구 · 타격 draft 46편 (JHMDB)
농구 · 점프슛 active 1편
야구 · 투구 active 0편

🔴 야구 투구는 참고 분포가 0편입니다. 가진 클립 한 편이 품질 게이트에 걸립니다(상반신 키포인트 유효 프레임 51% < 기준 70%). 서식 자체는 쓸 수 있습니다 — 분포는 참고 자료이지 질문의 조건이 아닙니다. 다만 「우리 선수들이 어디쯤에 몰려 있는가」를 못 보여 드립니다.

그리고 계획서의 「10~20건 시범」은 (B)의 단위입니다. (A)는 건수가 아니라 항목 수로 셉니다 — 축구 인스텝 슈팅은 6항목이고 한 시간이면 끝납니다. 10~20클립을 모으실 필요가 없습니다. 🔴 (A)와 (B)를 같은 일정에 묶으면 (A)까지 늦어진다고 적어 둔 것이 이 지점입니다.

서식이 일부러 안 하는 것

  • 🔴 처방을 미리 적지 않았습니다. 지금 구간이 이상해 보이는 자리를 알고 있지만(미결 20·22번) 서식에 안 적었습니다 — 유도하면 검수가 아니라 확인이 됩니다. 결과를 받은 뒤에 대조합니다
  • 🔴 분포에 맞춰 선을 그으라고 하지 않습니다. 저희 표본이 잘하는 선수들이 아닐 수 있고, 그 문장을 서식 첫머리에 적었습니다
  • 「모르겠다」·「이 항목은 빼야 한다」도 답으로 받습니다

| | | |—|—| 🔴 덧 (2026.09.09, 같은 날 늦게) — 섭외 여건이 안 된다고 확인됐습니다. 그러면 이 항목이 막고 있는 것의 범위를 다시 세어야 합니다. 제가 앞서 「8건이 여기 걸려 있다」고 적은 것을 정정합니다 — 다시 세어 보니 지도자만이 풀 수 있는 것은 draft 루브릭 승격과 20번의 (가)·(나) 정도입니다.

항목 지도자 필요? 실제로 막는 것
22번 (골반 회전 타당성) 아니오 항목 본문이 「축구 클립으로 같은 검사를 돌리면 답이 나온다」고 적어 두었습니다. 임계값이 아니라 타당성 문제라 코치가 답할 질문이 아닙니다
23번 아마 아니오 남은 1건은 루브릭 문구에 숫자가 박힌 것 — 문구 수정입니다
6번 아니오 27건 사람 판독이지 코치가 아닙니다
5·8번 아니오 표본 수입니다(촬영 문제)
20번 (가)·(나) · draft 승격 예 임계값 확정이 필요합니다

기한에 「미결 2번과 함께」라고 적힌 것들은 일정 묶음이지 의존이 아니었습니다. 검증 기준 자체를 어떻게 할지는 같은 구역 34번에 올렸습니다.

위 서식은 그대로 살려 둡니다 — 나중에 지도자가 생기면 그날 바로 씁니다.

   
지금 필요한 것 지도자 섭외 하나 → 34번의 판단(검증 기준 재정의). 서식은 준비돼 있습니다
확인 ls agent/eval/pending2_bands/packet/ — 루브릭 2개 서식(2026.09.16 정리, 아래). 사용법은 같은 폴더 README.md
하지 말 것 🔴 받은 답으로 바로 임계값을 고치지 마세요 — features 는 그대로여도 등급이 달라져 B-6 비교가 끊깁니다. 무엇이 어떻게 달라지는지 먼저 재고 사전 등록을 겁니다

🔴 문구 검수 서식을 따로 냈습니다 (2026.09.16) — 지도자가 오면 두 장입니다

선수가 화면에서 그대로 읽는 문장이 둘 있습니다 — 칭호(titles)와 추천 카드의 한 줄(card_lines, 2026.09.16 신설). 둘 다 루브릭에 데이터로 두었고 그 이유가 사람이 검수할 수 있게 하려는 것이었는데, 정작 내밀 종이가 없었습니다. 위 임계값 서식과 같은 형태로 만들었습니다.

cd agent && uv run python eval/pending2_wording/build_review_packet.py --write
  임계값 서식 (pending2_bands) 문구 서식 (pending2_wording)
묻는 것 숫자 — 구간을 어디서 가릅니까 말 — 이 문장이 그 동작을 맞게 부릅니까
필요한 표본 실측 분포(축구 18편이 유일하게 분포다운 분포) 🔴 없습니다 — 표만 읽으면 됩니다. 두 장 다 지금 낼 수 있습니다
걸리는 시간 한 시간쯤 훨씬 짧습니다
받은 뒤 🔴 임계값을 바꾸면 등급이 달라져 B-6 비교가 끊깁니다 🔴 점수를 한 비트도 안 건드립니다 — 문장은 판정에 관여하지 않습니다. B-6 재실행 없음

🔴 한 장에 합치지 않았습니다. 섞으면 지도자가 한 번에 둘을 판단하게 되고 돌아온 답이 어느 쪽에 대한 것인지 모르게 됩니다. 같은 자리에서 두 장을 내미는 것은 됩니다.

  • 박민호 님: 섭외가 되면 문구 쪽을 먼저 받아 주세요. 표본이 필요 없고, 받은 답을 반영해도 되돌릴 것이 생기지 않습니다(점수 불변). 임계값 쪽은 34번 판단이 아직 앞에 있습니다

    🔴 임계값 서식도 정리했습니다 (2026.09.16) — 낡은 채로 내밀 뻔했습니다

문구 서식을 만들다 옆 폴더에서 둘을 찾았고, 사용자 확인을 받아 정리했습니다.

무엇이 어떻게
지운 루브릭의 서식 4장(야구 타격·투구, 농구 점프슛·레이업) 지웠습니다. 2026.09.11 축구 단일 종목 전환 때 루브릭은 지웠는데 종이가 안 따라 지워졌습니다 — 그대로 내밀면 안 하는 종목의 종이를 드리게 됩니다. README 의 「지금 낼 수 있는 것」 표도 6개 기준이었고 함께 고쳤습니다
🔴 남은 축구 서식이 6일 낡아 있던 것 다시 뽑았습니다. 서식은 2026.09.09 에 만들었는데 B-6 실측이 2026.09.15 에 갱신됐습니다(43번 ㉳ 3회차 — 마무리 길이를 구간 안에서 세도록 고침). 예: 차는 다리 뻗기 중앙값 143 → 134, 「지금 선이 낸 결과」 4·3·11 → 3·3·12

🔴 이 서식은 루브릭을 한 글자도 안 고쳐도 낡습니다 — 실측 분포를 싣기 때문에 평가를 다시 돌린 것만으로 숫자가 달라집니다. 그대로 내밀었으면 지도자가 지난 측정값을 놓고 선을 그었을 것이고, 돌아온 답이 어느 판에 대한 것인지 나중에 알 수 없었을 것입니다.

tests/test_review_packets.py 가 이제 두 폴더의 서식을 다 봅니다 — 루브릭이든 실측이든 바뀌었는데 다시 안 뽑았으면 빨개지고, 지운 루브릭의 서식이 남아 있어도 빨개집니다. (표류 검사가 실제로 무는지는 낡은 판으로 되돌려 확인했습니다.)

  • 확인: ls agent/eval/pending2_wording/packet/ — 루브릭 2개 서식. 사용법은 같은 폴더 README.md. 서식이 루브릭보다 낡으면 tests/test_coach_review_sheet.py 가 빨개집니다

🔴 검수 없이 갑니다 (2026.09.17, 사용자 판단) — 섭외를 그만둡니다

남은 개발 기간에 지도자 검수를 넣지 않기로 했습니다. 이유는 시간입니다.

🔴 새 결정이 아니라 이미 선 결정의 확정입니다. 같은 구역 34번에서 2026.09.11에 박민호 님이 이미 이 방향으로 정하셨고(제안서 3장 검증 기준 표·8장 KPI·5장 ASM-004「깨짐 — 섭외 불가 확인」에 반영 완료), 남아 있던 것은 미결 항목 쪽 기한 표기였습니다. 그것을 이번에 맞췄습니다.

🔴 이 결정이 뜻하지 않는 것

「지금 임계값이 맞다고 정했다」가 아닙니다. 검수를 안 받는다는 것은 검증되지 않은 채로 간다는 뜻이고, 그 사실이 산출물에 적혀 있는 상태로 갑니다. 34번 결정 3(2026.09.14)이 이미 문헌과 우리 값이 세 자리에서 어긋나는 것을 찾아 두었고 그래도 하나도 안 옮겼습니다 — 시점 정의가 다르기 때문입니다(문헌은 공 접촉, 우리는 extension_peak).

🔴 지우면 안 되는 표시 어디
threshold_validity: unverified (11/11) agent/contracts/rubric_evidence.yaml
event_validity: unverified 같은 곳
⚠️ 지도자 검수 전 임시값 머리말 agent/rubrics/football_*.yaml

이것이 이 결정의 유일한 안전장치입니다. 검수를 안 받기로 한 것과 안 받은 사실을 안 적는 것은 다릅니다 — 표시를 지우면 두 번째가 됩니다.

무엇을 포기하는가
  • draft 루브릭 승격 · 20번 (가)·(나) · 37번 (가)(밴드 0 대칭화) — 전부 임계값을 옮기는 일이라 검수가 근거였습니다. 안 옮깁니다 (덤으로 B-6 재실행도 안 부릅니다)
  • titles·card_lines 문구 검수 — 선수가 화면에서 읽는 문장이 사람 눈을 한 번도 안 거친 채 나갑니다. 지어낸 문장이 아니라 루브릭에 등급마다 적힌 고정 문구이고 검사가 숫자·경기 기록을 막고 있지만, 「검수됨」은 아닙니다
  • QWK·MAE — 34번이 이미 「골든셋 확보 시」 조건부로 내렸습니다
살려 두는 것

🔴 서식 두 장은 지우지 않습니다 — eval/pending2_bands/(임계값) · eval/pending2_wording/packet/(문구). 지도자가 생기는 날 그날 바로 씁니다. 표류 검사(test_review_packets.py·test_coach_review_sheet.py)도 그대로 둡니다 — 안 쓰는 동안 루브릭이 바뀌면 서식이 낡는데, 그걸 모르면 나중에 지난 측정값을 놓고 선을 긋게 됩니다(위 절에서 실제로 겪은 일).

🔴 provisional: true 가 영구가 됩니다 — 이것이 유일하게 남은 신호입니다

review_required: true 인 동안 결과에 provisional: true 가 붙고, 화면이 그 값으로 「검수 전」 배지를 그립니다(SquadSuggest.tsx). 검수를 안 받기로 했으므로 그 배지가 영원히 뜹니다.

🔴 review_required 를 false 로 바꾸지 않습니다. 배지가 거슬린다고 끄면 검수받지 않은 점수가 검수된 것처럼 나갑니다 — 이 결정에서 제일 하면 안 되는 한 가지입니다.

  • 🔴 백성검 님 — 문구만 한 번 봐 주세요. 「검수 전」은 곧 검수된다로 읽힙니다. 이제 영구라 「미검수」 쪽이 사실에 가깝습니다. 화면 문구는 그쪽 구역이라 제가 안 고쳤습니다 — 바꿀지 판단해 주시면 됩니다 (www/src/components/SquadSuggest.tsx · playerGrade.ts 가 짝으로 다룹니다)

  • 박민호 님: 섭외 진행 계획(위 「섭외 진행 계획」 절)은 접습니다 — 더 밀지 않으셔도 됩니다. 제안서 쪽은 09-11 결정으로 이미 맞아 있어서 새로 고치실 것은 없습니다. 다만 발표에서 「검증」이라는 말이 나오면 위 「뜻하지 않는 것」이 답입니다
  • 🔴 박민호 님 — 한 곳만 남아 있습니다. 챕터 7 칸반에 「골든셋 라벨링 지도자 섭외」 카드가 아직 있습니다 (jekyll/chapters/07-개발구현계획.markdown). 그쪽 구역이라 제가 안 고쳤습니다 — 내리거나 상태를 바꿔 주세요. 확인: grep -n '지도자 섭외' jekyll/chapters/07-개발구현계획.markdown
  • 이 결정으로 기한을 고친 항목: 같은 구역 20 · 22 · 36 · 37 · 50번. 함께 고친 코드·문서: agent/rubrics/football_*.yaml 머리말 · agent/README.md 「주의」 · agent/src/supersub_agent/scoring.py 의 provisional — 셋 다 「검수 전까지 대외 노출 금지」를 적고 있었는데 서비스는 이미 돌고 있었습니다. 못 지킬 규칙을 적어 두면 지켜지는 줄 압니다

  • 담당: 박민호(지도자 섭외) ⛔ 안 하기로 했습니다 (2026.09.17) · 정상호(위 「지우면 안 되는 표시」 유지) · 기한: 섭외가 가능해지는 날 (그때 서식이 준비돼 있습니다)

3. 최초 지원 종목과 동작 범위 ✅ 해소 (2026.08.26)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

4. 정식 서빙 환경 GPU 조달

개발 환경은 RTX 3050 (8GB)이고 현재 판정 모델은 EXAONE 4.0 1.2B(bf16) 로, 상주 VRAM이 2449MB(약 2.4GB) 다 (2026-08-25 실측). 8GB 안에 5GB 이상 여유가 있어 모델 규모를 올릴 여지는 있으나, 32B급을 쓰려면 24GB 이상 GPU가 필요하다.

등급 판정을 코드로 옮긴 뒤로는 판단 정확도를 이유로 한 모델 증설 압력이 줄었다 — 경계값 오판은 해소됐고, 남은 것은 근거 문장의 품질 문제다.

  • 자체 구축 / 클라우드 임대 중 선택 미정
  • 1.2B로 서비스 품질이 충족되면 조달 불필요 — 골든셋 측정 결과에 따라 결정
  • 8GB에 들어가는 더 큰 네이티브 모델이 나오면 24GB 조달보다 먼저 검토
  • 담당: 정어진 · 기한: 스프린트 3

5. 임팩트 탐색 범위가 클립 전체다 (2026.08.31 재정의)

이 항목의 제목과 진단이 08.31에 바뀌었다. 이전 제목은 “팔 종목의 임팩트 정의 — 릴리스보다 이르다”였다. 재조사 결과 이르다는 진단이 틀렸고, 팔 종목만의 문제도 아니다. 근거는 아래와 개발 로그 임팩트가 이른 게 아니라 다른 동작을 보고 있었다에 있다.

루브릭이 임팩트로 선언하는 extension_peak은 신전 각속도가 최대인 프레임 이다(features.py의 segment_phases). 탐색 범위가 클립 전체의 전역 최댓값 이고 평활도 위상 게이트도 없다. 그래서 동작과 무관한 구간의 급변이 실제 임팩트를 이길 수 있다.

실제로 이기고 있다.

클립 코드가 고른 프레임 그 프레임의 실제 동작
야구 투구 원본 49 와인드업 — 양손을 모은 상태에서 팔 키포인트가 엉킨 구간. 신뢰도는 0.8~0.9로 게이트를 통과한다
농구 점프슛 원본 68 드리블 주행 중 — 슛이 시작되기도 전이다

이전에 이 항목의 근거로 적혀 있던 팔꿈치각 61도·112.7도는 릴리스 각도가 아니다. 각각 와인드업과 주행 구간에서 잰 값이다.

두 클립 모두 이 질문에 답할 수 없는 입력이었다. 야구 클립은 마지막 프레임에서 팔이 아직 나오는 중이라 릴리스가 영상에 없고, 농구 클립은 점프슛이 아니라 레이업이다(basketball/jump_shot 루브릭으로 채점하고 있었다).

축구도 같은 문제였다

이전에 “축구 인스텝 슈팅은 발이 공에 닿는 순간이 곧 신전 각속도 피크라 문제가 없다”고 적혀 있었다. 재 본 적이 없었을 뿐이다.

임팩트가 실제로 들어 있고 공 궤적으로 객관적으로 잴 수 있는 킥 19건 (goldenset/soccerkicks_video)으로 확인했다. 그중 채점되는 것은 16건이다 (2건은 임팩트가 분석 구간 경계에 걸려 InsufficientQuality로 반려, 1건은 공 미검출).

   
임팩트 − 볼접촉 분포 −11 −9 −6 −6 −2 −1 0 0 0 1 1 4 5 5 8 15
중앙값 0.0 — 한쪽으로 치우쳐 있지 않다
|차이| 중앙값 4.5프레임 (실효 12~15fps에서 약 0.35초)
|차이| ≤ 1프레임 6/16

편향이 아니라 산포다. 이르기도 하고 늦기도 한다.

🗂 조사 상세 — 산포의 원인·오염 제거 시도와 되돌린 비용(A-1). 펼쳐 봅니다

산포의 원인 — 신호가 없어서가 아니다

접촉 추정 자체의 오차를 먼저 걷어냈다. 이 클립들은 전부 세트피스라 공이 정지 상태에서 출발하므로 독립적인 접촉 근거를 둘 만들 수 있다 — 발목 최근접 프레임과 공이 움직이기 시작하기 직전 프레임. 둘이 ±1 안에서 일치한 것은 18건 중 9건뿐 이다. 그 일치 클립만 봐도 임팩트는 여전히 빗나간다(|차이| 중앙 3.0).

그 클립들에서 각속도의 국소 최댓값을 전부 세고 접촉 ±2에 있는 것의 순위를 매겼다.

   
접촉 ±2에 국소 피크가 있는 클립 9/9 전부
그 피크가 전역 1위 5/9
전역 상위 3위 안 9/9 전부
진 경우 승자와의 배율 1.37 · 1.41 · 1.51배

접촉 지점의 신호는 언제나 있고 언제나 상위권인데, 근소한 차이로 진다.

같은 구조가 야구 400클립에서 재현됐다 (2026.09.01, 미결 7번 조사). 전역 argmax의 1위/2위 마진이 p10 1.12배, p25 1.37배이고 마진 1.5배 미만이 29%다. 축구 16건에서 본 “승자와의 배율 1.37·1.41·1.51배”와 같은 구조다. 즉 얇은 마진은 축구·leg 종목만의 사정이 아니라 전역 argmax 정의 자체의 성질이다. 자세한 것은 아래 미결 7번을 볼 것 — 다만 이 항목의 상태와 재개 조건은 그대로다.

범인은 탐색 범위다. 다른 후보는 전부 떨어졌다 — 평활을 넣어도 산포가 그대로고 (|차이| 중앙 3.0 → 3.0), 반대쪽 다리로 계산하면 더 나빠지며, 경계 아티팩트는 segment_phases의 경계 검사가 이미 반려로 막고 있다.

오염만 떼어 고쳐 봤고, 되돌렸다 (A-1, 2026.08.31)

탐색 범위 문제와 별개로 오염 하나만 고치는 변경을 만들어 검증했다. np.gradient가 중심차분이라 측정 불가 프레임의 각도가 양옆 정상 프레임의 각속도를 부풀리는 문제다. 속도를 usable 프레임의 각도만으로 계산하도록 바꿨다가 되돌렸다.

오염은 실재한다 — 제거 대상 프레임의 각도값 잔차가 자연 변동의 약 85배 (중앙 33.0 vs 대조 0.39)이고, 멀쩡한데 지워지는 과잉 제거는 1.7%뿐이다. 그 프레임들의 손목 신뢰도는 중앙 0.000, 70.3%가 정확히 0이다. 제거 자체는 정당하다.

그런데 결측이 정답 창에 몰린다 — 축구 참조 기준 결측 10/10이 접촉 ±10프레임 안(p=0.0017), 야구도 위치 분포가 비균일(KS p≈0)하고 갭은 평소의 1.91배로 움직이는 프레임에서 시작한다. 즉 정답이 있을 확률이 가장 높은 구간에서 후보를 제거한다.

되돌린 이유는 넷이다.

  • 야구 impact 84.3% 이동(중앙 33프레임)의 정당성을 검증할 독립 정답이 없고 확보 경로도 전부 막혔다(아래 “닫은 경로”).
  • GATE_JOINTS의 손목 면제 결정과 충돌한다 — 제거의 92.5%가 손목 관여, 72.5%는 손목만. 게이트에서 면제한 엄격성을 신호 계산에서 되살리는 셈이다.
  • B-2·B-3·B-4·B-6 평가가 전부 되돌린 상태를 기준선으로 수행됐다.
  • 얻는 것이 작다 — 야구 45.6%→46.3%(+23건), 농구 −1건.

결측률이 높은 클립에만 적용하는 방안도 근거가 없었다. 영향이 전 구간에 균일하다(야구 이동 83.6~85.9%).

되돌려서 남는 문제: 오염된 속도가 argmax를 이기는 사례는 그대로 남는다 (합성 픽스처, 농구 실클립 f68→f69). 알고 남기는 것이며 기존 기준선이다.

🔴 되돌린 비용이 실측으로 나왔다 (2026.09.02, target 30 재실행)

되돌리면서 “오염된 속도가 argmax를 이기는 사례는 그대로 남는다. 알고 남기는 것“이라고 적었다. 그 비용이 이제 숫자다.

target_fps를 15에서 30으로 올려 전체를 재실행했더니 B-6 features_ok가 111 → 106으로 줄었다. 분해하면 성공→실패 9건, 실패→성공 4건이다.

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

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

그리고 그 6건의 원인이 정확히 A-1이 지적했던 오염이다. gg5xRWjw3f8 (5개 selector 전부 실패, production 캐시로 재현):

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

f299의 각속도가 |83.1 − 1.2| = 81.9로 클립 전체 최대가 되어 argmax를 이긴다. 측정 불가 프레임 f298의 쓰레기 각도가 정상 프레임 f299의 속도를 부풀린 것 — np.gradient가 이웃 값을 쓴다는 바로 그 문제다. 임팩트가 마지막 프레임에 놓이니 segment_phases의 경계 검사가 반려한다.

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

A-1을 적용하면 argmax가 f299 → f84로 옮겨가 통과한다. 확인했다.

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

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

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

정정할 것도 없다. 되돌릴 때 “남는 문제”로 적어 둔 것이 실제로 그대로 나타났을 뿐이다.

재개 조건이 충족되면 이 6건이 근거가 된다

팔 종목 릴리스 정답 25~60클립이 확보되면, 이 6건이 A-1 계열 처방을 판정할 첫 번째 구체적 시험대가 된다. 물어야 할 것은 하나다 — A-1이 옮긴 f84가 실제 릴리스에 가까운가, 아니면 f299 반려가 옳았는가. 지금은 둘 다 말할 수 없다.

관련 구조적 결함은 미결 13번(np.gradient 끝단 단측차분)에 따로 적었다. 6건이 전부 클립 끝단에서 났다는 것은 우연이 아니다.

남은 근본 문제 — 마스크로는 못 푼다

손목을 품질 게이트에서 면제하면서(GATE_JOINTS), 손목으로 계산한 팔꿈치 각도를 신호로 쓴다(chain_series).

손목을 마스크에서 빼면 망가진 좌표로 각도를 계산하게 되어 오염이 그대로 돌아온다. A-1은 이 모순을 드러냈을 뿐 해결하지 못한다. 신호 정의 자체를 다시 봐야 한다.

닫은 경로 — 다시 조사하지 않는다

경로 결과
PitcherMotion 81컬럼 이벤트 주석 없음
normalised_frame == 0 릴리스 아님 (표본 260 검증)
Statcast companion 프레임 인덱스·타임스탬프 없음, 매핑 불가
UCF101 클립 단위 라벨만, 공 검출 0건
외부 공개 데이터셋 Grade A 없음 — UPLIFT(영상 없음), MultiSports(행동 구간이지 릴리스 아님 + 게이트 + CC BY-NC), Penn Action(이벤트 없음), BBDB(구간 라벨)
축구 표본 확대 필요 97~714클립 대비 확보 상한 38. leg 종목이라 팔 종목 효과 크기를 예측 못 함
인위적 결합 가설 proxy가 conf ≥ 0.6 필터로 인공물 프레임을 구조적으로 배제 → 검정 불가
offline 재분석 정답 없이 답할 수 있는 질문 소진

재개 조건

팔 종목 릴리스 프레임 정답 25~60클립. 필요량을 충족할 수 있는 유일한 경로는 자체 촬영이며 30fps 이상 · 릴리스가 화면에 남는 조건이 필요하다. 그때까지 이 항목은 진행하지 않는다.

후속 검토 대상 (지금은 하지 않는다)

갭 경계 단측 차분 — 제거된 후보의 84.4%가 정의 가능하다. 다만 단측차분이 중심차분보다 1.184배 크다는 스케일 편향이 선결 과제다. 축구 참조에서 3/16 변화, 13_freekick이 +5→0으로 적중했으나 n이 부족해 근거가 아니다.

🔴 정정 — 축구 contact_frame은 사람 라벨이 아니다

공-발목 최근접에서 자동 도출한 관측 기반 참조이고 저장된 라벨 파일이 없다. 독립 근거 두 개(발목 최근접 / 정지한 공의 이동 시작)의 ±1 일치가 18건 중 9건이므로 ±2프레임 불확실성을 가진다. 이 페이지와 개발 로그에서 한때 “정답”으로 적은 것은 과했다. 정식 라벨로 승격하려면 정의 확정과 육안 검증이 필요하며, 이는 미결 2번과 같은 병목이다.

다시 읽어야 하는 것

  • 2026.08.26 보강(외부 포즈 3,444클립) — “60fps에서 임팩트 시 팔꿈치각 중앙 153.7도로 밴드 안에 들어온다”는 관측은 그대로지만, 그 임팩트 프레임이 실제 릴리스라는 보장이 없다는 것이 이번에 드러났다. 프레임레이트 상향 (옛 선택지 3)은 원인 처방이 아니다 — 우리 클립의 A/B 비교에서 프레임을 두 배로 늘려도 임팩트 위치는 원본 1프레임 안쪽에서 그대로였다.
  • 옛 선택지 1·2(임팩트 사건 추가 / 밴드 재설정)는 “릴리스를 제대로 잡았는데 각도가 안 맞는다”를 전제했으므로 지금은 성립하지 않는다. 탐색 범위를 정한 뒤에 다시 판단한다.
  • 미결 7번과 이어진다 — 같은 임팩트 프레임에서 읽는 각도가 샘플링에 따라 61.4도와 102.4도로 갈렸다. 임팩트 프레임이 고정되면 그쪽 산포도 줄어든다.
  • 역방향으로도 이어진다 (2026.09.01 확인) — 미결 7번이 시간축 처리(E-1·E-2) 만으로 등급 일관성에 도달할 수 없음을 확인했고, 남은 처방이 탐색 범위 제한 (E-6) 이다. 그것은 이 항목의 재개 조건과 같은 정답을 요구한다. 7번의 근본 해결이 5번 재개에 묶여 있다 — 이 항목이 열리기 전에는 7번도 인접 결함 수준의 수리에서 멈춘다.

기록: 개발 로그 2026.08.31 두 편(진단 / A-1 되돌림). 축구 클립의 포즈·공궤적은 덤프해 두어 이후 비교는 GPU 없이 재현된다.

  • 담당: 정상호 · 기한: 보류 — 위 재개 조건(팔 종목 릴리스 클립 25~60건)이 충족될 때까지 진행하지 않는다. 현재 코드는 A-1 이전 상태다.

6. 스윙 측 자동 판별의 한계

던지는 팔·차는 발을 이동량으로 판별하는데, 팔 종목에서 약하다. 야구 투구 실클립에서 던지는 왼팔 18.2 대 글러브 오른팔 27.6으로 뒤집혔고, 농구 레이업은 16.30 대 16.09로 1% 차이였다. 다른 통계(관측 비율 할인, 손 최고점)로 바꿔 봐도 한 클립을 맞히면 다른 클립이 뒤집힌다.

지금은 업로드할 때 사람이 지정할 수 있게 열어 두고(side=left|right), 지정이 없으면 기존 자동 판별을 쓴다. 자동 판별만으로 가르는 방법은 미해결이다.

  • 종목별로 다른 판별 규칙을 둘지, 공 위치(도구 검출)를 근거로 쓸지 미정
  • 앱에서는 프로필의 주손/주발을 기본값으로 쓰는 방안 검토
  • 담당: 정상호 · 기한: 스프린트 2

위 수치는 옛 동작점 값이다 — 재계산했다 (2026.09.02)

정정: 위에 적힌 18.2 대 27.6, 16.30 대 16.09는 실효 12.5fps / 11.99fps에서 나온 값이다. DEFAULT_TARGET_FPS가 30으로 올라가 지금 동작점은 25fps / 23.98fps다. 두 클립의 포즈 덤프가 없어 확인하려면 GPU가 필요했는데, agent/eval/pending6_side/에 32KB짜리 덤프를 넣어 GPU 없이 재계산되게 만들었다. 재계산한 옛 수치는 기록과 소수점까지 일치한다(18.20 / 27.58).

클립 옛 12.5/12.0fps 현재 25/23.98fps 판별
야구 투구 팔 던지는 왼팔 18.20 대 글러브 오른팔 27.58 (마진 34.0%) 23.53 대 33.13 (마진 29.0%) 뒤집힌 채 그대로
농구 레이업 팔 16.09 대 16.30 (마진 1.3%) 17.05 대 18.67 (마진 8.7%) 그대로 (맞는 쪽)

target 30으로 올린 것이 이 문제를 고치지 못했다. 야구는 뒤집힌 채이고 마진이 34% → 29%로 줄었을 뿐이다. 레이업 마진은 1.3% → 8.7%로 벌어졌다.

identify_limb 주석이 적어 둔 원인(“12.5fps에서 릴리스가 두 프레임 안에 끝나 던지는 팔의 경로가 짧다”)은 실측과 반대였다 — 경로가 두 프레임에 몰린 쪽은 글러브 팔이다(32% 대 21%). 주석을 실측으로 고쳤고, 뒤집힘의 원인은 여전히 미상이다. 근거와 절차: agent/eval/pending6_side/README.md

어느 쪽이 옳은지는 라벨이 있어야 말할 수 있다. 다음 단계는 판별 통계를 바꾸는 것이 아니라 두 클립의 스윙 측을 사람이 라벨하는 것이다.

🗂 라벨 조사 기록 — 39클립의 성격·좌표계 오류·판독 12건. 펼쳐 봅니다

🔴 평가셋 39클립은 두 손 타격이다 — 근거 클립과 동작군이 다르다 (2026.09.03)

이 항목의 “팔 종목에서 약하다”는 서술은 한 손 동작 두 건에서 나왔다 — 야구 투구(던지는 팔 하나)와 농구 레이업(슛하는 팔 하나). 그런데 검증에 쓰는 평가셋 39클립은 Kinetics hitting baseball 전수로 전부 야구 타격, 즉 두 손으로 배트를 잡는 동작이다. 두 손이 같은 도구를 잡으면 “스윙 팔 대 지지 팔”이라는 구분 자체가 없다.

이 구분이 지금까지 어느 문서에도 없었다. 39클립에서 나온 수치(팔·다리 일치율, 마진 분포, fps 갈림)를 “팔 판별이 약하다”의 근거처럼 읽어 왔는데, 다른 동작군이라 그 질문에 답하지 않는다. 라벨을 39건 다 받아도 마찬가지다.

대조표가 잘못된 좌표계로 만들어져 있었다 — 고쳤다 (2026.09.03)

fe8c70f가 만든 side_stats.py가 원 픽셀 좌표로 travel을 쟀다. production은 identify_limb(normalize(kps), ...)로 부른다(features.py 273·480·581행). 정규화는 프레임마다 골반 중심·어깨 너비로 다시 재므로 프레임 간 변위가 균일하게 바뀌지 않고, 고르는 쪽 자체가 달라진다 — 39클립에서 arm 6건·leg 13건이 반대였다. 라벨을 받기 전에 고쳐 대조표를 다시 만들었다.

수치가 이렇게 바뀐다. 팔이 더 얇다는 그림이 뒤집힌다.

  원좌표 (틀림) 정규화 (production과 같음)
arm 마진 5%·10% 미만 12/39 · 19/39 8/39 · 11/39
leg 마진 5%·10% 미만 15/39 · 24/39 13/39 · 21/39
마진 중앙값 arm · leg 0.122 · 0.086 0.147 · 0.090
15↔30 갈림 arm · leg 2 · 6 1/38 · 6/38
팔 측과 다리 측 일치 15/39 (38%) 18/39 (46%)

이 평가셋에서 얇고 잘 갈리는 쪽은 팔이 아니라 다리다 (마진 중앙 0.090 대 0.147, 갈림 6 대 1). 두 손 스윙이면 마진이 0으로 뭉갤 것 같지만 그렇지 않았다 — 두 손목이 붙어 있어도 배트 위쪽 손이 더 먼 호를 그려 체계적인 차이가 남는 것으로 보인다. 그 차이가 가리키는 것은 top_hand이지 스윙 팔이 아니다.

갈림 분모가 38인 이유: 8gmHKqDxXdg는 원본이 10fps라 target 15·30 모두 step=1이고 두 캐시가 원소까지 같다. 그 클립에서 “15fps에서 갈렸다”는 정의가 안 된다. (나머지 38클립은 cache_target15가 cache_target30[::2]와 정확히 일치함을 전수 확인했다 — recompute.py가 두 클립에서 쓰던 성질이 평가셋 전체에서도 성립한다.)

부수로 철회한 것: eval/pending6_side/README.md에 적혀 있던 “프레임 수 정규화로 갈림이 arm 1→2, leg 6→7로 늘었다”는 산술적으로 불가능하다 — 양쪽을 같은 수로 나누면 부등식이 그대로다. 재현해도 39클립 전부 불변이었다. “처방이 아니다”라는 결론은 유지하되 근거는 미결 7번 본문으로 돌렸다.

그래서 39건 라벨로 무엇을 잴 수 있나

물음 39건으로
팔 종목에서 자동 판별이 약한가 못 잰다 — 동작군이 다르다 (한 손 대 두 손)
야구 타격에 “스윙 팔”이 정의되는가 잰다 — swing_arm = both 비율이 곧 답이다
top_hand와 자동 판별이 얼마나 겹치는가 잰다
다리 판별(스트라이드 발)이 맞는가 잰다 — 다리는 두 손 스윙에서도 정의된다. 마진도 여기가 더 얇다
스켈레톤이 타자에게 붙었는가 잰다 — subject_ok가 미결 8번의 정답이 된다

다만 유효 분모가 39보다 훨씬 작다. phaseA_metadata.csv 기준 usable_for_phase_B는 yes 7 · maybe 6 · no 26이고, 19클립은 camera_angle부터 미검증이다. 라벨을 받은 뒤 subject_ok = n과 na를 빼면 한 자릿수까지 내려갈 수 있다. 사전 등록 (agent/eval/pending6_side/labeling/AFTER_LABELS.md)에 그때는 정확도를 내지 말고 분모만 보고한다고 적어 두었다.

서식(side_form.csv)은 고칠 것이 없다 — top_hand·swing_arm(both 허용)·swing_leg·subject_ok 네 칸이 위 표를 그대로 덮는다. 라벨링은 지금 그대로 진행하면 된다.

🔴 판독 12건으로 사전 등록 명세를 돌렸다 (2026.09.04)

AFTER_LABELS.md를 labeled_stats.py로 그대로 실행했다. 라벨을 보고 스크립트를 고치지 않았다 — 명세와 구현이 라벨보다 먼저 커밋돼 있다(159355f). 전문과 해석: agent/eval/pending6_side/labeling/RESULTS.md

   
유효 분모 39건 → 판독 제외 27 → 12건. subject_ok = n은 0건
다리 판별 4/11 = 36.4%, 95% CI [15.2%, 64.6%]
팔 판별 🔴 정확도를 내지 않는다 — 분모 8건이다. 맞은 수만 6/8
swing_arm = both 4/12 (33%)
top_hand와 auto_arm_30 일치 5/12 (42%)
15↔30 갈린 것 중 라벨 있는 것 arm 1건 · leg 1건 — 결론을 달지 않는다

세 가지를 함께 적어야 한다.

  • 36.4%는 “동전보다 나쁘다”가 아니다. 95% 구간이 50%를 포함한다. 11건으로는 위인지 아래인지 못 가른다 — 말할 수 있는 것은 “높다고 말할 근거가 없다”까지다
  • 팔은 분모가 한 자릿수라 정확도를 안 냈다. 명세 6절이 라벨 보기 전에 그렇게 정해 두었고, 그 조건이 실제로 걸렸다. both 4건을 분자·분모 양쪽에서 뺀 결과다
  • top_hand도 대안이 못 된다. 명세 5절이 “대신 쓸 수 있는 것이 top_hand” 라고 적었는데 자동 판별과 42%만 겹친다. identify_limb이 재는 것은 스윙 팔도 top_hand도 아니라는 관찰이다 (겹침이지 정확도가 아니다)

🔴 이 12건으로 identify_limb을 고치지 않는다. 명세 7절이 금지한다 — 같은 12건으로 고르면 그 12건에 맞춘 것이 된다.

사전 등록의 결함 하나가 드러났다. 명세 6절 표의 마지막 줄 「subject_ok가 39건 전부에 답한다」가 성립하지 않는다 — 실제 판독은 12건 에만 매겨졌다. 등록 문안이라 표는 고치지 않았고, 스크립트가 실행할 때마다 이 어긋남을 함께 찍게 두었다.

그리고 기준선이 동전이 아니다. 다리 라벨이 L 9 · R 2로 치우쳐 있어 “항상 L“만으로 9/11이다. 🔴 이번 판정에 쓰지 않는다(결과를 보고 기준을 바꾸면 사후 선택이다). 다음 사전 등록에 다수 클래스 기준선을 함께 등록할 것.

🔴 위 「서식은 고칠 것이 없다」를 정정한다 — 2차 서식을 만들었다 (2026.09.04)

칸의 구성은 맞았지만 묻는 방식이 틀렸다. 네 칸이 한 줄에 나란히 있었고 안내문이 「top_hand부터 채우면 빠릅니다」라고 순서까지 권했다. 받은 12건에서 swing_arm이 both가 아닌 8건 전부가 top_hand의 반대쪽이고, 같은 8건에서 swing_leg도 swing_arm과 전부 같다.

12건으로는 두 해석을 못 가른다 — 판독자가 규칙으로 도출한 것인지, 우타자의 위쪽 손이 오른손이고 디딤발이 왼발인 실제 신체 상관인지. 상관이 진짜여도 결함은 남는다: 진짜인지 아닌지를 그 서식으로는 물을 수 없다.

2차 서식 agent/eval/pending6_side/labeling/review_packet2/:

  • 한 회차에 한 칸만 묻는다. 순서는 subject_ok → swing_leg → swing_arm → top_hand로, 도출의 기준점이 되던 top_hand를 맨 뒤에 둔다
  • 회차마다 줄 순서를 섞는다(고정 시드 20260904, 재현 가능)
  • 줄을 지우지 않는다. 1차에서 27줄을 지워 subject_ok = n과 na가 합쳐진 것(EXCLUDED.md)이 되풀이되지 않게 한다
  • seen_before 칸을 받는다 — labels.json의 편향 고지 선례를 따른다
  • 어휘와 산출 모양은 1차와 동일하다. merge_rounds.py가 회차를 1차와 같은 서식 한 장으로 되붙이므로 사전 등록(AFTER_LABELS.md)과 labeled_stats.py는 고치지 않았다 — 라벨 보기 전에 굳힌 것이라 지금 고치면 그 성질을 잃는다
  • 되붙일 때 찍을 진단 두 개를 라벨 보기 전에 등록했다(위 8/8이 2차에서도 유지되는가). 유지되면 실제 상관이라는 근거이고, 흩어지면 1차는 도출이었다는 뜻이다

⚠️ 이 서식도 기억까지 막지는 못한다. 같은 39장을 네 번 보는 사람은 그림을 알아본다. 회차 분리가 막는 것은 옆 칸을 보고 채우는 것이고, 거기까지 막으려면 회차마다 판독자가 달라야 한다 — 서식이 아니라 인력 문제라 열어 둔다.

  • 🔴 1차 12건은 지우지 않는다. 2차와 나란히 두면 두 서식의 일치율 자체가 도출 여부의 근거가 된다. 어느 쪽을 분석에 쓸지는 결과를 보기 전에 정할 일이 아니라 지금 정하지 않았다
  • 이 항목의 상태·결론·기한은 그대로다. 바뀐 것은 다음 판독을 어떤 서식으로 받을 것인가 하나다

수동 지정이 반대쪽 사지에 전달되지 않는 것은 의도다 (2026.09.02)

팔 루브릭에 side=left를 줘도 다리 지표는 auto로 계산되고, 그 지표도 결과 JSON에 실린다. 버그로 보이지만 아니다 — “왼쪽”이 팔과 다리에서 같은 것을 가리키지 않기 때문이다. 오른손 투수의 디딤발은 왼발이고, 오른발 인스텝 슈팅에서 크게 도는 팔은 왼팔이다. 평가셋 39클립에서 자동 판별된 팔 측과 다리 측은 46%(18/39)에서만 일치했다 — 한 값이 둘을 대신할 수 없다. (앞서 적은 44%를 정정한다 — 그 값은 옛 동작점 target 15의 것이었다. 결론은 그대로다.)

고치지 않고 extract_features docstring과 테스트 (test_manual_side_applies_to_the_impact_limb_only)로 못 박았다. 반대쪽까지 지정하려면 인자를 하나 더 두어야 하고, 그럴 만한 요구는 아직 없다.

  • 다만 결과 JSON은 두 지표를 구분하지 않는다 — 사람이 지정한 사지의 지표와 auto로 판별한 사지의 지표가 같은 평면에 실린다. 근거로 쓰는 쪽에서 이 차이를 알 방법이 없다. 표기 규격은 jin 구역 1번과 함께 볼 것
  • 담당: 정상호 · 제기: 정상호 · 기한: 스프린트 2

7. 프레임레이트에 따라 점수가 달라진다 (2026.09.01 원인 규명 완료)

같은 클립을 60fps와 30fps로 넣으면 임팩트 시 팔꿈치각이 10도 넘게 달라지는 경우가 50%다 (외부 포즈 220클립, 2026.08.26). 파이프라인은 “같은 입력 → 같은 점수”라는 의미의 결정론은 지키지만, 같은 영상을 다른 프레임레이트로 넣으면 다른 등급이 나온다. 포즈 신뢰도와는 무관했다(상관 −0.06).

사용자 영상의 프레임레이트를 통제할 수 없으므로 서비스에서 그대로 드러난다.

이 항목은 정답이 필요 없다. 같은 영상이 같은 점수를 받아야 한다는 것은 측정 없이 성립하는 요구이므로, “어느 fps가 옳은가”가 아니라 “왜 달라지는가”만 물었다. 2026.09.01에 PitcherMotion 400클립(60fps 외부 포즈를 정수배로 솎아 30·20·15·12fps를 만든다)으로 원인을 확정했다. 재현부터 확인했다 — 60 vs 30 공통 264건에서 |Δ팔꿈치각| > 10도가 45%로 08-26 관측과 같다.

확정된 원인 — 하나가 아니라 구조 하나 + 섭동 둘

구조: 전역 argmax의 승자 마진이 얇다. segment_phases가 임팩트를 클립 전체의 신전 각속도 최댓값으로 정의하는데, 1위와 (5프레임 이상 떨어진) 2위의 비율이 p10 1.12배, p25 1.37배, 중앙 2.28배다. 마진 1.5배 미만이 29%. 마진이 얇은 클립이 실제로 더 많이 옮겨 간다 — 마진 1.0~1.1 구간은 60→30에서 2프레임 초과 이동이 61%인데 3.0배 이상 구간은 28%다(상관 −0.24). 이 얇은 마진 위에서는 어떤 작은 섭동도 승자를 갈아치운다.

섭동 1 — 후보 격자 축소. 데시메이션하면 argmax 후보가 짝수 프레임만 남는다.

섭동 2 — 중심차분 스텐실 확대. np.gradient는 (s[t+1]-s[t-1])/2인데 데시메이션하면 (s[t+2]-s[t-2])/2가 된다. 물리 시간으로 2배 넓은 창이다.

둘을 2×2로 분해했다. 미분값은 60fps 그대로 두고 후보만 짝수로 제한한 것이 B다.

경로 같은 프레임 |이동| 중앙 평균
C→A 전체 (60fps → 30fps 실제) 33% 1 16.1
C→B 후보 격자만 49% 1 22.9
B→A 스텐실만 48% 2 26.6

두 섭동의 기여가 거의 같다. C→B 이동의 63%는 1프레임 이내(격자 반올림)지만 평균이 22.9프레임이다 — 이기던 프레임이 격자에서 빠지면 승리가 멀리 있는 경쟁자에게 넘어간다. 실제로 30fps 승자 자리의 91%는 60fps에서도 이미 국소 피크였다. 새 인공물이 아니라 원래 경쟁자다.

🗂 원인 분석 상세 — 차등 배율·하류 전파·배제한 것·부수 원인·인접 결함. 펼쳐 봅니다

스텐실 확대는 균일하지 않다 — 차등 배율 2.33배

넓어진 스텐실이 회수하는 각속도를 선형 기대치(1.00) 대비로 재면:

   
60fps 임팩트 프레임에서 0.46
완만한 구간(하위 50%)에서 1.06
차등 배율 2.33배

날카로운 사건이 완만한 사건보다 2.33배 더 깎인다. 릴리스처럼 두세 프레임 안에 끝나는 사건은 절반 이하로 줄고, 와인드업처럼 넓게 퍼진 움직임은 그대로 남는다. 마진이 1.12~1.37배뿐인 경쟁에서 이 차등이면 승자가 갈린다. 20fps 이하에서 팔꿈치각 중앙이 155도 → 121도로 무너지는 절벽이 이것이다.

하류 전파 — 임팩트 프레임 하나가 거의 모든 지표를 흔든다

지표 60·30 동일 |Δ| 중앙
swing_elbow_angle_at_impact 35% 6.5도
plant_knee_angle_at_impact 34% 1.9도
trunk_forward_lean_deg_at_impact 36% 1.4도
hip_shoulder_separation_deg 42% 0.5도
hip_rotation_range_deg 29% 0.8도

지표 동일률이 29~42%다. 임팩트 프레임이 모든 지표를 읽는 자리이기 때문이다. 야구 투구 루브릭으로 등급까지 태우면(264건) 릴리스 팔 신전 항목이 36% 바뀌고, 최종 등급(A~D)이 37% 바뀐다. 총점은 46%만 같고 |Δ총점| 중앙 4점, 최대 55점이다. 경계 밀집(골반-어깨 분리 21%가 구간폭 10% 안)은 부차적 증폭기이고, 크기의 주된 몫은 임팩트가 멀리 튀면서 지표 자체가 달라지는 데서 온다.

프레임 단위 경계 규칙도 fps에 딸려 간다. segment_phases의 impact - first < 2는 60fps에서 0.033초, 12fps에서 0.167초를 요구하는 셈이라 저 fps에서 반려가 5배로 늘어난다(60fps 2건 → 15·12fps 각 10건, 400클립 기준).

배제된 것

  • 샘플링은 원인이 아니다. read_frames는 idx % step == 0 균등 추출이고, 프레임 번호를 밝기로 새긴 합성 영상으로 역추적하니 60fps 원본과 30fps 판이 같은 물리 인덱스(0 4 8 12 …)를 골랐다. 60↔30은 2배 관계라 격자가 포개진다.
  • 각도 계열도 원인이 아니다. 같은 물리 프레임의 팔꿈치각 최대 오차가 1.27e-10도다(379클립 전수). joint_angle은 평행이동·스케일에 불변이고 시계열에 시간 필터가 없다. 따라서 각도 차이 100%가 “어느 프레임을 골랐는가” 에서 온다.
  • 미분의 단위 자체도 임팩트를 옮기지 못한다. argmax는 양의 상수배에 불변이라 도/프레임을 도/초로 고쳐도 고르는 프레임은 한 프레임도 안 바뀐다. 단위가 실제로 새는 곳은 follow_through_duration_frames(60·30 동일 6%)와 impact_frame(중앙비 정확히 0.50)이며, 둘 다 fps에 따라 변한 값이 판정 근거로 넘어간다.

부수 원인 — identify_limb도 fps에 의존한다

travel이 경로길이 합이라 프레임 수만큼 누적된다. 흔들리는 관절은 노이즈가 쌓이고 실제로 빠르게 움직이는 관절은 그렇지 않아, 데시메이션이 둘을 다르게 줄인다(30fps/60fps 경로길이 중앙비 0.86).

60fps와 스윙 팔이 달라지는 클립  
30fps 21/400 (5%)
20fps 31/400 (8%)
15fps 35/400 (9%)
12fps 40/400 (10%)

스윙 팔이 뒤집히면 아예 다른 관절 체인을 채점한다. 임팩트 경로와 독립이며 미결 6번과 같은 코드다.

함께 드러난 인접 결함 두 건 (이번 증상의 원인은 아니다) — ✅ 수리 (2026.09.03)

정수배가 아닌 소스에서는 격자가 어긋난다. step이 정수라 24fps → 실효 12.0, 25fps → 12.5, 50fps → 16.67fps가 된다. 같은 영상을 25fps와 50fps로 인코딩하면 선택 시각이 0.08초 격자와 0.06초 격자로 갈린다. 60↔30처럼 2배 관계일 때만 격자가 포개지므로, 위 “샘플링 배제”는 2배 관계에 한정된 결론이다.

max_frames=300이 덮는 실시간 길이가 fps에 따라 다르다. 25fps 소스는 실효 12.5fps라 24.0초를 덮고, 50fps 소스는 실효 16.67fps라 18.0초만 덮는다. 긴 영상에서 뒷부분이 fps에 따라 다르게 잘린다.

위 수치는 target 15 기준이라 낡았다. target 30에서 다시 재면 평가셋 39클립이 전부 step=1이고(59.94fps 한 건만 step 2), 300장이 덮는 길이는 10.0초다. 클립 자체가 10초 이하라 평가셋에서는 절단이 없다. 결함은 임의 길이의 실입력에서 그대로 남는다 — 30fps는 10.0초, 24fps는 12.5초를 본다.

무엇을 고쳤나.

  1. 분석 창을 초로 정한다. read_frames에 max_seconds(기본 10.0)를 두고, max_frames(300)는 메모리 가드로만 남겼다 — 미결 9번이 정한 값이지 분석 의도가 아니라는 것을 이름과 주석으로 갈랐다. 둘 중 먼저 걸리는 쪽이 이긴다. 실효 fps가 목표를 넘는 소스(40fps→실효 40)에서는 초 예산이 프레임 예산을 넘으므로 가드가 여전히 필요하다
  2. 낮은 실효 fps를 경고한다. 수정 방향의 「최소 입력 fps 경고」다. 한계는 목표의 0.75 — 0.8(=24.0)로 두면 NTSC 24(23.976)가 걸린다. 평가셋에 실제로 있는 흔한 소스이고, 흔한 입력이 매번 경고를 내면 그 경고는 읽히지 않는다. 이 경고는 절벽만 막으며 fps 불변성을 뜻하지 않는다
  3. 재디코딩 desync를 막았다. PoseResult가 상한을 함께 들고 다니고 load_frames()가 그것을 쓴다. 전에는 좁은 창으로 추출하고 미리보기만 기본값으로 다시 읽으면 프레임이 키포인트보다 길어졌다(잠재 결함이었다)

무변화 확인. 실클립 39개에 옛 규칙(장수만)과 새 기본값을 각각 돌려 장수도 화소도 전부 동일했다. 경계가 실제로 걸리는 자리가 있어서 확인이 필요했다 — 예산을 내림으로 잡으면 29.97fps·10초가 299장이 되어 기존 300장에서 조용히 한 장 줄어든다. 올림으로 잡아 막았고 테스트로 고정했다. 테스트 154개 통과(+12).

격자 어긋남(첫 번째 결함) 자체는 고치지 않았다. 소분수 샘플링으로 바꾸면 어떤 프레임을 고르는지가 달라져 모든 지표가 움직인다 — E-1·E-2가 사전 산정에서 걸러진 것과 같은 종류의 변경이라, 측정 없이 손대지 않는다. 현재 동작점에서는 평가셋에 실제로 걸리는 클립이 없다(50fps 소스가 들어오면 그때 다시 본다).

미결 5번과의 관계

같은 뿌리다 — 얇은 argmax 마진. 미결 5번이 축구 16건에서 본 “신호는 언제나 상위권인데 1.37·1.41·1.51배 차이로 진다”가 야구 400클립에서 마진 p10 1.12배로 재현됐다. 5번은 그 얇은 마진에 탐색 범위라는 섭동이 얹힌 경우이고, 7번은 같은 마진에 격자·스텐실이 얹힌 경우다. 5번의 “편향이 아니라 산포다”도 그대로 재현됐다(클립 길이의 5% 넘게 점프한 20%가 앞뒤 50:50).

그래도 7번을 고쳐도 5번은 열리지 않는다. 격자와 스텐실을 fps 불변으로 만들면 “같은 영상이 같은 점수를 받는다”는 지켜지지만, 그렇게 고정된 프레임이 실제 릴리스인지는 여전히 모른다. 5번의 재개 조건(팔 종목 릴리스 정답 25~60클립, 자체 촬영, 30fps 이상)은 그대로다.

수정 방향 (아직 구현하지 않았다)

정답 없이 검증되는 것: 스텐실을 물리 시간으로 고정(섭동 2 제거), 각도 계열을 공통 시간 격자로 리샘플(섭동 1 제거 — 단 usable 마스크 밖으로 보간하면 5번이 확인한 “결측이 정답 창에 몰린다”와 충돌한다), 프레임 단위 값·임계값의 물리 시간 표기(반려 집합이 바뀌므로 기존 평가 기준선과의 비교 가능성 확인 선행), identify_limb의 샘플링 의존 제거(미결 6번 코드라 판별 정확도와는 분리해야 한다), 최소 입력 fps 경고(절벽만 막고 30↔60의 37%는 남는다).

정답이 필요한 것: 탐색 범위 제한과 임팩트 사건 재정의 — 미결 5번의 재개 조건과 같으며 7번 단독으로는 착수할 수 없다.

권고 순서는 스텐실 고정 → 리샘플이다. 이 둘이면 fps 불변성은 정답 없이 확보되고, 탐색 범위는 5번이 재개될 때 그 위에 얹는다.

🔴 정정 — 위 “이 둘이면 fps 불변성은 정답 없이 확보된다”는 틀렸다 (2026.09.01)

바로 위 문단은 반증됐다. 지우지 않고 남긴다 — 무엇을 잘못 예상했는지가 다음 설계의 출발점이기 때문이다. E-1·E-2를 구현하기 전에 offline으로 산정했고, 두 방식 모두 원리적으로 목표를 달성할 수 없음이 드러났다.

E-2(공통 시간 격자 리샘플)는 작동하지 않는다 — 파라미터 문제가 아니다. 선형 보간으로 만든 조각선형 함수를 표본 간격의 배수 반폭으로 미분하면 값이 구간 마다 선형이라 최댓값이 항상 표본점에서 나온다. 즉 argmax를 표본점 밖으로 옮길 수 없다. 400클립에서 모든 fps의 임팩트가 무수정과 100% 일치했고 스냅 손실도 0이었다. 분해능을 얻으려면 비선형 보간(스플라인)이 필요하며 그것은 다른 제안이다.

E-1(스텐실 물리 시간 고정)도 원리적으로 불가능하다. 반폭이 max(1, round(τ·fps))인 정수라, τ가 프레임 간격의 배수인 fps에서만 정확히 τ가 된다. τ=1/12초는 60fps(5프레임)와 12fps(1프레임)에서만 맞고 30·20·15fps에서는 0.067·0.100·0.067초로 흩어진다. τ=1/60초는 60fps를 뺀 전부에서 반폭 1로 바닥쳐 무수정과 완전히 같아진다. 정수 격자 위에서 물리 스텐실은 고정되지 않는다.

두 축을 제대로 고정해도 등급은 따라오지 않는다. 공통 60Hz 격자 + 전 fps ±1/60초 스텐실(E-1+E-2 τ=1/60초)은 60fps 결과를 100% 보존하면서 15fps 임팩트가 60fps 임팩트의 2프레임 안에 드는 비율을 33% → 42%로 올린다. 임팩트는 실제로 수렴한다. 그런데 같은 방식의 60↔15fps 최종 등급 변화는 55% → 54%다.

  base E-1+E-2 τ=1/60초
임팩트 ≤2프레임 (15fps) 33% 42%
60↔15fps 최종 등급 변화 55% 54%

이유는 거리 분포의 모양이다. 임팩트 오차의 몸통이 여전히 30프레임대에 흩어져 있고(평균 33.1프레임 그대로), 등급은 밴드 경계를 넘느냐로 정해진다. 1~2프레임 개선은 경계를 움직이지 못한다.

사전 등록한 기준(합격: 60↔15 등급 변화 ≤5% · 5개 fps 임팩트 일치 ≥80% · 60fps 보존 ≥90%)으로 판정하면 검토한 7개 명세가 전부 불합격이다. 등급 변화를 가장 많이 줄인 τ=1/12초 계열(55%→39%)은 15fps 쪽을 하나도 바꾸지 않고 60fps 쪽을 저 fps로 끌어내려 일치시킨 것이라 정보 우위 보존 조건에서 떨어진다 (60fps 보존 30%).

그래서 구현하지 않았다. A-1은 구현한 뒤 되돌렸지만 이번에는 구현 전에 걸렀다. 판정 기준·산정 스크립트·재실행 방법은 agent/eval/pending7_fps/에 있다 (PREREGISTRATION.md, README.md).

다음 설계가 답해야 할 질문은 셋이다 — (1) 공통 격자를 소스와 무관하게 고정할 것인가(없는 정보를 보간으로 만들게 된다), (2) 스텐실을 프레임이 아니라 연속 시간으로 정의할 것인가(정수 반올림은 사라지나 필터 설계가 새 자유도로 들어온다), (3) 얇은 마진 자체를 없애는 탐색 범위 제한(E-6) — 이것만이 근본 처방인데 정답이 필요하다.

target_fps를 15 → 30으로 올렸다 (2026.09.02 실행 완료)

수정 방향 중 동작점 이동 하나를 실행했다. pose.DEFAULT_TARGET_FPS = 30을 단일 진실원으로 두고 15곳을 정리했다(미결 10번 해소). max_frames는 300 그대로다 — 평가셋 필요값이 299라 손댈 필요가 없었다.

30을 고른 근거는 밴드 적중 15fps 31.8% → 30fps 49.8%이고, 30→60은 +3.7pp뿐이며 평가셋 39클립 중 38건이 30fps 이하다.

전체를 재실행했다(GPU 약 55분). 비교 가능한 것만 나란히 둔다.

지표 15fps 30fps
총 프레임 5,404 10,705
총 후보 30,805 61,364
프레임당 후보 5.70 5.73
B-1 baseline 58.1% 58.1%
B-1 A 70.1% 70.1%
B-1 B 70.9% 74.4%
A≠B 불일치 11.03% (596) 12.45% (1,333)
gate_arm 통과 18/39 19/39
gate_leg 통과 31/39 32/39
q_wrist_mean 중앙 0.717 0.717
B-6 features_ok 111 106
B-6 등급 변화 0 0

비교 불가가 둘이다. switch_rate는 프레임 간격이 절반이 되면 프레임당 전환 확률이 기계적으로 내려가므로 정의상 다른 척도다 — 나란히 놓아도 대소를 말할 수 없다. B-4/B-5 검수 60건은 라벨 자체는 재매핑으로 살지만 표본으로서 무효다. 15fps 불일치 596건의 info_score 상위에서 뽑은 것인데 모집단이 1,333건으로 바뀌었다. 재검수하지 않고 사실만 남긴다.

프레임당 후보 밀도(5.70 → 5.73)와 품질 지표(q_wrist_mean 0.717 동일)가 사실상 불변인 것이 온전성 검사다 — 프레임 수만 두 배가 되고 검출 특성은 그대로라는 뜻이다.

🔴 이것은 근본 해결이 아니다

동작점을 옮긴 것이지 정확해진 것이 아니다. 밴드 적중이 오르고 B selector가 +4건이 된 것은 임팩트를 더 정확히 찾아서가 아니라 다른 프레임을 고르게 되어서다. 어느 쪽이 실제 임팩트에 가까운지는 정답이 있어야 말할 수 있고 그것은 미결 5번 보류다.

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

얇은 argmax 마진은 그대로다. 이 항목이 확정한 원인(구조 하나 + 섭동 둘) 중 어느 것도 fps를 올려서 사라지지 않는다. 오히려 재실행에서 그 마진의 비용이 새로 드러났다 — features_ok 111 → 106이고 순감소 5는 전부 경계 규칙이며, 원인이 미결 5번이 되돌린 그 오염이다(5번의 “되돌린 비용이 실측으로 나왔다” 참고).

분류 정정 — “정답 없이 판정 가능”은 부분적으로 틀렸다

이 항목은 ‘정답 없이 판정 가능’으로 분류했으나 부분적으로 틀렸다. 증상 진단과 원인 규명은 정답 없이 완료됐고, 인접 결함(E-3, 격자 어긋남, max_frames 커버리지) 도 정답 없이 고칠 수 있다. 그러나 등급 일관성의 근본 해결은 얇은 argmax 마진을 없애야 하고, 그 처방(E-6 탐색 범위 제한)은 정답이 필요하다 — 미결 5번 보류 조건과 동일하다.

✅ E-3 — 프레임 단위 값에 물리 시간을 붙였다 (2026.09.04)

인접 결함 셋 중 하나를 고쳤다. 결과에 나가는 프레임 번호가 어느 격자 위에 있는지 알 방법이 없었다.

  고치기 전 지금
/api/analyze/video 결과 impact_frame: 62 뿐. sampled_fps가 아예 없었다 timebase 블록에 원본·실효·목표 fps, step, 분석 길이(초), 그리고 프레임 지표의 초 환산
화면 표기 「임팩트(62프레임)」 「임팩트(2.07초 · 62프레임)」
analyze_s3.py 리포트 sampled_fps는 있었다 — 읽는 쪽이 직접 나눠야 했다 frame_metrics_seconds를 함께 낸다
합성 경로 run_pipeline(fps=12.0) 기본값이 조용히 쓰였다 🔴 known: false를 내고 초를 안 붙인다

모르면 지어내지 않는 것이 이 수정의 핵심이다. 그럴듯한 기본값을 채워 넣는 것이 이 결함이 생긴 방식이다 — 목표 fps를 실효 fps인 양 쓰면 25fps 소스에서 20% 어긋난다(read_frames docstring).

🔴 features 딕셔너리를 바꾸지 않았다. 시간은 결과 봉투의 형제 블록으로 나간다. features는 루브릭·판정·적재가 읽는 측정 이름공간이고, 격자 정보는 선수에 대한 측정이 아니다. 그래서 판정 입력이 그대로이고 B-2~B-6과 비교가 끊기지 않는다 — 위 미결 11번이 「E-3가 B-6 재실행을 부른다」고 적어 둔 것은 이 형태의 수정에는 해당하지 않는다.

  • 새 프레임 단위 지표가 선언을 빠뜨리면 테스트가 걸린다 (test_every_frame_valued_metric_is_declared) — 이름이 _frame/_frames로 끝나는데 FRAME_INDEX_METRICS·FRAME_DURATION_METRICS 어디에도 없으면 실패한다. 결함을 조용히 되살릴 수 없게 하는 것이 이 검사의 목적이다
  • 인덱스(언제)와 길이(얼마나 오래)를 목록으로 갈라 두었다. 나누는 수는 같지만 읽는 쪽이 뜻을 섞으면 안 된다
  • 테스트 8건 추가 (161 → 169)

🔴 남은 것 — 앵커의 프레임 수는 다른 격자에서 매긴 값이다 (2026.09.04, 발견만)

E-3을 고치다 인접한 것을 하나 봤다. 고치지 않았고 발견만 적는다.

follow_through_duration_frames가 두 루브릭(football_instep_shot, football_inside_pass)의 measured_by에 있고, anchors에 프레임 수가 상수로 박혀 있다(9 · 5 · 2 등). 앵커는 판정 모델에게 주는 예시인데, 그 값이 매겨진 동작점과 지금 동작점이 다르면 같은 팔로스루가 두 배로 읽힌다(15fps에서 5면 30fps에서 10이다).

등급은 안 바뀐다. 확인했다 — judge_criterion이 grade = criterion.grade_for(features) 로 코드가 등급을 정하고 모델은 근거 문장만 쓴다(judge.py 주석이 그렇게 적어 두었다). 그리고 이 항목의 bands는 swing_hip_flexion_after_impact_deg(도)라 프레임과 무관하다.

  • 그래서 영향은 근거 문장에 한정된다. 모델이 “팔로스루 18프레임”을 앵커의 9와 견주면 실제보다 길게 서술할 수 있다
  • 처방은 (a) 앵커를 초로 다시 쓰거나 (b) 프롬프트에 격자를 함께 주는 것인데, 둘 다 판정 입력을 바꾸므로 B-6 재실행을 부른다. E-3 표기와 달리 이쪽은 진짜로 그렇다
  • 정답은 필요 없다 — 근거 문장이 수치와 맞는지는 정답 없이 읽어서 판단한다
  • 지금 안 하는 이유: 루브릭 버전을 올려야 하고, 그건 사전 등록이 붙어야 할 변경이다. 이 항목의 상태·결론은 그대로다

✅ 실영상으로도 쟀다 — 모의보다 크다 (2026.09.09)

지금까지 인용된 「60↔30 등급 37%」는 외부 포즈를 솎은 모의다(디코딩·포즈 추정 없음). 사용자가 실제로 겪는 것을 재려고 파일을 다시 인코딩해 전 구간을 태웠다. 근거: agent/eval/pending7_realfps/ · 사전 등록 edb6520.

표본은 pending34_repro(재현성)와 같은 4편이다 — 「가만두면 얼마, fps 바꾸면 얼마」를 나란히 말하려고 맞췄다.

클립 원본 1/2 1/3
10_penalty1 (24fps) 35점 D 🔴 게이트 반려 18점 D (−17)
11_freekick (23.98) 35점 D 48점 D (+13) 35점 D (0)
12_penalty (29.97) 30점 D 55점 C (+25) 48점 D (+18)
bball_shot (23.98) 62점 C 100점 A (+38) 53점 C (−9)
성공 7변형 기준  
총점 |Δ| 평균 17.1점 · 최악 38점
최종 등급 변경 2/7 (29%) · 항목 등급 변경 6/7 (86%)

🔴 3장 허용치가 3점이다 — 평균이 그 5.7배, 최악이 12.7배다. 같은 표본의 「동일 파일」 재현성은 σ 0.00 이다(ho 34번). 두 지표가 극단적으로 갈린다.

🔴 같은 영상이 반려되기도 한다. 10_penalty1 은 24fps 에서는 35점이 나오는데 12fps 로 줄이면 「임팩트 프레임이 분석 가능 구간 경계」로 반려된다. 점수가 달라지는 것과 아예 분석이 안 되는 것은 사용자에게 다른 사건이다.

편향이 아니라 산포다 — 올라간 것 4 · 내려간 것 2 · 그대로 1. 이 항목이 기록해 둔 것과 같은 결론이다. 🔴 첫 판본은 이것을 말할 수 없었다: abs() 를 취한 뒤 + 형식으로 찍어 전부 양수처럼 보였고, 그대로 읽었으면 없는 편향을 보고할 뻔했다. 부호를 살려 다시 돌렸다.

한계: 4클립·7변형이라 비율을 정밀한 값으로 읽으면 안 된다. 1/3(8~10fps)은 실제 업로드에 드물다 — 다만 1/2 만 봐도 4편 중 1편 반려 + 3편이 13~38점 움직인다. 🔴 모의의 37% 를 대체하지 않는다. 둘 다 남긴다 — 서로 다른 것을 잰다.

  • 기록: 개발 로그 2026.09.01 두 편(원인 규명 / E-1·E-2 사전 판정). 조사 스크립트·판정 기준은 agent/eval/pending7_fps/ (GPU 불필요, 재현 약 2분) · 실영상 측정은 agent/eval/pending7_realfps/ (GPU, 약 6분)
  • 담당: 정상호 · 기한: 스프린트 2 — 원인 규명과 수정안 1차 판정이 끝났다. 남은 것은 인접 결함 수리와 명세 재설계이며, 근본 해결은 미결 5번에 묶여 있다.

8. 분석 대상(선수) selector 미확정

영상에 여러 사람이 잡힐 때 누구를 분석 대상으로 고를지 정하는 selector를 후보 5종으로 비교했다. 계층 순서(pose 계열 > geometry 계열 » 면적 최대)는 안정적이지만, 마지막까지 남은 A-pose와 B-pose 중 어느 쪽을 production으로 쓸지는 정하지 못했다. 둘의 차이는 직전 프레임에서 보던 사람을 계속 볼지에 가점을 주는 continuity 항 하나다.

그리고 이 데이터셋으로는 검수를 더 해도 갈리지 않는다 (2026.08.28 확인). 격리 판독 60건에서 B-pose 26 : A-pose 17(60.5%)이지만 p=0.22, 클립 클러스터 95% 구간 [0.40, 0.80]으로 0.5를 포함한다. 이 효과크기를 α=0.05로 확인하려면 판정 가능 340건이 필요한데 39클립이 내놓을 수 있는 상한은 약 49건이다.

대신 드러난 것: continuity는 처음 잡은 대상이 맞으면 이기고 틀리면 진다. 승패가 클립 안에서 몰린다(2건 이상 판정된 11클립 중 6클립 만장일치).

  • 방향 전환: “어느 selector인가”가 아니라 “continuity를 언제 신뢰할 것인가” 로 질문을 바꾼다. 대상 전환 빈도(A-pose 6.8% → B-pose 1.8%)와 잘못된 대상에 고착된 구간의 길이는 사람 라벨 없이 잴 수 있다
  • 클립을 늘리는 선택지도 있으나(26클립·104구간이 병목) 비용 대비 효과 미정
  • 사람 검수자는 여전히 미확보 — 지금까지의 라벨은 전부 AI 단독 판독이다 (미결 2번과 같은 병목)
  • 기록: agent/eval/phaseA/, 전체 보고서 eval_b4/clean_review_report.md
  • 담당: 정상호 · 기한: 스프린트 3

O2GSaYqH8JY는 selector 문제가 아니다 — 후보에 타자가 없다 (2026.09.03)

미결 6번 판독 자료를 만들다 이 클립의 스켈레톤이 공 줍는 사람에게 붙어 있는 것을 보고 selector 실패로 의심했는데, 후보 상자를 직접 열어 보니 다르다.

   
라벨된 프레임 labels.json 30·75·119 (그리고 pass1의 20·50·80)
그 프레임의 후보 수 전부 1개 — 고를 것이 없었다
B-1·B-2 채점 5종 selector 전부 3/3 correct, clip_correct=1

즉 “정답”이 selector의 능력을 재지 않았다. RT-DETR이 그 프레임에서 사람을 하나만 검출했고, 그 하나가 타자가 아니었다. 클립 전체로는 300프레임 중 다중후보가 100프레임(2개 93 · 3개 7)이라 검출이 아예 안 되는 것은 아니지만, 라벨된 3프레임이 하필 단일후보 구간이었다.

  • 함의: 이 클립에서 selector를 바꿔도 결과가 안 바뀐다. 고칠 자리가 있다면 selector가 아니라 검출 임계·후보 생성 쪽이다
  • 검정력 산정(340건)에 이런 “고를 것이 없는” 판정이 몇 건이나 섞여 있는지는 아직 안 셌다. eval_b1/selector_eval_clips.csv의 candidate_count_summary로 셀 수 있다 — 다음에 이 항목을 열 때 먼저 할 것
  • 같은 형태로 이미 기록돼 있던 클립: 3R1kvNrGJK0(심판/포수). 둘 다 phaseA_metadata.csv의 notes에 있었고 usable_for_phase_B = no다
  • 🔴 w-AQcjcoDyA를 여기서 뺀다 (2026.09.03 정정). 앞서 이 클립도 “신발 클로즈업, 타자가 피사체가 아님”이라 적었는데 그 기록이 오진이었다 — 스켈레톤이 붙은 사람이 타자가 맞고 신발은 앞에 있던 다른 사람이 찍힌 것이다. 미결 6번 판독에서 subject_ok = y로 확인됐다. phaseA_metadata.csv와 metadata.py를 고쳤고, 그 오진에 딸려 있던 player_scale·peak_verdict도 미검증으로 되돌렸다(둘 다 “대상이 없다”를 전제로 매긴 값이다)
  • 그래서 selector가 실제로 틀린 것으로 남은 것은 3R1kvNrGJK0 하나다. O2GSaYqH8JY는 위에서 본 대로 고를 것이 없던 경우이므로 selector 실패가 아니다. 이 항목의 근거가 생각보다 얇다
  • 🔴 판독 자료로는 나머지 클립을 확인할 수 없다 — 39장 중 19클립은 애초에 미검증이다. 미결 6번 서식의 subject_ok 칸이 채워지면 그때 39건 전수의 답이 생긴다. 이 항목이 기다릴 값이다
  • 이번에는 아무것도 고치지 않았다 (발견 보고만)
  • 🔴 상호 참조 — 18번「프론트에 분석 대상을 찍는 UI가 생겼는데 백엔드가 안 받는다」: 사람이 첫 프레임을 찍는 경로가 화면에 생겼다. 이 항목은 격하되지 않는다(이유는 그쪽에 적었다). 위에서 “다음에 이 항목을 열 때 먼저 할 것”으로 남긴 단일후보 판정 집계도 그쪽에서 셌다

🔴 기다리던 subject_ok가 나왔는데 39건 전수가 아니다 (2026.09.04)

위에 「미결 6번 서식의 subject_ok 칸이 채워지면 그때 39건 전수의 답이 생긴다. 이 항목이 기다릴 값이다」라고 적어 두었다. 그 값은 생기지 않았다.

   
답이 있는 것 12건 — 전부 y (n 0건)
답이 없는 것 27건. subject_ok를 매기기 전에 판독 단계에서 빠졌다
그 27건에 들어간 것 3R1kvNrGJK0(selector가 틀린 것으로 알려진 하나)과 O2GSaYqH8JY(후보가 1개뿐이던 것) 둘 다

🔴 이 라벨을 selector 정확도로 읽으면 안 된다. 12/12가 되는데 그것은 표본이 그렇게 만들어졌기 때문이지 성능이 아니다 — 판독자가 “타자가 아니다”로 판정한 클립이 통째로 빠졌고, selector가 틀린 경우가 바로 거기에 있다.

  • 전수 답을 받으려면 2차 판독(review_packet2/의 1회차)이 27건에 subject_ok를 매겨야 한다. 그 서식은 줄을 지우지 않게 만들었다
  • 근거: labeling/RESULTS.md C절
  • 이 항목의 상태와 결론은 그대로다. 기다리던 입력이 반만 왔다는 사실만 적는다

🔴 3회차 — 갈림은 앵커에서 시작하지 않는다. 내내 갈린다 (2026.09.16, 축구)

항목이 적어 둔 방향 전환(「어느 selector인가」가 아니라 「continuity를 언제 신뢰할 것인가」)을 처음으로 쟀습니다. 사전 등록 PREREGISTRATION.md(69f63c1) · 축구 수정본(46dcb07, 결과 보기 전) · 결과 RESULTS.md. 🔴 조사 회차 — src/·rubrics/·eval_b2.py 무변경, 라벨 불필요.

표본을 축구로 옮겼습니다 (사용자 지시 · 아래 별도 절). SoccerNet 방송 100편 — 축구 1인 영상 19편으로는 「여러 명 중 고르기」를 아예 못 묻습니다.

   
계기 검사 (기준 A) 46번 관문 기록과 겹치는 40편에서 다인률 97.8% 대 97.8%, 상관 ρ = 1.000 ✅
갈림 비율 다중후보 프레임의 중앙 57% 에서 A(continuity 없음)와 B(있음)가 다른 사람을 고릅니다
🔴 주 예측 (기준 B) 「갈림은 앵커에서 시작한다」 — 2/99 클립 (2%). 크게 틀렸습니다
짝 기준 (C) 97/99 가 중간에 새로 갈립니다. 클립당 갈림 중앙 11회, 구간 길이 중앙 4.5프레임(최장 44)
전환율 (F) A 18.5% · B 3.4% (5.4배). 야구 기록(6.8%/1.8%, 3.8배)과 비율은 안 견줍니다 — 표본도 짝도 다릅니다. 같은 것은 방향과 크기 순서

🔴 왜 예측이 틀렸나 — 항 하나만 보고 합을 안 봤습니다. continuity 가 직전 프레임 IoU라 「한번 갈리면 각자 트랙을 강화한다」고 봤는데, 두 selector 는 나머지 항(중앙성·크기)이 같아서 기하 점수가 가까워지면 다시 같은 박스로 모입니다. 「각자 강화」는 그 항 안에서만 참입니다.

그래서 이 회차가 닫는 것: 🔴 클립 단위로 continuity 신뢰 여부를 정하는 길은 닫혔습니다. 갈림이 클립마다 열 번 넘게, 4~5프레임씩 나므로 「이 클립은 믿는다/안 믿는다」가 애초에 답할 수 있는 형태가 아닙니다. 조건을 찾으려면 프레임 단위 사건이어야 합니다.

🔴 어느 쪽이 옳은지는 여전히 모릅니다 — 라벨이 없습니다. 잰 것은 「얼마나 자주·얼마나 오래 갈리는가」이지 「누가 맞는가」가 아닙니다.

사후(판정 밖): 다인 비율과 갈림 비율의 상관이 0.099 — 사람이 많아서 갈리는 것이 아닙니다. 다음 단서는 이쪽입니다.

야구 회차는 판정하지 않았습니다

옮기기 전에 야구 39편으로 먼저 돌렸고 기준 A(저장된 B-2 선택 재현)가 불합격이었습니다 — 182/234, 격자를 맞추자(저장된 CSV 는 target 15, 제 측정은 target 30) 221/234. 사전 등록대로 B·C 를 판정하지 않았습니다. 🔴 표본을 옮긴 것이 그 판정을 피하려는 것이 아닙니다 — 옮기기 전에 이미 불합격이었고, 그 사실을 수정본에 결과 보기 전에 적었습니다. 남은 13건은 전부 인접 후보 사이의 뒤바뀜이라 근사 동점으로 보이지만 왜인지는 못 밝혔습니다. 🔴 selector_eval_frames.csv 가 target 15 산출인데 이름에 동작점이 없습니다 — 미결 10번이 말한 그 형태입니다.

  • 확인: cd agent && uv run python eval/pending8_continuity/measure_divergence.py --sample soccernet --out <csv> (검출 포함 약 30분, GPU)

9. 4K 입력에서 host RAM이 먼저 터진다

PoseResult.frames가 샘플링한 프레임을 원본 해상도 BGR로 전부 들고 있다 (pose.py의 kept_frames). 오버레이 렌더링에 쓰려고 남기는 것인데, 추가 추론이 없다는 이점 대신 메모리를 프레임 수에 비례해 먹는다.

해상도 150프레임 300프레임
1080p (6.2MB/프레임) 0.93GB 1.87GB
4K 세로 2160×3840 (24.9MB/프레임) 3.73GB 7.46GB

VRAM은 여유가 있다. 검출·포즈 모델이 프레임을 한 장씩 처리하므로(배치 1) 프레임 수가 늘어도 GPU 점유는 그대로다 — 포즈 추출 peak VRAM 643MB 실측. 병목은 host RAM이다.

agent/data/baseball_pitch_trim.mp4가 정확히 2160×3840이라 가상의 문제가 아니다. 지금은 target_fps=15에 눌려 프레임 수가 적어 드러나지 않지만, 미결 7번 때문에 target_fps를 올리면 즉시 문제가 된다 — 그때는 max_frames도 함께 올려야 하므로 상한이 두 배로 겹친다.

D-1 구현 (2026.09.01). PoseResult가 프레임 대신 video_path·target_fps만 들고, 미리보기를 만들 때 load_frames()로 다시 디코딩한다. 재디코딩 비용은 포즈 추출(모델 적재 제외)의 9.7~10.8%로 실측됐다. 같은 클립 2건(1080p·4K)에서 지표와 등급이 소수 끝자리까지 동일하고, 미리보기는 화소 단위로 같다.

  • 남은 것: 프레임 수 자체(max_frames=300)와 4K 상한은 그대로다 — 재디코딩은 보관 시간을 줄일 뿐 한 번에 올리는 장수를 줄이지 않는다
  • 정답 불필요 — 메모리 사용량은 측정으로 확정되고 옳고 그름을 정답 없이 판단한다
🗂 원인과 RSS 실측 — 가드가 창을 줄이던 경위. 펼쳐 봅니다

🔴 가드가 분석 창을 조용히 줄이고 있었다 — 재 봤다 (2026.09.07)

메모리 문제인 줄만 알았는데 분석 의도까지 먹고 있다. 상한이 둘인데 (창 DEFAULT_MAX_SECONDS=10.0초 · 가드 DEFAULT_MAX_FRAMES=300장) 먼저 걸리는 쪽이 이긴다. 실효 fps가 30을 넘으면 가드가 이겨서 보기로 한 10초를 못 본다.

   
가드가 이기는 소스 30.5~44.5fps 가 가장 나쁘다 (step이 1이라 원본 fps가 그대로 넘어온다). 위쪽 구간도 간헐적으로 걸린다
최악 44.5fps → 6.74초. 보기로 한 10초의 67%
평가셋 39클립 0건. 최대 30.0fps(59.94는 step 2로 29.97이 된다)라 하나도 안 걸린다

🔴 그래서 이 가드를 고쳐도 B-6 재실행을 부르지 않는다 — 39클립의 결과가 한 비트도 달라지지 않는다. 값을 옮길 수 있는 드문 자리다.

지금 한 것은 드러내는 데까지다. timebase에 limited_by를 넣어 "window"(보기로 한 만큼 봤다)와 "memory_guard"(자원 때문에 창을 못 지켰다)를 가른다. 잘렸다는 사실만으로는 그 둘이 구분되지 않아 「10초 보기로 하고 6.7초만 봤다」가 안 보였다.

처방 후보 — 가드를 장수가 아니라 바이트로 둔다. 지금 가드는 해상도를 안 본다. 같은 300장이 4K에서 7.46GB, 1080p에서 1.87GB다(위 실측표). 예산을 지금의 4K 300장으로 잡으면:

해상도 예산 안 장수 44.5fps에서 창(445장)을
4K 세로 300장 — 그대로 여전히 못 지킨다
1080p 1,200장 지킨다
720p 2,700장 지킨다

4K 동작을 한 비트도 안 바꾸면서 나머지를 푼다. 다만 아직 구현하지 않았다 — 프레임 말고도 드는 것이 있어(모델·중간 텐서) 실제 RSS를 재기 전에는 예산을 옮기지 않는다. 그 측정이 이 항목의 다음 한 걸음이다.

📏 RSS 실측 — 끝났다. A ✅ · B 🔴 · C ✅ · D ✅ (2026.09.08)

근거: agent/eval/pending9_rss/ (사전 등록·스크립트·결과). 합격 기준은 측정 전에 고정했다. 로컬(--plan safe)과 EC2(--plan full, RAM 15.8GB) 두 회차이고, 결론은 EC2 것이다 — 4K 300장이 로컬 RAM 9GB로는 안 돌아간다.

RSS_peak ≈ base + k × frame_bytes (EC2, 재현 회차는 적합에서 뺐다):

해상도 점 base k
1920×1080 3 1,570MB 0.97
2160×3840 4 1,748MB 0.98
기준 결과
A base 가 해상도끼리 20% 안 ✅ 편차 10% — 상수라는 전제가 선다
B k 가 1.0~3.0 🔴 0.97~0.98 — 하한을 2~3% 밑돈다
C 4K 300장이 인스턴스 RAM 안 ✅ 9,061MB / 16,162MB (56%)
D 재현 10% 안 ✅ 9,061 vs 9,050MB — 편차 0.1%

🔴 기준 B 불합격을 기록으로 남기고 기준을 고치지 않는다. 사전 등록에 「1.0 미만이면 측정이 프레임을 못 보고 있다」고 적었는데 그 해석이 틀렸다 — k≈0.98 은 정확히 한 벌만 들고 있다는 값이고 D-1(재디코딩)이 의도한 그대로다. 2~3%는 RSS 회계와 서너 점 적합의 오차로 설명된다. 두 기계가 같은 값을 냈으므로 적합 오차가 아니라 실제 배수가 1에 가깝다는 뜻이다. 그래도 결과를 보고 합격선을 옮기면 근거가 아니게 되므로 불합격으로 두고 근거가 틀렸다는 것만 적는다. 사전 등록의 「B가 3.0을 넘으면 사본이 여러 벌인지부터」는 발동하지 않는다.

채택 예산 = 4K 300장 실측 9,061MB. 위 처방 표를 이 값으로 다시 계산하면:

해상도 항목의 기존 추정 실측 반영
4K 세로 300장 (그대로) 300장 (그대로)
1080p 1,200장 약 1,240장
720p 2,700장 약 2,790장

기존 추정이 잘 맞았다(5% 안). 예산이 base 보다 훨씬 커서 영향이 작았다. 로컬 적합의 외삽 예측 9.1GB도 실측 9.06GB와 0.5% 안에서 맞았다 — 다만 맞았다는 것은 재 보기 전에는 말할 수 없었다.

🔴 기준 C는 통과했지만 혼자 쓸 때의 이야기다. 이 인스턴스에는 vLLM이 상주하고(호스트 RSS 약 3.2GB), 측정 중 시스템 전체가 13.0GB / 15.8GB 까지 올라갔다(OOM은 없었다). 바이트 예산을 구현하는 회차에서는 동거 프로세스 몫을 예산에서 먼저 빼야 한다. 워커는 vLLM과 같은 기계에서 돈다.

🔴 준비하면서 「조용히 틀리는」 결함을 셋 잡았다. (1) 클립이 짧아 요청 장수에 도달 못 했다 — 1080p 150·300장이 똑같이 146장이었다. 그대로 EC2에서 돌렸으면 4K 92장을 재고 「300장」이라 적을 뻔했다. 도달 못 하면 종료 코드 2로 죽게 했고 이어 붙인 긴 클립을 만드는 --prepare 를 뒀다 — 🔴 이어 붙이는 횟수도 고정하지 않는다(원본 길이가 기계마다 다르다: 로컬 146 · EC2 46프레임). (2) VmHWM 이 프로세스 생애 최고라 회차끼리 오염됐다 — 세 조합이 전부 같은 3,759MB로 나왔다. 조합마다 새 프로세스로 돌린다. 적합이 k=-0.00 을 내고서야 드러났고, 사전 등록의 기준 B가 그걸 잡았다. (3) --clip 이 항상 종료 코드 2로 죽었다 — argparse choices=tuple(CLIPS) 가 파서를 만들 때 평가되는데 CLIPS 는 그때 비어 있다(채우는 함수가 parse_args() 뒤에 돈다). (2)가 가른 자식 프로세스가 전부 그렇게 죽어 EC2 첫 전체 회차가 첫 조합에서 멈췄다. 🔴 (2)의 수정이 (3)을 불렀다.

계획이 기준 D를 판정할 수 없었다. PLAN_FULL 에 반복 회차가 없어 D는 영원히 「미측정」이었다 — 재는 계획이 기준을 만족시키지 못하면 그 기준은 판정이 안 된다. 4K 300장을 두 번 넣고, 반복 점은 적합에서 뺐다(같은 점을 두 번 세면 최소제곱이 그쪽으로 쏠린다).

🔴 이 회차에서 DEFAULT_MAX_FRAMES 는 바꾸지 않았다 — 바꾸면 features 가 달라져 B-6 재실행을 부른다. 남은 것은 가드를 바이트로 옮기는 별도 회차이고, 거기서 (a) 동거 프로세스 몫을 뺄 방법과 (b) 4K 300장을 상한으로 둘지를 정한다.

✅ 가드를 바이트로 옮겼다 — A~E 5/5 합격 (2026.09.08)

근거: agent/eval/pending9_budget/ (사전 등록·판정 스크립트·결과). 합격 기준은 구현 전에 고정했다(15244dc). 재현은 uv run python eval/pending9_budget/verify_budget.py (불합격이면 종료 코드 1).

DEFAULT_MAX_FRAMES = 300(해상도를 안 보는 장수) → DEFAULT_MAX_FRAME_BYTES = 7,465MB(= 지금의 4K 세로 300장). 장수는 frames_within_budget(w, h) 가 해상도에서 계산한다.

해상도 옛 상한 새 상한 44.5fps 10초 창(445장)을
4K 세로 300장 300장 (그대로) 여전히 못 지킨다
1080p 300장 1,200장 지킨다
720p 300장 2,700장 지킨다
기준 결과
A 평가셋 39클립 장수 불변 ✅ 39/39 — B-6 재실행을 부르지 않는다
B 4K 세로 300장 그대로 ✅ 정확히 300장
C 30.5~44.5fps 에서 가드가 창을 안 먹는다 ✅ 1080p 상한 1,200장 · 구간 0건
D 동거 몫을 뺀 예산이 인스턴스 안 ✅ 7,465+1,748+3,200 = 12,413 / 14,546MB
E 예산이 런타임 상태에 의존하지 않는다 ✅ 런타임 조회 0곳

항목이 남긴 두 질문에 답한 방식:

  • (a) 동거 vLLM 몫 — 예산을 상수로 두고 값을 정할 때 뺐다. 🔴 런타임에 남은 메모리를 조회하는 설계를 배제했다: 그러면 같은 클립이 기계 상태에 따라 다른 장수로 분석되어 점수가 흔들리고 그 차이가 아무 데도 안 남는다. 편해 보이는 쪽이 조용히 틀리는 쪽이다
  • (b) 4K 300장 — 상한으로 뒀다. 실측 여유는 11,214MB로 1.5배지만 올리면 4K 결과가 달라져 창을 지키는 일이 아니라 동작점 이동이 된다

🔴 4K는 여전히 창을 못 지킨다 — 고친 것이 아니다. 4K 세로 고fps 소스는 그대로 6.74초까지 줄어든다. 남은 결함이고 「해소」가 아니다.

🔴 판정 스크립트에서 단위를 섞고 있었다. 예산은 MiB로, 실측값은 10진 MB로 받아 같은 예산이 7,465와 7,119 두 값으로 보였다. 어느 쪽이든 합격이라 판정이 안 뒤집혔고 그래서 더 위험했다 — 합격이 나오면 아무도 다시 안 본다. 10진으로 통일했다.

인접 확인: eval/phaseA/labeling/targets.py(보존 자산)의 MAX_FRAMES = 300 주석이 「read_frames 기본값과 같아야 한다」인데 그 문장은 이제 성립하지 않는다. 다만 39클립에 창을 안 씌우고 재도 target 15·30 양쪽에서 300장에 닿는 클립이 0건이라 실해가 없다. 보존 자산을 고치지 않았다 — 얻는 것이 주석 한 줄의 정확도뿐이라 B-6 자산을 건드릴 값이 아니다.

| | | |—|—| | 확인 | cd agent && uv run python eval/pending9_budget/verify_budget.py — 기준 A~E 5/5. 불합격이면 종료 코드 1 | | 크기(고치기 전) | uv run python eval/pending9_window/coverage.py — 최악 6.74초 · 평가셋 0건. 왜 고쳤는지를 남기는 자리다 | | RSS 판정 | agent/eval/pending9_rss/RESULTS.md (원본 ec2_full.json·ec2_full.log). 다시 재려면 RAM 15GB 이상 기계에서 --prepare 뒤 --plan full | | 하지 말 것 | max_frames를 창으로 쓰지 말 것. 올리면 덮는 실시간 길이가 fps에 따라 달라지고 그 차이가 아무 데도 안 남는다 |

  • 담당: 정상호 · 기한: 미결 7번 수정 착수 전 (항목을 만든 e59c0b6부터 정어진으로 잘못 적혀 있었다 — jin 구역 6번에서 지적받아 2026.09.03에 고쳤다)

10. 서비스와 평가가 target_fps를 서로 다른 방식으로 얻는다 ✅ 해소 (2026.09.02)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

11. B-6 재실행 자산이 /mnt/d 단일 사본에 의존한다

Phase A와 B-1~B-6은 전부 /mnt/d/supersub-phaseA/(WSL에서 접근하는 Windows D: 드라이브, git 저장소 아님)를 작업 루트로 썼다. 스크립트·CSV·라벨은 저장소에 들어와 있지만 바이너리 입력은 그 드라이브에만 한 벌 있었다.

자산 크기 없으면
candidates/*.npz 589KB Track 1 재실행 불가
cache/*.npz 1.20MB Phase A 지표표를 GPU 없이 다시 낼 수 없다
clips/*.mp4 130MB 위 둘을 다시 만들 수 없다

복구 경로가 링크 로트에 노출돼 있다. candidates/를 잃으면 clips/ 130MB에 RT-DETR을 다시 돌려야 하는데, 그 클립은 Kinetics/YouTube 원본이라 다시 모을 수 있다는 보장이 없다. 589KB가 130MB 재수집을 막는 유일한 방벽이었다.

재실행 결과가 이전과 같다는 보장도 없다. 모델 가중치가 HF 저장소 이름만으로 고정돼 있어(revision= 없음) 업스트림이 갈아 끼우면 조용히 바뀌고, 로컬 캐시가 살아 있는 동안은 드러나지 않는다. MAX_BATCH=24의 OOM 폴백이 배치 크기를 바꿔 부동소수점 결과를 흔들 수 있으며 폴백 발생 여부를 기록하지 않는다. cudnn.deterministic도 설정돼 있지 않다.

불일치가 나오면 판단은 “이번 재실행을 채택할까”가 아니라 “B-2~B-6의 어느 결론까지 다시 봐야 하는가” 가 된다.

부분 완화 (2026.09.01). candidates/·cache/ 합 1.78MB를 agent/eval/phaseA/로 백업했다(md5 전수 대조 완료). 다만 스크립트가 /mnt/d 경로를 하드코딩하므로 그 사본은 아직 읽히지 않는 백업이고, clips/ 130MB와 labeling/ 31MB는 여전히 그쪽에만 있다. 재실행 절차와 비결정성 요소는 agent/eval/phaseA/eval_b6/RERUN.md에, 자산 설명은 같은 폴더 PRESERVED_ASSETS.md에 있다.

코드 사본은 없앴다 (2026.09.02). 이 항목이 “읽히지 않는 백업”이라고 적어 둔 상태가 실제 사고로 이어졌다 — 평가 스크립트 13개가 sys.path에 /mnt/d를 넣고 targets·eval_b2를 거기서 import했고, target 30 재실행에서 저장소의 라벨 재매핑이 반영되지 않은 채 B-1/B-2가 돌았다. 예외도 경고도 없이 숫자만 달랐다 (selector A 70.9% 대 70.1%, B 73.5% 대 74.4%). 13개를 저장소 기준으로 재배선하고 /mnt/d의 .py 38개를 지웠다 — 코드는 저장소, 데이터는 /mnt/d. 감사 결과 38개 중 37개가 저장소판과 바이트 동일했고 갈라진 extract.py 하나도 수치를 바꿀 수 없는 차이여서 과거 결론에는 영향이 없다(커밋 adf2ba9). 남은 것은 아래의 경로 상수 문제이며 미결 14번으로 분리했다.

사본이 이제 읽힌다 — 그리고 낡아 있었다 (2026.09.03). 이 항목이 “읽히지 않는 백업”이라고 적어 둔 상태를 실제로 고쳤는데, 그 과정에서 더 나쁜 것이 드러났다. 저장소에 보존해 둔 캐시가 target 15인데 프로젝트는 2026-09-02에 target 30으로 옮겼다 — 보존 자산이 커밋된 결과와 다른 동작점이었다. 미결 13번 조사에서 항목이 적어 둔 재현 경로로 해당 프레임을 찾을 수 없어 드러났다.

  • 현재 동작점을 들여왔다: cache_target30/(2.4MB)·candidates_target30/(2.2MB). 기존 것은 cache_target15/·candidates_target15/로 이름을 바꿨다 — 폴더 이름에 동작점이 안 보이면 섞어 쓰게 되고 그게 미결 10번의 형태다
  • eval/phaseA/paths.py를 두어 어디서 읽을지 한 곳에서 정한다. md5 전수 대조 일치(39/39, 78/78)이고, 저장소 사본으로 다시 잰 결과가 /mnt/d로 잰 것과 바이트 동일하다. SUPERSUB_PHASEA_ROOT=/nonexistent로 가려도 돈다
  • GPU 없이 지표를 다시 내는 경로는 이제 저장소만으로 열린다 — 이 자산의 원래 보존 이유가 그것이었다
  • 남은 것: clips/(130MB)·labeling/(31MB)은 여전히 /mnt/d에만 있고, 스크립트 14개의 경로 상수도 그대로다(미결 14번)
  • 모델 가중치에 revision을 고정할지 미정 → ✅ 고정했다 (2026.09.11). 아래 참고
  • 정답 불필요 — 사본이 몇 벌인지, 재실행 결과가 일치하는지는 정답 없이 확인된다

✅ 가중치를 커밋으로 고정했다 (2026.09.11). eval_b6/RERUN.md 가 이것을 비결정성 N-1 로, 「가장 흔하고 가장 늦게 발견되는 원인」이라고 적어 두고 있었다. 그 줄을 닫았다.

  • pose.py 에 PERSON_DETECTOR_REVISION(457857ce…)·POSE_MODEL_REVISION (a93ac0c6…), judge.py 에 MODEL_REVISIONS(EXAONE 4.0 1.2B 3abf2810…). 재실행 경로 7개 스크립트도 같은 상수를 쓴다 — production 만 고정하면 B-6 재실행이 계속 표류한다
  • 🔴 값을 바꾸지 않았다. 세 해시는 2026.09.11 HF 캐시의 refs/main 이고 지금까지의 모든 결과를 낸 스냅숏 그대로다 — 다음에 바뀌는 것을 막은 것이다. HF_HUB_OFFLINE=1 로 적재가 되는 것으로 확인했다(재다운로드 없음)
  • 🔴 3.5 계열(2.4B·7.8B)은 비워 뒀다 — 이 기계에 캐시가 없어 확인할 수 없고, 확인 못 한 해시를 적는 것은 안 적는 것보다 나쁘다. 그 둘은 원격 코드라 transformers 버전까지 함께 고정해야 한다
  • 표류 감지: tests/test_model_pins.py (6건). 🔴 빨개지면 고정을 캐시에 맞추지 않는다 — 그러면 과거 결과와 다른 가중치로 조용히 갈아타는 것이다. 올리려면 재실행 회차와 함께 올린다
  • 확인: cd agent && uv run pytest tests/test_model_pins.py -q
  • 남은 것: clips/(130MB)·labeling/(31MB)의 두 번째 사본 → ✅ 했습니다 (2026.09.16). 아래 참고. 남은 것은 경로 상수(미결 14번)와 기계 밖 사본

✅ 두 번째 사본과 지문 (2026.09.16) — 「어디에 둘지가 판단」이라 적어 둔 것을 정하고 실행했습니다.

   
어디에 ~/supersub-assets/phaseA/ (ext4). /mnt/d 는 Windows drvfs 라 같은 기계라도 다른 고장 모드입니다 — 드라이브를 못 붙이거나 Windows 쪽에서 지우는 것을 막습니다
지문 agent/eval/phaseA/preserved_manifest.csv — 198개 파일의 path·bytes·md5 (15KB). 🔴 이쪽이 사본보다 먼저입니다 — 사본을 늘리는 것만으로는 「그때 그 바이트인가」에 답하지 못하고, 47번이 그 질문에 일주일을 썼습니다
확인 cd agent && uv run python eval/phaseA/verify_preserved.py [--root <사본>] — 두 사본 198/198 일치, 없는 루트를 주면 198개 어긋남(판별이 되는 검사입니다)
🔴 안 막는 것 디스크 고장과 기계 자체의 분실. 같은 기계의 다른 파일시스템입니다. 기계 밖 사본은 여전히 없고, S3 에 두려면 videos/·reports/ 밖 접두사에 쓰기 권한이 필요합니다(지금 역할은 reports/ 쓰기 전용)
🔴 어긋나면 덮어쓰기 전에 어느 쪽이 맞는지부터 정합니다 — 다른 사본이 틀렸을 수도, 매니페스트가 낡았을 수도 있습니다
  • 담당: 정상호 · 기한: 미결 7번 수정 착수 전 (E-3·E-6이 B-6 재실행을 부른다)

12. 관측 sink 우회는 흔적을 남기지 않는다 ✅ 해소 (2026.09.03)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

13. np.gradient의 끝단은 오염에 구조적으로 더 취약하다

np.gradient는 안쪽에서 중심차분을 쓰지만 첫·마지막 프레임에서는 단측차분을 쓴다. 이웃이 하나뿐이라 그 하나가 측정 불가 프레임이면 평균으로 희석되지 않고 그대로 속도가 된다.

2026.09.02 target 30 재실행에서 이것이 실측으로 드러났다. features_ok 감소 5건의 원인인 경계 규칙 실패 6건이 전부 클립 끝단에서 났다. gg5xRWjw3f8에서 f299의 각속도가 |83.1 − 1.2| = 81.9로 클립 전체 최대가 되어 argmax를 이겼는데, 1.2는 f298(usable 아님)의 쓰레기 값이다. 안쪽 프레임이었다면 양옆 평균이라 절반으로 희석됐을 값이다.

  • 미결 5번의 “갭 경계 단측 차분” 검토와 이어진다. 그쪽은 갭 경계에서 단측차분을 쓰자는 제안이고 여기는 끝단에서 단측차분이 이미 쓰이고 있다는 사실이다. 단측차분이 중심차분보다 1.184배 크다는 스케일 편향도 같은 문제의 일부다.

🔴 전수 측정 결과 (2026.09.03) — 끝단은 문제의 8%다

39클립 전수로 쟀다 (agent/eval/pending13_edge/).

  arm leg
임팩트가 유효 구간 끝단 3 (8%) 1 (3%)
이긴 프레임의 이웃이 오염됨 27 (69%) 10 (26%)
 그중 끝단이기도 한 것 3 1
오염을 빼면 argmax 가 옮겨감 26 (67%) 9 (23%)

오염이 argmax 를 정한 27건 중 24건은 끝단이 아니다. target 15에서도 같다 (끝단 10%, 오염 77%). 이 항목의 제목이 문제를 좁게 잡고 있었다 — 끝단은 희석이 없어 더 아프게 드러날 뿐이고, 오염 자체는 클립 전체에 퍼져 있다.

처방 (a)「끝단 프레임을 임팩트 후보에서 뺀다」는 기각한다. 3건을 고치고 24건을 남긴다. 경계 규칙 반려 3건이 통과로 바뀌는데 그 통과가 옳은지는 여전히 알 수 없고, 오염이 정한 나머지 24건은 조용히 남는다 — 드러나던 실패를 감추고 숨은 오류는 남기는 방향이라 이 계열 항목이 지켜 온 원칙과 어긋난다. (b)「끝단에서 이웃 usable 확인」도 덮는 범위가 같아 마찬가지다. 이 기각에는 정답이 필요 없다 — 몇 건을 덮는지 세면 된다.

처방 (c)「속도 정의를 바꾼다」는 미결 5번의 A-1 그 자체다. 오염원을 빼고 다시 뽑으면 argmax 가 중앙값 64프레임 옮겨간다(최대 226). 5번이 A-1을 되돌린 이유가 “야구 impact 84.3% 이동(중앙 33프레임)을 검증할 정답이 없다”는 것이었는데, 여기서 독립적으로 같은 크기의 이동이 재현됐다. 되돌린 근거는 그대로 유효하고, 이 항목은 5번의 재개 조건을 함께 기다린다.

  • 남은 처방은 (c)뿐이고 그것은 정답이 있어야 판정된다. 그전까지 손대지 않는다
  • 정답 불필요 — 끝단이 단측차분이라는 것도, 그 이웃이 usable이 아니라는 것도 정답 없이 확인된다. 다만 어느 처방이 옳은지는 미결 5번의 정답이 필요하다.
  • 기록: 개발 로그 2026.09.02, 측정 agent/eval/pending13_edge/README.md
  • 🔴 재현 경로를 정정하고 고쳤다. 앞서 agent/eval/phaseA/cache/gg5xRWjw3f8.npz 로 재현된다고 적었는데 그 파일은 target 15(150프레임)라 f297~f299가 없었다. target 30 캐시가 /mnt/d에만 있었기 때문이다. 2026.09.03에 저장소로 들여왔고(cache_target30/), 이제 /mnt/d 없이 전부 재현된다 — 미결 11번 참고. 명령은 uv run python eval/pending13_edge/measure_edge.py
  • 잡음 증폭비는 정의에 따라 다르다 — 위 1.184와 별개로, 안쪽 프레임 전체에 두 공식을 적용해 산포를 비교하면 중앙값 1.444다. 인용할 때 정의를 함께 적을 것
  • 담당: 정상호 · 기한: 미결 5번의 정답 확보와 함께 (처방 (a)·(b)는 2026.09.03에 기각됐고, 남은 (c)는 정답이 있어야 판정된다)

14. 평가 스크립트에 /mnt/d 데이터 경로가 하드코딩돼 있다 ✅ 해소 (2026.09.11)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

15. AWS 배포가 라이선스 두 건을 상용 경로 앞에 세웠다 (2026.09.02)

분석 에이전트를 독립 AWS 환경(S3 + g4dn.xlarge)에 올리는 절차를 agent/deploy/README.md에 정리했다. 내부 검증용으로는 그대로 진행하면 되지만, 이 인스턴스가 외부 사용자에게 서비스를 제공하는 순간 아래 두 건이 동시에 열린다.

  • EXAONE 4.0은 NC(비상업) 라이선스다 — 미결 1번 그대로다. 배포가 그 결정을 앞당겼을 뿐 새로운 문제는 아니다. ✅ 결정 (2026-09-08, 박민호, 미결 1번과 동일): 지금 10주 개발 기간은 매출이 없어 이 조건이 걸리지 않으므로 급하지 않다. 서비스를 유료로 여는 시점부터 다시 열리고, 그때는 “AI 평가만 무료면 된다”가 아니라 “매출 나는 서비스에 모델이 들어가면 그 자체로 상업적 이용”일 수 있다는 점까지 라이선스 원문으로 재확인해야 한다(상세는 미결 1번)
  • ultralytics는 AGPL-3.0이다 — pyproject.toml:42의 판단대로 EC2에는 --extra tracking을 설치하지 않았다. yolo11n.pt는 scripts/track_overlay.py (검수용 오버레이) 전용이고 서비스 경로의 비전 인식은 RT-DETR + ViTPose (Apache-2.0)다. 서비스 경로에 YOLO를 넣자는 제안이 나오면 소스 공개 의무를 먼저 판단해야 한다. 🔴 이 건은 위 결정과 무관하게 그대로 남는다 — AGPL의 소스 공개 의무는 “상업적 이용”이 아니라 “네트워크로 서비스를 제공하는가”에 걸리므로, 매출 유무와 상관없이 서비스를 외부에 열면 바로 적용된다

지금 조치는 “설치하지 않는다”로 충분하지만, 결정이 코드가 아니라 배포 절차에만 적혀 있다. 다른 사람이 EC2에서 uv sync --extra tracking을 치면 조용히 넘어간다.

  • 상용 배포 시점에 (1) EXAONE 대체 모델 또는 상용 라이선스 (2) YOLO 사용 범위를 한 번에 정리한다. 둘 다 “지금 급하지 않지만 늦으면 비싼” 종류다
  • 담당: 박민호(PM 판단 필요) · 제기: 정상호 · 기한: 서비스 오픈 전

16. AWS 계정에서 ho가 IAM·할당량을 못 쓴다 (2026.09.02) ✅ 해소 (2026.09.03)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

17. S3에 영상이 올라와도 분석이 돌지 않습니다 — 큐를 소비하는 것이 없습니다 (2026.09.03)

버킷 supersub-ai 에 영상이 올라왔는데 리포트가 생기지 않는다는 것을 확인했습니다. 버그가 아니라 아직 안 만든 부분입니다.

사람/앱이 videos/ 에 업로드  ─┐
                             ├─  이 사이가 비어 있습니다
EC2에서 손으로 명령 실행     ─┘ → reports/ 에 JSON + 미리보기

reports/baseball_pitch_trim/ 이 있는 것은 09-03 GPU 검증 때 제가 그 명령을 직접 쳤기 때문입니다. videos/16a37c31-29e2-40e1-8ca0-4587525f90db/ 는 올라오기만 했고 아무도 명령을 안 쳐서 리포트가 없습니다.

트리거가 될 만한 것이 정말 없다는 것을 전수로 확인했습니다.

   
S3 이벤트 알림 없음 (버킷에 건 것은 90일 뒤 IA 로 내리는 수명주기 규칙뿐)
Lambda · 큐 컨슈머 · 워커 없음
EC2 의 폴링 서비스 없음. systemd 유닛은 supersub-vllm(판정 서버)·supersub-autostop(유휴 종료) 둘뿐
scripts/analyze_s3.py video 인자가 필수 — 어떤 영상을 볼지 사람이 지정해야 합니다

계약은 이미 이 자리를 예상해 두었습니다. api-contract.md 3-1절이 POST /videos → analysis_job_id(status: queued) → 에이전트 실행 → POST /analyses 로 그려 두었고 analysis_job 에 queued·running·succeeded·failed 까지 정의돼 있습니다. 그 queued 를 꺼내 가는 것이 없습니다.

정어진 님께 — 결정·구현을 부탁드릴 네 가지

  만족해야 할 성질
가. videos/ 키 규칙의 정본 지금 버킷에는 videos/<UUID>/… 가 있는데 계약 예시는 videos/2026/08/28/abc.mp4 입니다. 둘 중 무엇이 정본인지와 저 UUID 를 무엇이 올린 것인지를 알려 주세요. fastapi/app/analysis/.../video_orm.py 는 “저장소가 아직 정해지지 않았다(5장 ASM-003)” 상태이고 www/ 에서도 업로더를 못 찾았습니다
나. 큐를 소비하는 것 analysis_job 이 queued 로 남지 않고 실제로 실행될 것. 방식은 자유입니다 — S3 이벤트든, 백엔드가 EC2 에 요청하든, EC2 가 주기적으로 videos/ 와 reports/ 를 비교해 안 돈 것만 처리하든 상관없습니다. ✅ 셋째 방식은 2026.09.04에 만들어 두었습니다 — 아래 「제가 한 것」 참고. 🔴 다만 “백엔드가 EC2 에 요청”은 권하지 않습니다: 재시작마다 퍼블릭 IP 가 바뀝니다(이번에 실제로 겪었습니다)
다. 자동 종료와의 관계 인스턴스가 꺼져 있으면 큐가 쌓입니다. 깨울지·상시 켤지·모아서 배치로 돌릴지가 시간당 $0.647 이 걸린 비용 결정이라 배포 소유자 몫입니다. 지금 방어선은 자동 종료 하나뿐입니다. 2026.09.04에 값을 올렸습니다 — 유휴 90분/접속만 240분/최대 18시간(앞서 30/120/12라고 적은 것을 정정합니다). 🔴 새 워커를 만드실 때 BUSY_PATTERN도 함께 늘려 주세요 → 🔴 정정 (2026.09.08): 넣으면 폴링 프로세스 때문에 인스턴스가 영영 안 꺼집니다. 목적은 워커가 analyze_s3.py를 별도 프로세스로 부르는 것으로 이미 지켜집니다 (jin 18번 「BUSY_PATTERN 은 늘리지 않았습니다」)
라. 종목·동작을 함께 넘길 것 업로드가 어떤 종목의 어떤 동작인지를 실어 주셔야 합니다. 없으면 어느 루브릭으로 채점할지 정할 수 없습니다 (아래 「하지 말 것」의 기본값 함정과 같은 문제입니다)

✅ 제가 한 것 (정상호, 2026.09.04 · 커밋 b899e65)

🔴 폴더를 주면 아무것도 안 돌던 것을 고쳤습니다. 프론트가 videos/<UUID>/<파일>.mp4 로 올리는데 S3 에 폴더는 없습니다 — 콘솔이 폴더로 그리는 것은 키 접두사이고, 그것을 객체인 양 download 에 넘기면 “그런 키 없음”으로 죽습니다.

# 폴더를 그대로 줘도 됩니다
uv run python scripts/analyze_s3.py s3://supersub-ai/videos/<UUID>/ \
    --rubric rubrics/<루브릭>.yaml --out s3://supersub-ai/reports

실제 버킷에서 끝까지 확인했습니다 — videos/f6a49f2e-…/ 를 주고 안의 영상을 찾아 62점 리포트까지 올라갔습니다.

🗂 정정 두 건 (2026.09.07~09.08) — 없앤 플래그와 종료 코드 설명. 펼쳐 봅니다

🔴 위에 적었던 --skip-analyzed 를 없앴습니다 (2026.09.08)

앞서 이 자리에 “🔴 큐 소비의 최소 형태 — 리포트가 없는 것만 돕니다”로 --skip-analyzed 예시를 적었던 것을 정정합니다. 플래그는 지웠습니다.

큐를 소비하는 길이 둘이 되어서입니다(미결 jin 20번의 요청). 큐의 정본은 analysis_job 이고 그것을 집는 것은 scripts/worker.py 입니다. 둘을 같이 두면 워커가 집어 running 으로 돌리는 사이 스캔이 같은 영상을 또 돕니다 — reports/ 는 분석이 끝나야 생기기 때문입니다. GPU 시간이 두 배로 나가고 리포트가 둘 생기는데 어느 쪽도 오류로 보이지 않습니다.

폴더를 주는 것(위 첫 예시)은 그대로 둡니다 — 그건 큐가 아니라 사람이 범위를 지정하는 배치이고, 평가·재현에 계속 씁니다. 되살아나지 못하게 tests/test_worker.py::test_there_is_only_one_queue 가 검사합니다.

   
0바이트·영상 아닌 객체 뺍니다. videos/ 자체가 0바이트 객체이고, 대상 지정 박스를 사이드카로 올리면(18번) JSON 이 옆에 놓입니다
빈 폴더 🔴 버킷에 실제로 있습니다 (videos/2f3b04d9-…/). 콘솔에는 폴더로 보이는데 객체가 하나도 없습니다 — 업로드가 끊기면 그렇게 남습니다. 그때 무엇을 확인할지 메시지에 적어 둡니다
여러 편 한 편이 실패해도 나머지를 계속 돕니다. 품질 게이트(종료 코드 2)는 흔한데 거기서 멈추면 뒤가 영영 안 돕니다
🔴 새 스크립트를 안 만든 이유 BUSY_PATTERN 이 analyze_s3\|analyze\|measure\|track_overlay 만 봅니다 … 별도 스캐너 대신 analyze_s3.py 의 플래그로 넣었습니다 → 🔴 정정 (2026.09.08): 결국 별도 스크립트(scripts/worker.py)로 갔고 플래그는 지웠습니다. 자동 종료 문제는 워커가 analyze_s3.py 를 자식 프로세스로 부르는 것으로 풀렸습니다

리포트 자리도 고쳤습니다. 옛 규칙이 파일 stem 하나라 videos/<A>/clip.mp4 와 videos/<B>/clip.mp4 가 둘 다 reports/clip/ 으로 갔습니다. 이제 바로 위 폴더를 함께 씁니다 — reports/<UUID>/<파일>/<타임스탬프>.json. videos/ 바로 아래 파일은 옛 경로 그대로라 이미 올라간 reports/baseball_pitch_trim/ 은 안 떠내려갑니다.

  • 워커가 부를 인터페이스: 영상 한 편을 주면 품질 게이트에서 SystemExit(2) 가 그대로 올라갑니다. 폴더로 여러 편을 줄 때는 🔴 일부 실패해도 0 으로 끝납니다 — 한 편의 품질 미달로 워커가 실패로 표시되면 재시도가 무한히 돕니다. 몇 편 실패했는지는 마지막 줄에 찍습니다

🔴 바로 위 종료 코드 설명을 정정합니다 (2026.09.07)

“한 편이면 SystemExit(2) 가 그대로 올라간다”는 사실이 아니었습니다. main() 의 루프가 SystemExit 를 편수와 무관하게 삼키고 있어서 한 편짜리 호출도 종료 코드가 0 이었습니다. 주석은 “그대로 올라간다”고 말하고 있었는데 코드가 달랐고, 그 주석을 읽고 위 문장을 썼습니다.

이 값을 믿고 워커를 만들면 품질 게이트에 걸려 리포트가 없는 분석이 전부 succeeded 로 보고됩니다 — 큐는 줄어드는데 결과가 없습니다.

analyze_s3.py 를 고쳤습니다(한 편이면 올리고, 접두사 스캔은 지금처럼 0). 되살아나지 못하게 tests/test_worker.py 에 검사 두 개를 두었고 — test_a_single_video_propagates_its_exit_code · test_a_folder_scan_still_ends_zero_when_some_clips_fail — 고치기 전 코드에 실제로 걸리는 것을 확인했습니다. 자세한 것은 jin 18번에 적었습니다.

  • 야구 타격 루브릭이 없는 것은 제 몫입니다(미결 3번). 아래 참고

🔴 하지 말 것

  • EC2 에 videos/ 쓰기 권한을 주지 마세요. 지금 IAM 정책이 읽기 전용인 것이 의도입니다 — “영상 업로드는 사람이 하고 EC2 는 읽기만 한다”(agent/deploy/README.md 2-A). EC2 가 털려도 원본을 못 지웁니다
  • --rubric 기본값에 기대지 마세요. 기본이 football_instep_shot.yaml(축구 인스텝 슈팅)이라, 안 주면 야구 영상을 축구 루브릭으로 채점합니다. 워커를 만드실 때 반드시 명시하도록 해 주세요
  • 야구 “타격”은 지금 돌리셔도 의미가 없습니다. 앞서 “타격 루브릭이 없다”고 적은 것을 정정합니다 (2026.09.04). rubrics/baseball_batting.yaml이 생겼습니다 — status: draft이지만 --rubric 경로로 주면 스크립트는 그대로 돕니다(status를 보는 곳은 GET /rubrics 뿐입니다). 다만 투구 루브릭을 타격에 쓰는 것은 여전히 안 됩니다 — 미결 3번이 기각한 오류이고, 두 동작은 임팩트 정의부터 다릅니다. 타격 영상에는 반드시 baseball_batting.yaml 을 주십시오
   
확인 aws s3 ls s3://supersub-ai/videos/ --recursive 와 .../reports/ --recursive 의 대상이 맞아떨어지면 된 것입니다. 트리거 유무는 aws s3api get-bucket-notification-configuration --bucket supersub-ai — 지금은 비어 있습니다
급한 정도 막고 있는 것은 없습니다. 손으로 돌리면 리포트가 나오고, GPU 검증도 그렇게 끝냈습니다. 다만 시연에서 “올리면 결과가 나온다”를 보여주려면 이것이 있어야 합니다
  • 지금 손으로 돌리는 방법: agent/deploy/README.md 6절 · uv run python scripts/analyze_s3.py s3://supersub-ai/videos/<키> --rubric rubrics/<파일> --out s3://supersub-ai/reports
  • 적재까지 이어지려면 jin 구역 1번(분석 결과 적재 규격) 이 먼저 풀려야 합니다 — metric_definition 이 비어 있으면 POST /analyses 가 외래키에서 거부됩니다

    답변 (2026-09-04, 정어진)

가·라는 답을 드리고, 나는 방식을 제안하고, 다는 결정하실 것만 정리했습니다.

가. 키 규칙 정본 — videos/<user_id>/<uuid>.<ext> 입니다 ✅

코드가 정본입니다. 사전 서명 URL 을 낼 때 서버가 키를 만들고, 등록할 때 소유까지 확인합니다.

# app/analysis/application/use_cases/video_interactors.py
storage_key = build_storage_key(command.user_id, extension)   # videos/<user_id>/<uuid>.<ext>
...
if not owns_key(command.user_id, command.storage_key):        # 남의 접두사면 거부
  • videos/16a37c31-…/ 의 앞 UUID 는 업로더의 user_id 입니다. 파일명 UUID 는 서버가 붙인 것이라 클립마다 다릅니다
  • videos/2026/08/28/abc.mp4 는 폐기된 초안입니다. 3-1절에 「옛 초안 / 지금」 대조표가 있고 “정본은 3-6절” 이라고 적혀 있습니다 — 3030c94(09-03 10:08)에 들어갔고 main 에도 있습니다. 그 표의 왼쪽 칸을 보시면 지금 말씀하신 예시라, 헤더 없이 보면 현행처럼 읽힙니다. 대조표라는 것만 알려 드립니다
  • “www/ 에서 업로더를 못 찾았다”도 맞습니다. 웹·앱 어느 쪽도 아직 안 붙였고, 그건 미결 jin 12번으로 백성검 님께 올려 둔 자리입니다. 그 UUID 는 제가 09-03 에 서버에서 끝에서 끝까지 확인하며 올린 것입니다
라. 종목은 이미 실립니다 — 동작이 없습니다 🔴

POST /videos 가 sport_code 를 받아 video.sport_code 에 넣습니다. 그런데 sport_code 만으로는 루브릭을 고를 수 없습니다. 실물을 세어 봤습니다.

종목 있는 루브릭 고를 수 있나
baseball baseball_pitching ✅ 하나뿐이라 결정된다
basketball basketball_jump_shot · basketball_layup ❌ 둘 중 무엇인지 모른다
football football_instep_shot · football_inside_pass ❌ 둘 중 무엇인지 모른다

그래서 동작을 담을 자리가 필요한데 부록 D 에 없습니다 — video 는 user_id·sport_code 뿐이고 analysis_job 도 상태·시각뿐입니다. ERD 를 고치는 결정이라 따로 올렸습니다 → jin 구역 17번.

🔴 정정 (같은 날 늦게) — 지금은 막히지 않습니다

바로 위에서 “갈리는 종목은 실행하지 말고 failed 로 남기겠다”고 썼는데, 루브릭 파일을 더 읽어 보니 과한 판단이었습니다. status 필드가 있고 종목당 active 가 하나씩입니다.

종목 active draft
baseball baseball_pitching —
basketball basketball_jump_shot basketball_layup
football football_instep_shot football_inside_pass

scoring.py 가 status 를 “사용자에게 내보낼지 여부 — active만 선택지에 오른다” 로 정의해 두셨으니, active 인 것 하나를 고르면 종목만으로 정해집니다. 그래서 세 종목 다 지금 돌릴 수 있습니다.

워커에 넣을 규칙은 이렇게 바꿨습니다.

그 종목의 active 루브릭이 정확히 하나면 그것을 쓰고, 0 개거나 2 개 이상이면 실행하지 말고 failed 로 보고한다.

draft 를 승격시키시면 그 종목이 둘이 되어 그때 멈춥니다 — 조용히 아무거나 고르는 것보다 낫다고 봤습니다. 미결 jin 17번(동작 컬럼)이 풀리면 이 규칙은 필요 없어집니다.

jin 17번은 그대로 둡니다 — 지금 안 막힐 뿐 루브릭이 늘면 다시 막히고, “어떤 동작을 올린 것인지”는 사용자가 정하는 게 맞습니다. 다만 급하지 않다고 등급을 낮춥니다.

나. 큐 소비 방식 — 워커가 백엔드를 폴링(pull)하는 쪽을 제안합니다

“방식은 자유”라고 하셔서 넷을 견줬고, S3 이벤트·백엔드 push 보다 pull 이 낫다고 봅니다.

왜  
GPU 인스턴스가 자동 종료된다 push 는 대상이 꺼져 있으면 실패합니다. pull 이면 켜질 때 밀린 것을 가져갑니다
루브릭을 고르려면 종목·동작이 필요한데 그건 DB 에 있다 S3 만 비교하는 방식(videos/ vs reports/)은 그걸 알 수 없습니다
analysis_job 이 이미 계약의 정본이다 S3 비교는 이 상태를 우회해 진실이 둘이 됩니다
nginx 60초에 안 걸린다 오래 도는 쪽이 워커고 API 는 짧게 답합니다. 동기 호출로 만들면 proxy_read_timeout 기본 60초에 끊깁니다
videos/ 쓰기 권한이 필요 없다 그쪽 「하지 말 것」을 그대로 지킵니다. 워커는 videos/ 읽기 + reports/ 쓰기만

붙일 것은 둘입니다.

  • POST /internal/analysis-jobs/claim — queued 하나를 running 으로 바꾸고 {job_id, storage_key, sport_code, motion, side} 를 돌려줍니다. 🔴 한 문장(UPDATE … WHERE status='queued' … RETURNING)으로 집습니다 — 읽고 쓰기를 나누면 워커 둘이 같은 것을 집습니다
  • PATCH /internal/analysis-jobs/{id} — succeeded/failed + failure_reason

워커 자격 증명은 정하셔야 할 것이 하나 있습니다 — 사람 토큰이 아니라 기계용이 필요합니다. 공유 시크릿 헤더가 제일 작고, 그 자리를 .env 로 넣겠습니다.

종료 코드 계약은 기다리겠습니다. 지금 확인한 것은 품질 게이트에서 SystemExit(2) 라는 것뿐입니다(analyze_s3.py). 정리해 주시면 그걸 그대로 failure_reason 에 매핑하겠습니다.

다. 자동 종료·비용 — 결정하실 것만 정리했습니다 (배포 비용 소유자)

pull 방식이면 꺼져 있어도 큐가 안전하게 쌓입니다. 그래서 급한 결정은 아닙니다.

안 월 비용(온디맨드 기준) 메모
A. 지금처럼 수동으로 켜서 배치 쓴 만큼 시연 전까지는 이것으로 충분하다고 봅니다
B. 상시 켜기 약 $466 ($0.647 × 24 × 30) “올리면 곧 결과”가 되지만 비쌉니다
C. 큐가 쌓이면 깨우기 A + 깨우는 장치 EventBridge·Lambda 를 새로 만들어야 합니다

제 의견은 A 입니다. 자동 종료(유휴 30분/접속만 120분/최대 12시간)를 그대로 두고, 시연 일정이 잡히면 그때 B 로 올렸다 내리면 됩니다.

지금 제 쪽에서 막고 있는 것

적재(POST /analyses)는 미결 jin 1번이 풀려야 합니다 — metric_definition 이 0 행이라 지금 만들면 외래키에서 전부 거부됩니다(방금 확인했습니다). 그래서 워커를 만들어도 reports/ 에 JSON 을 남기고 analysis_job 상태를 옮기는 데까지입니다. 그것만으로도 “올리면 결과가 나온다”는 보여집니다.

「나」는 양쪽이 다 찼습니다 (2026.09.07)

백엔드 claim/PATCH(정어진, f7ea780) + 워커 폴링 루프(정상호, scripts/worker.py) — 상세는 jin 18번입니다. 남은 것은 WORKER_TOKEN 값을 받아 EC2 에 설치하는 것이고, 그 전까지는 이 항목이 말하는 상태 그대로입니다 (올려도 안 돕니다). 「다」(비용·상시 가동 여부)는 그대로 열려 있습니다.

  • 담당: 정어진(분석 파이프라인·배포) · 제기: 정상호 · 기한: 스프린트 3 (시연 일정이 앞당겨지면 그때 다시 이야기해 주세요)

18. 프론트에 분석 대상을 찍는 UI가 생겼는데 백엔드가 안 받는다 (2026.09.04) — 줄이 이어졌습니다 (2026.09.09)

📋 판독 ① 준비 완료 — 사람이 채워 주셔야 합니다 (2026.09.14)

명세 agent/eval/pending18_subject/AFTER_LABELS.md(커밋 d9eca0c, 라벨을 받기 전에 굳혔습니다) · 자료 생성 make_packet.py(커밋 b7ffe8f) · 채점 subject_stats.py(명세와 같은 커밋).

🔴 먼저 정정합니다 — 이것은 10회차의 「사람 판독 70장」이 아닙니다

14회차 결과에 “(라) 사람 판독 70장(10회차의 (A)) … (다)의 「공 쪽이 맞다」도 이것으로만 선다” 고 적었는데 틀렸습니다.

  10회차의 (A) 판독 ①
표본 phaseA 야구, 안 끝난 7구간 SoccerNet 축구 방송 25클립
경로 사람이 지정한 대상의 추적 자동 선택(_largest_person_box)
묻는 것 「잃은 구간에 대상이 보이는가」 「이 프레임의 분석 대상은 누구인가」

장수만 같고 다른 일입니다. 10회차의 (A)는 그대로 열려 있습니다.

무엇이 막혀 있어서 이 판독이 필요한가

11~14회차가 잰 M 은 「우리가 고른 사람 = 공 최근접 후보」인 빈도이고 12~18% 로 낮습니다. 🔴 그 숫자는 (가) 우리 규칙이 틀려서인지 (나) 공 근접이 애초에 대상을 안 가리켜서인지 말하지 못합니다. 공 근접은 라벨이 아니라 물리적 개연성이라 정답이 있어야 갈립니다.

자료
   
어디에 /mnt/d/sports_dataset_probe/review_packet_18/ — 🔴 저장소에 없습니다(방송 프레임)
무엇이 이미지 70장 · subject_form.csv · INSTRUCTIONS.md
구성 25클립 · 층 A 27장 · 층 B 43장 · 후보 수 중앙 10
채울 것 subject(후보 번호 · none · unclear) · confidence(high/low) · note
시간 한 장 30초, 전부 30~40분

🔴 none(후보 중에 대상이 없다 = 검출 문제)과 unclear(화질·가림 때문에 모르겠다 = 판독이 성립 안 함)를 갈라 달라고 안내에 적었습니다. 뭉뚱그리면 어느 쪽인지 영영 알 수 없습니다.

앵커링을 막으려고 뺀 것 셋

🔴 공을 안 그렸습니다(판정하려는 신호라 그리면 「공 옆 사람」을 고르게 됩니다) · 🔴 박스는 전부 같은 색·같은 굵기입니다(우리가 고른 것을 강조 안 함) · 🔴 번호는 화면 왼쪽부터입니다(면적 순도 공 거리 순도 아닙니다 — 그러면 번호가 힌트가 됩니다). 참조값은 판독 폴더가 아니라 저장소에 둡니다.

🔴 AI 가 채우면 정답이 아닙니다

로드맵 2절·B-3~B-5 선례 그대로입니다. 사람이 채운 것만 씁니다. 🔴 두 분 이상이 같은 70장을 보시면 훨씬 좋습니다 — 한 분만 보시면 「사람끼리 얼마나 일치하는가」를 못 내고 그 한계가 결과에 남습니다. 관련: 미결 2번(라벨링 주체 — 이 항목의 오랜 병목입니다).

🗂 지난 회차 기록 — 11~14회차와 그 앞의 조사·정정·닫힌 길 (2026.09.04~09.14). 펼쳐 봅니다

🔴 14회차 (규칙 비교) — 아무 후보도 못 넘었습니다. 높이는 오히려 더 나쁩니다 (2026.09.14)

사전 등록 agent/eval/pending18_rule/PREREGISTRATION.md(커밋 97c37e7) · 결과 같은 폴더 RESULTS.md. 🔴 src/ 0줄 — 구현이 아니라 비교라 이미 검출된 박스 위에서 규칙만 갈아 끼웠습니다.

규칙 층 A (고르는 데 씀) 층 B (확인 전용)  
R0 argmax(area) — 현행 17.7% 12.1% 대조군
R1 argmax(height) 9.1% (−8.6%p) 8.3% (−3.8%p) 🔴 더 나쁩니다
R2 누운 박스 제외 19.7% (+2.0%p) 11.4% (−0.8%p) 못 넘음

WIN_MARGIN 10%p 를 넘은 것이 없습니다. 기준 A(층 A M(R0) 17.7% 재현) · B(층 B 관문 16편) · C(LIE 민감도) · D(src/ 0줄) · E(pytest 413) · F⑵(단일후보 152/152) 전부 통과.

🔴 R1 은 「못 넘은」 게 아니라 더 나쁩니다
  층 A 층 B
R1 이 R0 와 같은 박스를 고른 비율 47% 45%
R1 이 살린 / 죽인 프레임 8 / 25 9 / 19

절반이 넘는 프레임에서 다른 사람을 고르는 큰 변화인데 거래가 3:1 손해 이고 두 층에서 같은 방향입니다.

🔴 그래서 13회차 진단을 다시 읽어야 합니다
주장 상태
「고른 박스가 넓고, 그 폭은 자세에서 온다」 ✅ 그대로 섭니다 (13회차)
「면적이 나쁜 기준이라 바꾸면 낫다」 🔴 안 따라옵니다. 바꿔 보니 더 나빠졌습니다

두 문장이 다 필요합니다. 앞만 남기면 「면적을 버리면 된다」로 읽고, 뒤만 남기면 왜 면적이 이상한 것을 고르는지를 잃습니다.

✅ 보지 않은 층이 실제로 일했습니다

R2 의 부호가 뒤집혔습니다 — 층 A +2.0%p → 층 B −0.8%p. 층 A 만 봤으면 「작지만 이긴다」로 적었을 값입니다. ✅ 그리고 현상 자체가 재현됐습니다 — 한 번도 안 본 16클립 264프레임에서도 M(R0) 12.1% 로 낮습니다. 12회차 값이 그 9편에 맞춰진 것이 아닙니다.

🔴 제 예측이 절반 틀렸습니다 · 사전 등록에 빈칸이 있었습니다

R2 는 맞혔고(상한 5%p) R1 은 방향을 틀렸습니다 — 「높이로 보면 대등해진다」를 근거로 들었는데 대등해지는 것과 우리 쪽이 뒤집히는 것은 다른 말이었습니다. ✅ 13회차에서 반성한 대로 예측의 출처를 적어 둔 덕에 무엇이 틀렸는지 짚을 수 있었습니다.

🔴 그리고 사전 등록이 R2 의 「모든 후보가 빠지는 프레임」 동작을 안 정해 두었습니다. R0 로 되돌아가게 정했고(커버리지 0 은 7회차가 닫은 경로입니다) 결과를 보기 전에 정했지만, 빈칸이었던 것은 사실입니다 — 다음 사전 등록에 「규칙이 후보를 비울 수 있으면 그때의 동작을 함께 적는다」를 넣습니다.

다음 한 걸음 — 🔴 기하로 고치는 길을 닫습니다

높이는 더 나쁘고 누운 박스 제외는 5% 만 건드려(R0 와 95~96% 동일) 아무것도 안 바꿉니다. 15회차(구현)는 열지 않습니다 — 구현할 것이 없습니다. 남은 것은 공 근접을 선택에 넣기(그전에 「공 쪽이 맞다」를 세워야 합니다)와 사람 판독 70장뿐이고, 후자가 전자의 선행입니다.

🔴 면적이 좋다는 뜻이 아닙니다 — M(R0) 가 12~18% 입니다. 셋 다 대부분 틀리고 그중 면적이 덜 틀릴 뿐입니다.

✅ 13회차 (넓은 박스) — 겹침이 아니라 「자세」입니다. 뿌리는 선택 규칙입니다 (2026.09.14)

사전 등록 agent/eval/pending18_wide/PREREGISTRATION.md(커밋 205dd01) · 결과 같은 폴더 RESULTS.md. 🔴 src/ 0줄 · 11·12회차를 import 했고 층이 163프레임으로 12회차와 같습니다(기준 A).

12회차가 「면적이 폭에 끌린다」까지 좁혔고 폭의 이유를 안 갈랐습니다. 거기서 처방이 갈립니다 — 겹침이면 검출 후처리, 자세면 선택 규칙입니다.

계기는 IoA(고른 박스 안에 다른 후보가 얼마나 들어 있는가)입니다. 🔴 IoU 가 아닙니다 — IoU 는 분모에 큰 박스가 들어가 삼킨 경우를 구조적으로 낮게 만듭니다.

기준 결과  
A 12회차와 같은 층(163f) 163f ✅
B 🔴 계기 사각지대 — 안 삼켰는데 w/h≥0.75 36/163 (22.1%) 보고
C src/·rubrics/ 무변경 0줄 ✅
D SWALLOW 네 값에서 판정 불변 전부 (자세) ✅
E pytest -q 413 통과 ✅

S = 12.3%(20/163) — 넓은 박스가 다른 후보를 삼키는 것은 여덟에 하나 이고 IoA 최대값 중앙은 0.19(스치는 정도)입니다. 문턱을 0.5까지 내려도 27.6% 라 문턱에 안 업혀 있습니다.

🔴 사각지대를 최악으로 쳐도 안 뒤집힙니다

기준 B 의 36프레임을 전부 겹침으로 쳐도 (20+36)/163 = **34.4%** 로 자세가 여전히 다수(65.6%) 입니다. 🔴 사각지대를 세어 두지 않았으면 이 문장을 못 씁니다 — 사전 등록에 계기의 사각지대를 미리 적어 둔 값입니다.

🔴 제 예측이 두 회차 연속 틀렸습니다

「둘 다(30~70%)」를 예측했는데 12.3% 입니다. 근거로 적은 “w/h 중앙 0.61 은 선 사람보다 넓지만 두 사람 폭(≥1.0)엔 못 미친다” 는 전제는 맞았고 결론이 틀렸습니다 — 0.61 은 겹침이 섞여 있다가 아니라 거의 없다는 뜻이었습니다(w/h ≥ 1.0 은 8/163).

🔴 집계값 하나로 기전을 추정하는 제 습관이 문제입니다. 12회차도 같은 형태였습니다. 앞으로 예측을 적을 때 그 예측이 어떤 분포에서 나왔는지를 함께 적습니다(중앙값 하나가 아니라 꼬리까지). 다만 예측은 옆에 붙여 둔 것이라 판정에는 안 들어갔습니다.

눈으로 본 것 — 🔴 판정에 안 씁니다

가장 넓은 6개를 그려 보니 셋이 땅에 눕거나 엎드린 선수였습니다 (w/h 3.39 = 슬라이딩/넘어짐). 면적으로 고르면 누운 사람이 이기고, 누운 사람은 공을 다루고 있을 리가 거의 없습니다. 🔴 6프레임 인상이지 측정이 아닙니다 — 처방 회차에서 수치로 고정해 전수로 셉니다. (그림은 /tmp 에서만 봤습니다 — 방송 프레임은 저장소에 안 넣습니다.)

다음 한 걸음 — 🔴 처방인데, 「비싸다」는 제가 과장했습니다

「면적 → 높이」가 곧장 떠오릅니다. 여기에 selector 동작 기준이라 B-1~B-6 이 전부 무효라고 적었는데 🔴 정정합니다 (같은 날, 14회차 사전 등록 1절) — eval_b2/eval_b2.py:94 와 eval_selectors.py:93 의 baseline 모드가 argmax(area) 라 production 과 같은 규칙이고, 바뀌는 것은 「baseline 이 곧 제품이다」라는 대응 하나입니다.

   
✅ 안 죽습니다 B-1~B-5 의 A vs B 결론(A·B 사이의 비교이고 검정 표본도 A·B 불일치 프레임에서 뽑혔습니다) · 🔴 B-3~B-5 판독 라벨(다시 못 받는 유일한 자산인데 이 변경에 안 걸립니다)
🔴 다시 돌릴 것 B-6 244초(eval_b6/RERUN.md 실측) · B-2 약 6분 — 합쳐 10분 남짓

🔴 그래도 순서는 안 바꿉니다. 이유가 「비싸서」가 아니라 「어느 규칙이 나은지 아직 모르기 때문」으로 바뀔 뿐입니다. 14회차는 구현이 아니라 오프라인 비교로 세웠습니다(eval/pending18_rule/PREREGISTRATION.md, 커밋 예정) — src/ 를 안 고치고 이미 검출된 박스 위에서 규칙만 갈아 끼워 R0(면적·현행)·R1(높이)·R2(누운 박스 제외)를 견줍니다. 🔴 보지 않은 층(SoccerNet 41~100번)을 확보하고 시작하고, 거기서는 규칙을 고르지 않습니다 — 같은 층에서 기전을 세우고 같은 층에서 이기는 규칙을 고르면 순환입니다. 🔴 짝 기준으로 「M 이 올라도 고른 박스의 키포인트 신뢰도가 10%p 넘게 떨어지면 이겼다고 적지 않는다」를 박아 두었습니다 — 공에는 가까운데 자세를 못 재는 사람을 고르게 되면 채점이 망가집니다.

🔴 12회차 (다인 방송) — 판별불가입니다. 기전이 원근이 아니라 「폭」이었습니다 (2026.09.14)

사전 등록 agent/eval/pending18_broadcast/PREREGISTRATION.md(커밋 2a9373c) · 결과 같은 폴더 RESULTS.md. 🔴 src/·rubrics/ 0줄(조사 회차) · 11회차 스크립트를 고치지 않고 import 했습니다(복제하면 두 회차의 계기가 조용히 갈라집니다 — 미결 10번의 형태).

표본이 서니 회차가 성립했습니다 — 주 층 198프레임 · 9클립(11회차는 1프레임 · 0클립). 표본은 46번이 확보한 SoccerNet 방송 40편입니다.

기준 결과  
A 주 층에 단일후보 0건 0건 ✅
B 🔴 계기 — ±1px 섭동에 argmin flip <20% 0.0% (0/198) ✅
C src/·rubrics/ 무변경 0줄 ✅
D reach 주 층 198/1,924 (10.3%) · 클립 9/40 —
E NEAR 네 값에서 판정 구간 불변 전부 같은 쪽 ✅
F pytest -q 413 통과 ✅
G 🔴 짝 기준 — 불일치 높이비 중앙 ≥2.0 1.19 🔴

M 17.7%(35/198) · 클립 가중 중앙 8.3% · 불일치 margin 중앙 1.687 키높이(문턱 0.25 의 6.7배). (나)의 두 조건은 다 섰습니다. 🔴 그런데 G 가 깨졌고, 사전 등록이 그 경우를 「판별불가로 내린다」고 미리 적어 두었습니다. 그대로 합니다.

🔴 제 예측이 두 겹으로 틀렸습니다

사전 등록에 「(나)가 나올 것」이라 박았는데 판별불가이고, 더 중요하게 기전이 틀렸습니다. 저는 “방송 구도에서 「가장 큰 사람」 = 「카메라에 가장 가까운 사람」” 이라고 적었는데, 불일치 프레임의 높이비가 1.19(p25 1.04) 로 거의 같은 크기입니다.

🔴 그 이야기는 관문을 돌린 뒤 프레임 하나를 눈으로 보고 세운 것입니다. 사전 등록에 「나는 이미 봤다」고 적고 바를 1.2 → 2.0 으로 올렸는데, 정직하게 말하면 그 조정은 결과를 안 바꿨습니다 — 1.19 는 1.2 에서도 불통과입니다. 막은 것은 바가 아니라 G 를 넣어 둔 것 자체입니다.

✅ 진짜 기전 — 면적이 폭에 끌립니다
  고른 박스 최근접 후보
가로세로비 w/h 0.61 0.50
면적비 / 높이비 1.71 / 1.19  

_largest_person_box 는 면적으로 고르는데 다인 화면에서 면적을 키우는 것은 키가 아니라 폭입니다. 고른 박스가 그 프레임에서 가장 넓은 박스인 비율이 64%(105/163)입니다. 「거의 동률이라 임의」는 부차적입니다 — 최대 면적의 10% 안에 2명 이상인 프레임이 32% 뿐이고 중앙은 1명입니다.

🔴 폭이 넓은 이유는 안 갈랐습니다 — 팔다리를 벌린 자세 · 달리는 선수 · 겹친 사람 둘을 한 박스로 잡은 것 중 어느 쪽인지. 13회차의 질문이고 거기서 처방이 갈립니다: 겹침이면 검출 후처리(NMS·병합) 문제이고 자세면 선택 규칙 문제입니다.

🔴 말하지 않는 것 — 결과에도 그대로 옮겨 두었습니다
  • 제품 성능이 아닙니다. 방송 중계이고 제품 입력은 휴대폰입니다
  • 채점을 안 물었습니다. 표본 전체 사람 키 중앙이 28px 라 박스만 썼습니다
  • 「공 쪽이 맞다」가 아닙니다. 공 근접은 라벨이 아니라 물리적 개연성입니다
  • 🔴 주 층은 카메라가 가까운 프레임에 몰립니다(고른 박스 높이 중앙 86px) — 공이 검출되려면 가까워야 해서입니다. 기준 B 가 0.0% 인 것도 그 덕이고, 「28px 에서 계기가 선다」는 뜻이 아닙니다
다음 한 걸음 — 🔴 처방이 생각보다 비쌉니다

「면적 대신 높이로 고르자」가 곧장 떠오르는데, 그건 _largest_person_box 의 선택 규칙이고 selector 동작 기준이라 B-1~B-6 이 전부 무효가 됩니다. 공짜가 아닙니다. (가) 폭이 넓은 이유를 가르는 조사 회차를 먼저 돌립니다 — src/ 무변경이고 그 답이 처방을 정합니다.

🔴 11회차 (공 근접) — 판별 불가입니다. 계기는 섰는데 표본에 현상이 없습니다 (2026.09.14)

사전 등록 agent/eval/pending18_ball_proximity/PREREGISTRATION.md(커밋 9680a3b, 측정 스크립트를 쓰기 전에 굳혔습니다) · 결과 같은 폴더 RESULTS.md. 🔴 조사 회차라 src/·rubrics/ 0줄 · GPU 는 검출만 씁니다.

물은 것: 공이 누군가의 발치에 있는 그 순간에, 그게 우리가 고른 사람인가. 아래 「11회차를 열기 전에」가 적어 둔 대로 표본을 축구 19편으로 옮겼고, 45번 1회차가 못 갈랐던 (가) 공이 날아가는 중 대 (나) 딴 사람을 보고 있다를 가르려 했습니다.

주 층이 19편 전체에서 1프레임입니다. 클립 최소치(5프레임)를 넘은 클립이 없어 분모가 0편입니다. 🔴 문턱을 옮겨 판정을 만들지 않았습니다.

기준 결과  
A 계기① 주 층에 단일후보 0건 0건 ✅
B 계기② 45번 1회차 발목 기반과 순위 상관 ρ≥0.7 ρ=0.835 (n=19) ✅
C src/·rubrics/ 무변경 0줄 ✅
D reach 보고 주 층 1/891 (0.1%) · 클립 0/19 🔴
E NEAR 네 값에서 판정 구간 불변 전부 판별불가 ✅※
F pytest -q 413 통과 ✅
G 짝 기준(조건부) 미적용 — M 을 못 냅니다 —
🔴 왜 비었나 — 셋을 차례로 배제했습니다
   
자(scale)가 아닙니다 최근접 후보 키 ÷ 클립 중앙 키가 중앙 0.99. 후보 자기 키로 재면 발치 프레임이 58 → 38로 줄어듭니다
🔴 PERSON_ELIGIBLE_THRESHOLD(0.5)도 아닙니다 검출 바닥 0.3 까지 풀어도 후보 수 중앙 1·평균 1.44, ≥2명인 프레임 24%. 4·5회차의 「0.48 이 0.02 차이로 걸린다」 형태가 여기서는 아닙니다
🔴 눈으로 봤습니다 19/19 가 1인 훈련 영상이고 사람이 화면 높이의 46~82%(중앙 54%)를 차지합니다. 프레임 비율도 사람 주위로 잘려 있습니다(630×674·908×720). 🔴 OpenPose 스켈레톤이 픽셀에 구워져 있습니다

「여럿 중 누구를 고르는가」라는 질문이 이 표본에서 성립하지 않습니다. 계기가 틀린 것이 아니라 물어볼 대상이 없습니다.

※ E 의 ✅ 는 공허합니다 — 층이 어디서도 안 서서 모든 값이 같은 답을 낼 수밖에 없었습니다. 기준 문구에 「층이 성립한다면」이 빠져 있었습니다.

🔴 A 가 이 회차의 전부를 말해 줍니다. 발치 프레임 58건 중 57건(98%)이 단일후보였고 A 가 그것을 걷어냈습니다 — A 가 없었으면 「일치율 98%」가 결론으로 남을 뻔했습니다. (B-1 라벨 97건 중 39건(40.2%)이 같은 오염이었고, 그래서 넣은 기준입니다.)

✅ 하나는 섰습니다 — 계기 B

「박스 하단 중앙 ↔ 공」이 「발목 ↔ 공」의 대리로 섭니다(ρ 0.835). 45번 1회차는 대상 한 명에게 ViTPose 를 돌려야 이 값을 얻었는데, 이 계기는 검출 결과만으로 같은 순위를 냅니다 — 후보 전부에 대해 잴 수 있고 더 쌉니다(클립당 13초 → 5초). 표본만 바뀌면 그대로 씁니다.

🔴 정정 — 45번 1회차의 진단이 틀렸습니다

그 회차는 “이 표본은 중계 영상이고 「가장 큰 사람」은 골키퍼·수비벽· 심판일 수 있다” 고 적었습니다. 둘 다 틀렸습니다 — 1인 훈련 영상이고, 다른 사람이 애초에 화면에 없습니다. 그러면 그 회차가 본 큰 공-발 거리는 「딴 사람을 골라서」가 아니라 한 사람뿐인데 그 사람이 공에서 떨어져 있는 것이고, 세트피스 준비 자세가 그렇습니다.

🔴 그 회차의 절대 크기도 믿을 값이 아니었습니다. 순위는 삽니다(ρ 0.835). 그런데 어깨너비 ≈ 키의 0.25 로 환산해 맞추면 배율이 0.8~7.6(중앙 2.2) 으로 흩어집니다 — 순수 단위 변환이면 상수여야 합니다. 어깨너비는 촬영 방향에 따라 투영 폭이 달라지고(이 표본은 대부분 측면) 그것으로 나눈 거리가 부풀어 오릅니다. 미결 37번과 같은 뿌리입니다. 그래서 1회차의 “4.82 어깨너비(≈2m)나 떨어질 수 없다” 는 논증은 약해집니다 — 🔴 1회차의 판정(A·B 불합격)은 그대로이고, 흔들리는 것은 뒤에 붙인 설명입니다.

🔴 표본 결함 — 네 회차가 이 표본을 썼습니다
회차 영향
43번 ㉮ 1회차 (종목 게이트) 판정은 그대로 섭니다(게이트는 배트·라켓을 봅니다). 🔴 다만 그 회차가 남긴 한계 “표본이 방송·데이터셋 영상” 이 축구 층에 대해 틀렸습니다 — 1인 훈련 영상이고 스켈레톤이 그려져 있습니다. 한계에 그 줄을 더해야 합니다
45번 1회차 (터치) 위 정정
45번 2·3회차 (공 이벤트·연속성) 🔴 공만 보는 회차라 사람 수와 무관합니다. 판정은 안 흔들립니다
18번 11회차 분모 0편

🔴 더 큰 것: 다인 축구 표본이 우리에게 없습니다. phaseA 39편은 야구이고(다인 10건이 있지만 야구입니다), 축구 19편은 1인입니다. 제품은 축구 단일 종목(39번)이 됐는데 「여러 명 중 고르기」를 축구에서 잰 적이 한 번도 없습니다.

사후 관찰 — 🔴 판정에 안 씁니다

자와 무관한 약한 질문(공 보임 + 후보 2명 이상, 73프레임·3클립)에서는 일치 53.4% · 불일치 margin 중앙 0.35 키높이 · 🔴 불일치 시 고른 사람 키 ÷ 최근접 후보 키 중앙 6.77. 53.4% 는 등록한 30~70% 폭의 한가운데라 어느 쪽도 아니고, G 는 주 층에서 재기로 등록돼 있었으므로 통과했다고 적지 않습니다.

다음 한 걸음 — 처방이 아니라 표본입니다

🔴 12회차를 알고리즘으로 열지 않습니다. 이 회차가 보인 것은 「공 근접이 안 통한다」가 아니라 「이 표본으로는 물어볼 수 없다」입니다.

필요한 축구 클립의 조건 (안 적으면 또 안 맞는 자료가 옵니다): ⑴ 한 프레임에 eligible 사람 2명 이상인 프레임이 클립당 5프레임 이상 ⑵ 공이 누군가의 발치에 오는 구간(드리블·패스 주고받기) ⑶ 🔴 스켈레톤·박스·자막이 안 그려진 원본 ⑷ 휴대폰 촬영이면 더 좋습니다 — 제품 입력이 그것입니다.

phaseA 야구 다인 10건으로 대신할 수 없습니다(「공이 발치에」는 축구의 물리입니다). 10회차가 남긴 (A) 사람 판독 70장은 표본 문제와 무관하게 여전히 열려 있습니다.

🔴 배운 것

「계기가 재려던 것을 재는가」 위에 한 칸이 더 있습니다 — 「표본에 그 현상이 있는가」. 8회차는 계기가 다른 양을 쟀고, 10회차는 계기가 현상의 2.6% 만 봤고, 11회차는 계기가 맞았는데 현상이 표본에 없었습니다. 사전 등록에 넣었어야 할 한 줄은 「착수 전에: 이 표본에서 후보 2명 이상인 프레임이 몇 %인가. 10% 미만이면 재기 전에 표본을 바꾼다」이고, 그 값(8%)은 GPU 100초면 나왔습니다. 12회차 사전 등록에 이 관문을 넣습니다.

🔴 11회차를 열기 전에 — 표본부터 다시 정해야 합니다 (2026.09.11)

45번(다중 이벤트) 1회차를 돌리다 이쪽으로 흘러들어와 두 가지가 드러났습니다.

⑴ 새 단서가 생겼습니다 — 공-대상 거리. 축구 19편에서 공↔추적 대상 발목 거리가 갈립니다: 0.15~0.66 대 0.86~4.82 어깨너비(≈2m). 열 회차가 전부 IoU·외양이었고 공은 로드맵 4절의 닫힌 경로 목록에 없습니다. 「공을 다루는 사람의 발과 공은 가깝다」는 라벨이 아니라 물리라 정답이 필요 없습니다.

⑵ 🔴 그런데 그 단서를 이 항목의 표본으로는 검증 못 합니다.

   
공이 잘 안 잡힙니다 phaseA 골든셋 39편에서 sports_ball 궤적이 남은 것 12편(31%). 축구는 19/19
🔴 더 근본적으로 야구는 공이 발치에 없습니다. 저 물리는 축구의 것입니다

🔴 그리고 이것이 이 항목 자체의 문제를 드러냅니다 — 제품은 2026.09.11에 축구 단일 종목(39번)이 됐는데 이 항목의 증거 기반은 야구 39편이고, 난이도 성격도 다릅니다(관중 많은 중계 야구 대 휴대폰 축구).

열 회차의 결론(닫힌 경로)은 알고리즘의 성질이라 그대로 유효합니다 — 재조사 금지는 그대로입니다. 다만 11회차의 표본은 축구여야 하고, 그러면 「사람 판독 70장」도 그 표본에서 다시 뽑아야 합니다.

근거: agent/eval/pending45_touch_events/RESULTS.md(정정 절) · agent/eval/pending43_sport_gate/gate_raw.json(종목별 공 검출률)

🔴 정정 (2026.09.14) — 바로 위 문장이 절반만 맞습니다

“알고리즘의 성질이라 종목을 넘어 유효하다” 고 뭉뚱그려 적었는데, 넘어가는 것과 안 넘어가는 것을 갈라야 합니다. 오늘 그 위험이 실제로 드러났습니다 — 45번 1회차가 원리로만 적어 둔 「야구는 공이 발치에 없다」를 재 봤더니 발치+다인 층이 0.4%(39편 중 1편) 입니다. 공을 23%나 보면서도 그렇습니다 (agent/eval/sample_gate/).

  종목을 넘는가 예
대수(代數)·자료 구조의 성질 ✅ 넘습니다. 표본과 무관하게 참입니다 「외양을 재가중으로 넣으면 겹치는 후보가 1개인 프레임에서 무엇을 곱해도 argmax 가 안 바뀐다」(1회차) · 「거부한 뒤 무엇을 내놓는가는 K 와 별개의 출력 형태다」(7회차)
계기의 성질 ✅ 넘습니다. 무엇을 재는 도구인가의 문제입니다 「고정된 previous 와의 IoU 는 구간이 길면 대상이 있어도 떨어진다」(8회차) · 「재획득으로 끝난 구간만 보면 현상의 2.6%」(10회차)
🔴 장면의 물리 ❌ 안 넘습니다 「공이 발치에 있다」 · 「분석 대상이 공 근처에 있다」 · 카메라 구도(중계 대 휴대폰) · 인원 수
🔴 수치로 고른 상수 ❌ 안 넘습니다 K=41/1 · MIN_CLEAN=20 · TAU · NEAR — 전부 그 표본에서 잰 값입니다

🔴 그래서 닫힌 경로 9개를 다시 훑어야 하는가 — 아닙니다. 9개는 위 표의 앞 두 줄(대수·계기)에서 나왔고, 뒤 두 줄에 기댄 결론은 애초에 「닫혔다」로 안 적었습니다(그래서 (가)/(나) 갈래가 아직 열려 있는 것입니다). 바뀌는 것은 표현이지 목록이 아닙니다 — 다만 앞으로 「종목을 넘어 유효하다」고 적을 때는 위 표의 어느 줄인지 밝히고 적습니다.

🔴 대조군으로 쓰는 것은 이 구분과 다릅니다. 오늘 야구 39편을 쓴 자리는 「우리 도구가 다인을 셀 줄 아는가」이고 재는 대상이 도구입니다. 축구 성능을 야구로 말한 것이 아닙니다 — 회차마다 그 한계를 적어 두었습니다.

✅ 마지막 칸을 채웠습니다 — 워커 배선 (2026.09.09, 정상호)

정어진 님이 POST /videos → analysis_job → claim 응답까지 실어 주셨고 (paik 6번, 커밋 ee401d6), 남은 것이 워커가 그 값을 자식에게 넘기는 세 줄이었습니다. 그것을 붙였습니다 — 이제 화면에서 찍은 박스가 분석까지 끊기지 않고 갑니다.

구간 상태 소유
화면에서 박스 지정 ✅ 백성검
POST /videos 본문 · 기하 검증 ✅ 2026.09.09 정어진
작업 행 → claim 응답 ✅ 2026.09.09 정어진
claim → analyze_s3.py 인자 ✅ 2026.09.09 정상호 ← 이번
에이전트 입구·기록 ✅ (56e0ada) 정상호
# worker.py · analyze_command — --focus 옆
--subject-box "0.39,0.35,0.12,0.4" --subject-at-ms 4200

🔴 워커는 좌표를 다시 검증하지 않습니다. 무엇이 올바른 지정인가는 pose.parse_subject_spec 한 곳이 정합니다. 규칙을 복사하면 두 곳이 갈렸을 때 같은 입력이 경로에 따라 통과했다 막혔다 합니다(미결 10번의 형태). 범위 밖 값은 자식이 0 아닌 코드로 죽고 사유가 failure_reason 에 남는 것이 옳은 실패입니다 — 검사가 그 성질을 지킵니다.

🔴 둘 다 있을 때만 붙입니다. 하나만 붙이면 자식이 「박스를 주면 시각도 함께」로 죽는데, 그것은 지정이 없는 것과 다른 사건입니다. 지정이 없으면 「자동으로 고르기」로 정상 분석돼야 하고 그쪽이 지금 거의 모든 작업입니다. 여기서 무너지면 자동 분석이 통째로 죽습니다.

   
검사 4건 추가 (351 → 355) — 통과 · 지정 없음 · 반쪽 지정 · 재검증 안 함
확인 grep -n 'subject-box' agent/scripts/worker.py · cd agent && uv run pytest tests/test_worker.py -q
확인(왕복) 워커가 만드는 문자열이 parse_subject_spec 로 그대로 읽히고, 픽셀 좌표(640,360,…)는 거부되는 것까지 확인했습니다

⚠️ 여기까지가 「배선」입니다 — 「지정대로 분석됐다」가 아닙니다. 아래 「닻은 맞는데 이어가기가 갈아탄다」(다인 10건 중 3건)는 그대로 남아 있습니다. 정어진 님도 paik 6번에 같은 단서를 다셨습니다. 지정이 실제로 지켜졌는지는 결과 봉투의 subject.source 로 봅니다 — 갈아탔으면 specified_uncertain 입니다.

🔴 숫자 둘이 돌아다닙니다 — 63% 와 30% 는 다른 것을 잽니다

정어진 님이 paik 6번에 「정상호 실측 63%」라고 다셨는데 제 수치가 맞습니다. 다만 둘이 같은 것을 재지 않아 여기 갈라 둡니다 — 「갈아타는 문제가 63%」로 읽히면 실제보다 훨씬 나쁘게 들립니다.

수치 무엇의 비율인가 분모
63% 3R1kvNrGJK0 한 클립 안에서 엉뚱한 사람으로 분석된 프레임 그 클립의 300프레임
30% 트랙이 갈아탄 클립 다인 10건
(43%) 같은 것을, 지정이 실제로 일을 한 클립만으로 7건 (갈림 0%인 3건 제외)

「다인 클립의 30%에서 갈아탄다. 갈아탄 클립에서는 프레임의 상당수가 엉뚱한 사람이다(한 사례에서 63%)」 가 맞게 적은 문장입니다. 🔴 어느 쪽도 사용자 트래픽 비율이 아닙니다 — 표본이 Kinetics 경기 중계·연습 영상 10건입니다.

🔴 10회차 (돌아온 자리) — 판별불가입니다. 계기가 현상의 2.6%만 봅니다 (2026.09.10)

9회차가 남긴 갈래 — (i) 후보가 진짜 남이다 대 (ii) 대상인데 안 닮아 보인다 — 를 재획득 상자의 자리로 가르려 했습니다. 근거: agent/eval/pending18_link/ · 사전 등록 3f4a415(코드보다 먼저) · 결과 e182279.

판정 — (다) 판별불가. 섞여 있습니다
     
A 궤적 비트 동일 ✅ 읽기만 했습니다
B 주 층 8구간+ · 3클립+ ✅ 상한 10프레임 · 9구간 · 7클립
C 층 일치 🔴 층 A 33%(3구간) 대 층 B 50%(6구간)
D 계기 검사 |ρ| < 0.5 ✅ +0.11 — 길이 오염은 없었습니다
E 다수 쪽 70%+ 🔴 이어짐 44% · 안이어짐 56%

주 층 9구간 중 이어짐 4 · 안이어짐 5. 어느 쪽도 사전 등록한 70% 폭에 못 닿습니다 — (i)도 (ii)도 아니고 둘 다 일어납니다.

🔴 이 회차의 값은 판정이 아니라 계기가 어디에 닿는지입니다
관문 통과 클립의 잃은 프레임 1,309  
재획득으로 끝난 구간 17건 408 31%
🔴 안 끝난 구간 7건 901 69%
✅ 주 층 9건이 설명하는 몫 34 2.6%

구간 길이 중앙이 재획득 10프레임 대 미완 147프레임입니다. 안 끝난 일곱(196·178·166·147·128·77·9프레임)이 커버리지 붕괴 그 자체인데, 이 계기는 트래커가 스스로 「돌아왔다」고 말한 구간만 볼 수 있어서 거기에 원리적으로 못 닿습니다. 재획득으로 끝난 구간은 쉬운 경우입니다.

🔴 9회차가 이 규격을 쓸 때 그 간극을 못 봤습니다 — 「재획득이 일어난 23구간이 대상이다」라고 적었는데 그 23구간이 문제의 표본이 아니었습니다.

8회차 결함과 다른 결함입니다
회차 계기가 틀린 자리 증상
8 무엇을 재는가 cont_iou 가 대상 유무가 아니라 구간 길이를 쟀습니다
10 누구를 재는가 재는 양은 맞았습니다(ρ +0.11). 닿는 표본이 현상이 아닙니다

계기 검사는 두 개입니다 — 「이 양이 내가 묻는 것인가」와 「이 표본이 내가 묻는 대상인가」. 10회차는 앞의 것만 기준에 넣었고 뒤의 것은 사후에 봤습니다.

문턱을 미리 못 박은 것이 일했습니다

LINK_IOU 를 0.1·0.2·0.3·0.5 로 옮기면 이어짐이 67%·56%·44%·22% 입니다. 🔴 0.5 로 그었으면 「(i) 대상이 없었다」가 78% 로 결론이 됐습니다. 0.3 은 production MIN_ANCHOR_IOU 라 결과를 보기 전에 정해져 있었습니다. 값이 문턱에 이만큼 민감한 것 자체가 「섞여 있다」의 다른 표현입니다.

사후 관찰 — 섞임은 잡음이 아니라 두 덩어리입니다

reach(프레임당 상자폭 이동)로 늘어놓으면 사이가 비어 있습니다 — 아래 무리 0.04~0.41 여섯 건(제자리 복귀) · 위 무리 1.83~2.98 세 건 (두 프레임 만에 상자 폭의 두세 배 = 자리 이동). 🔴 9건이고 금을 값을 보고 그었으므로 판정에 안 씁니다 — 8회차가 사후 「정정」으로 틀린 방향을 적은 그 자리라, 사전 등록 판정 옆에 붙여만 뒀습니다.

🔴 닫는 것 — 기하로 (i)/(ii)를 가르는 길

표본을 늘려도 이 계기는 붕괴 구간에 못 닿습니다(트래커가 「돌아왔다」고 말해야만 보입니다). 8·9·10회차가 기하·외양·기하로 물었고 셋 다 「대상인지 말해 줄 독립 근거가 없다」는 같은 벽에 닿았습니다 — 라벨 없이 가르는 길이 사실상 소진됐습니다.

다음 한 걸음 — 두 길뿐이고 둘 다 이 회차 밖입니다
  무엇을 대가
(A) 사람 판독 🔴 권합니다 안 끝난 7구간에서 구간당 10프레임씩 70장을 사람이 봅니다 — 「대상이 화면에 보이는가」 예/아니오. 그것이 (i)/(ii)의 정답입니다 사람 시간. AI 판독은 정답으로 승격 못 합니다
(B) 더 강한 외양으로 되짚기 붕괴 구간 후보를 색 히스토그램이 아닌 표현으로 닻과 대 봅니다. 되찾으면 (ii)와 처방이 함께 증명됩니다 새 의존(re-ID 모델·GPU). 못 찾아도 (i) 이 증명되지는 않습니다

🔴 (A)를 권하는 이유: (B)는 성공할 때만 말이 되고 실패하면 아무것도 못 닫습니다. (A)는 어느 쪽이 나와도 갈래가 닫힙니다. 그리고 70장은 이 프로젝트가 못 구한 라벨 중 가장 싼 것입니다 — 6번의 39클립 판독과 달리 예/아니오 한 칸입니다.

✅ 9회차 (거부의 원인) — 표류가 아닙니다. 그 진단을 닫습니다 (2026.09.10)

8회차가 남긴 숙제 셋(계기·관문·순환)을 다 받았습니다. 근거: agent/eval/pending18_drift/ · 사전 등록 95856f7(코드보다 먼저) · 결과 7c8944a.

물음을 바꿨습니다 — 「있는가」가 아니라 「왜 거부했는가」

거부는 한 줄에서 일어납니다 — app(ref, pick) < TAU. ref 는 갱신되며 흘러가고 ah(닻)는 고정입니다. 그 프레임에서 둘을 나란히 보면 원인이 바로 나옵니다.

🔴 8회차 결함의 해독제입니다 — 거부는 한 프레임의 사건이라 구간 길이가 값에 안 들어옵니다.

판정 — 기준 다섯이 다 섰습니다
     
A 궤적 비트 동일 ✅ 읽기만 했습니다
B 관문 통과 층마다 3건+ ✅ A 5건 · B 6건 · C 1건
C 층 A·B 다수 쪽 일치 ✅ 100% · 92%, 같은 쪽
D 🔴 계기 검사 ✅ 구간 길이와 ρ = −0.05
E 다수 쪽 70%+ ✅ 96%

거부 24건 중 23건에서 ref 뿐 아니라 「닻도」 TAU 미만입니다.

🔴 표류의 크기가 직접 말합니다

anchor_app − ref_app 이 중앙 −0.06 · 최대 +0.05 입니다. 닻에서 거부까지 중앙 70프레임(최대 191)이 걸렸는데도 두 의견이 사실상 같습니다 — ALPHA = 0.05 의 갱신이 그 시간 동안 의미 있게 안 흘렀습니다.

🔴 3회차부터 이어진 표류 진단을 닫습니다. 3·6·7회차가 만졌던 축(갱신 규칙 · 구제 문턱 · 출력 형태)이 전부 뿌리가 아니었습니다. 재조사하지 않습니다.

✅ 계기가 이번엔 살았습니다
anchor_app 이 무엇에 반응하는가 ρ
구간 길이 −0.05
후보 수 −0.09
닻에서의 거리 +0.10

8회차의 cont_iou 는 길이가 분류를 정했습니다(≤10프레임 0.76 대

40프레임 0.07). 거부를 한 프레임의 사건으로 잡은 것이 그 축을 통째로 없앴습니다.

🔴 제 8회차 예측이 틀렸습니다

8회차에 「사후 값이 27/73 이라 (나) 쪽이 유력」이라 적었는데 (나)는 24건 중 1건입니다.

회차 물은 것 답
8 사전 등록 cont_iou(구간 중앙) 없다 77% — 🔴 길이에 오염
8 사후 onset IoU(시작 3프레임) 있었다 73%
9 anchor_app(거부 시점) 🔴 닻이 못 알아본다 96%

셋이 모순이 아닙니다. 8회차 사후가 찾은 「시작 시점에 이어지는 후보」는 실제로 거기 있습니다 — 다만 IoU 는 「상자가 있다」만 말하고 「그게 대상이냐」는 안 말합니다.

잃은 구간에 사람은 있습니다. 대상은 아닙니다(또는 알아볼 수 없습니다).

🔴 8회차의 사후 「정정」이 그 자체로 틀린 방향이었습니다. 사전 등록 판정을 안 고치고 옆에 붙여 둔 것이 옳았습니다 — 고쳐 썼으면 틀린 값이 정본으로 남았을 겁니다.

관문이 일했습니다 (MIN_CLEAN = 20, 이 회차의 유일한 새 상수)

층 A 에서 YNMHMKb5Md4(깨끗 0장 — 8회차에 공허하게 통과했던 그 건)와 8gmHKqDxXdg(17장)이 빠졌고, 층 B 에서는 넷이 장수가 아니라 app 으로 막혔습니다(0.33·0.56·0.59·0.60) — 그 클립들은 잘 따라갈 때도 안 닮습니다.

🔴 안 닫힌 것 — 둘이 남았습니다

「닻도 못 알아본다」가 두 사정을 아직 안 가릅니다.

   
(i) 후보가 진짜 남이다 → None 이 정직한 답이고 커버리지 붕괴는 결함이 아닙니다
(ii) 대상인데 그동안 안 닮아 보인다 가림·조명·스케일 → 뿌리는 닻 기술자(색 히스토그램)이고 처방은 더 강한 외양 표현입니다

대조군이 깨끗한 프레임에서 0.60~0.93을 내고 재획득이 23/37(62%) 에서 일어나므로 기술자가 통째로 못 쓰는 것은 아닙니다. 구간 내 닻 최선(anchor_best)이 중앙 0.47 이라 「고를 것이 있는데 안 골랐다」도 아닙니다.

다음 한 걸음 — 10회차

재획득 시점의 상자가 잃기 직전 상자와 이어지는가. 이어지면 대상이 그 자리에 있었다는 뜻이라 (ii), 딴 데서 나타나면 (i)입니다. 재획득은 닻 기준으로 고른 것이라 트래커 자신이 낸 「대상이다」 선언이고, 그것을 공간으로 잇는 데는 라벨이 필요 없습니다.

🔴 거기도 IoU 라 길이에 오염될 수 있습니다 — 구간이 길면 재획득 상자가 멀어지는 것이 당연합니다. 구간 길이로 층화하고 짧은 구간(≤10프레임)만으로 낸 값을 주 지표로 삼습니다. 🔴 그리고 층 B·C 를 이번에 처음 썼으니 10회차에는 「안 쓴 표본」이 없습니다 — 그 사실을 사전 등록에 적고 시작합니다.

🔴 8회차 (대상이 있기는 한가) — 판정은 났는데 계기가 틀렸습니다 (2026.09.10)

7회차가 「다음은 한 칸 위」라 적은 자리입니다. 처방이 아니라 진단이고, 다섯 번 연속 처방이 불합격한 뒤라 고치려는 것이 결함이기는 한지를 먼저 물었습니다. 근거: agent/eval/pending18_presence/ · 사전 등록 60d2673(코드보다 먼저) · 결과 9b74219.

사전 등록이 낸 판정 — 그대로 적습니다
    기준
(가) 대상이 없다 (absent + drifted_away) 77% 70%
(나) 못 알아본다 (rejected) 22% 70%
(다) 판별불가 0건 4건+

자기 검사도 셋 다 통과했습니다 — 궤적이 6회차와 비트 동일, 분류가 잃은 프레임 1,267장을 빠짐없이 덮고, 7회차의 auto 보유율 93%가 99%로 재현됐습니다.

🔴 그런데 이 판정을 믿으면 안 됩니다 — 사후 관찰이 계기를 잡았습니다

drifted_away 의 판정식 cont_iou < 0.3 에서 cont_iou 는 잃은 구간 내내 고정된 previous 와의 IoU입니다.

구간이 길면 대상이 있어도 떨어집니다. 사람은 움직이는데 previous 는 안 움직입니다. 「없다」가 아니라 「멀어졌다」를 쟀습니다.

구간 길이 개수 중앙 cont_iou
≤ 10프레임 5 0.76
> 40프레임 8 0.07

구간 길이 중앙이 38프레임이라 길이가 분류를 정했습니다.

시작 3프레임만 보면 — 거의 뒤집힙니다
  구간 프레임 비율
시작부터 이어지는 후보가 없다 (진짜 (가)) 3 337 27%
시작에는 있었다 15 930 73%

🔴 사전 등록의 (가) 77% 와 정확히 뒤집힙니다. drifted_away 8구간 중 5구간(프레임 가중 51%) 이 시작 시점에는 IoU 0.74~0.98 짜리 후보를 갖고 있었습니다. 정말 (가)인 것은 3R1kvNrGJK0(시작 IoU 0.03)와 YNMHMKb5Md4(0.02·0.01) 둘뿐입니다.

✅ 그래도 굳게 남는 것 하나 — 대조군은 진짜로 통과했습니다

이 회차가 세운 관문의 값은 그대로입니다. 깨끗한 프레임(닻 제외)의 app(닻, chosen) 중앙값이 0.62 · 0.64 · 0.70 · 0.73 · 0.84 · 0.88 — 여섯 클립 전부 TAU(0.6) 이상입니다.

🔴 외양 기술자는 추적이 깨끗할 때 실제로 작동합니다.

그래서 7회차의 「채운 프레임 app 중앙 0.28」은 진짜로 낮습니다. 같은 클립·같은 닻으로 0.62~0.88이 나오는데 채운 것만 0.28입니다 — 7회차의 F 불합격(1%)은 계기 탓이 아니었습니다.

🔴 다만 YNMHMKb5Md4 는 관문이 공허하게 통과했습니다 — 깨끗한 프레임이 닻 한 장뿐이라 자기 자신과의 비교였습니다(당연히 1.00). 사전 등록에 최소 장수를 안 걸었습니다.

🔴 배운 것 — 사전 등록이 막지 못하는 것이 있습니다

이 시리즈의 여섯 번째 정정인데 종류가 다릅니다. 앞의 다섯은 「뿌리가 무엇인가」를 틀렸고, 이번은 재는 도구가 틀렸습니다.

사전 등록은 「결과를 보고 기준을 바꾸는 것」을 막습니다. 계기가 재려던 것을 재는지는 막지 못합니다.

cont_iou 의 정의는 사전 등록에 정확히 적혀 있었고(“프레임별 최대의 중앙값”) 규격대로 구현했고 자기 검사 셋을 다 통과했습니다. 그런데도 다른 양을 쟀습니다. 앞 회차들의 짝 기준은 「좋아 보이는 결과」를 막는 장치였는데, 여기 필요했던 것은 계기 검사였습니다 — 「이 지표가 내가 말한 것 말고 무엇에 반응하는가」. 길이로 층화해 보는 한 줄이면 잡혔습니다.

다음 한 걸음 — 9회차

🔴 (가)/(나) 갈래는 안 닫혔습니다. 이 회차는 「이 계기로는 못 갈랐다」 이지 「대상이 있다/없다」가 아닙니다.

   
무엇을 시작 IoU 기반 분류로 사전 등록을 다시 세우고 다시 잽니다
예상 사후 값이 27/73 이라 (나) 쪽이 유력합니다. 🔴 다만 사후를 사전으로 승격하지 않습니다 — 그 값을 보고 세운 기준이라 같은 표본에서 다시 재면 순환입니다
반드시 ① 대조군 관문에 최소 깨끗 장수 ② 계기 검사 — 분류 지표를 구간 길이·후보 수·닻 거리로 층화해 「무엇에 반응하는가」를 함께 냅니다
표본 🔴 과녁 7건은 이미 썼습니다. 6회차가 보류 8건을 처음 열어 얻은 것이 컸으니 9회차도 안 쓴 표본을 확보하고 시작합니다
안 건드린 것

src/(읽기만 했습니다 — 추적을 한 비트도 안 바꿉니다) · 앞 회차 스크립트(import 만) · 새 상수 0개(RESCUE_IOU·TAU 뿐입니다) · 🔴 사전 등록의 판정을 고쳐 쓰지 않았습니다 — (가) 77% 를 그대로 두고 옆에 믿으면 안 되는 이유를 붙였습니다. 값을 조용히 바꾸면 다음 사람은 계기가 틀렸던 것을 모릅니다.

🔴 7회차 (거부의 출력 형태) — 커버는 7/7 채워졌고, 채운 것이 전부 남이었습니다 (2026.09.10)

6회차가 「다음은 커버리지 붕괴 정면」이라 적은 자리입니다. 세 후보 중 「거부해도 자동 박스로 떨어지되 source 를 낮추기」를 골랐습니다. 근거: agent/eval/pending18_coverage/ · 사전 등록 6b6a16a(코드보다 먼저) · 결과 14611c1.

🔴 먼저 진단을 정정합니다 — 뿌리는 K = 1 이 아닙니다

3회차부터 「뿌리는 K = 1 거부」라고 적어 왔는데 절반만 맞았습니다. K 는 얼마나 쉽게 거부하는가를 정하고, 거부한 뒤에 무엇을 내놓는가(chosen[t] = None)는 따로입니다. 커버리지를 0으로 만드는 것은 그 출력 형태입니다. 그래서 이번에도 문턱 추가가 아니라 불일치 제거였습니다 — 이어갈 후보가 없으면 auto[t] 로 떨어지는데 외양이 거부되면 None 이었습니다. 같은 「모르겠다」에 두 답을 하고 있었습니다.

판정 — 불합격. 그런데 불합격한 방식이 이 회차의 값입니다
  기준 결과
A 자동 경로 비트 동일 ✅ B-6 재실행 없음
B 보정용 7건이 6회차와 비트 동일 ✅ 채우기가 한 번도 발동 안 함
C 과녁 7건 중 5건+ 이 커버 50%+ ✅ 7/7
D 커버 오른 클립의 엉뚱률 10%p+ ≤1건 🔴 7/7 전부
E 커버 ≥50% 였던 26건 지킴 🔴 엉뚱률 후퇴 3건
F 채운 것의 절반+ 가 app(닻) ≥ 0.6 🔴 1%

커버리지는 되찾아집니다 — 과녁 7건이 전부 100%가 됐습니다. 그런데 채운 1,731프레임 중 닻과 닮은 것이 16개(1%) 입니다. YNMHMKb5Md4 가 극단입니다: 커버 1% → 100% 인데 엉뚱률이 0% → 99% — 아무도 안 보던 것을 엉뚱한 사람을 보는 것으로 바꿨을 뿐입니다.

✅ 「채울 것이 없어서」가 아닙니다 — 있는데 남입니다

사전 등록이 반드시 보고하라고 한 항목입니다(이 값이 낮으면 실패의 뜻이 완전히 달라집니다): 잃은 프레임의 93%에 auto[t] 가 있었습니다 (채움 1,731 · 못 채움 129). 「자동 경로에 박스가 없어 커버가 0이었다」는 가설은 여기서 죽습니다.

🔴 사후 관찰이 두 사유를 갈랐습니다 — 문턱을 낮추는 곁길도 닫힙니다

(사전 등록에 없습니다. 판정에 쓰지 않았고 기준도 바꾸지 않았습니다.) 채운 프레임의 app 이 중앙 0.28 · 1사분 0.15 · 3사분 0.45, 문턱 근처(0.5~0.6)는 11% 뿐입니다. 「거의 맞았는데 문턱에 걸린 것」이면 0.5~0.6 에 쌓였어야 합니다. 남을 채운 것이고, 그래서 문턱을 0.5 로 낮춰도 들어올 것이 없습니다.

✅ 기준이 이번엔 제대로 일했습니다

5회차 E(production 대비)·6회차 D(한 축만)가 연달아 두 번 되돌림을 개선으로 적었습니다. 이번엔 D·F 를 짝으로 박아 C 의 7/7 을 개선으로 적지 못하게 막았습니다. C 만 있었으면 이 회차는 「7/7 완전 해결」로 적혔을 것입니다. 바를 내리지 않았습니다.

🔴 정정 — 사전 등록 표에 적은 커버6 값 넷

8gmHKqDxXdg 19 → 21% · gg5xRWjw3f8 16 → 17% · w-AQcjcoDyA 68 → 73% · mTP2kap9cIw 99 → 102%. 정의가 다릅니다 — 6회차는 순환성 때문에 구제된 프레임을 분자·분모 양쪽에서 뺐고, 이번엔 순환성이 없어(채우기가 app 을 안 봅니다) 전 프레임으로 셉니다. 🔴 과녁 7건의 구성원은 안 바뀝니다 — 새 정의로도 전부 50% 미만이라 기준 C 는 그대로 성립합니다.

   
자기 검사 채우기를 끄면 주·보류 전수에서 6회차와 출력 동일 — 이번 차이는 채우기 하나에서 온 것입니다
새 상수 0개. 채우기는 문턱이 아니라 형태라 고를 값이 없습니다
안 건드린 것 src/·앞 회차 스크립트(import 만) · K·이력 · 채운 박스로 previous·ref 갱신 안 함(그래서 추적 결정이 6회차와 한 비트도 다르지 않습니다)
🔴 다음 한 걸음은 한 칸 위입니다 — 「잃은 구간에 대상이 있기는 한가」

이 회차는 어떻게 채울까를 묻고 「그 방향엔 없다」를 얻었습니다.

  • (가) 대상이 화면에 없다(가림·이탈) → 커버리지 붕괴는 결함이 아닙니다. None 이 정직한 답이고, 고칠 것은 지표가 아니라 기대입니다
  • (나) 있는데 닻이 못 알아본다 → 뿌리는 정지 히스토그램의 표류(3회차)입니다

라벨 없이 가를 수 있습니다 — 잃은 구간의 후보 수(0개면 (가))와, 후보가 있는데 전부 app 이 낮으면 후보끼리의 app·닻 시점부터의 시간 거리를 봅니다. 🔴 새 사전 등록을 받습니다.

⚠️ 한계: 판정 회차가 아닙니다 — 채운 박스가 진짜 대상인지는 라벨 없이 못 말하고 app(닻) 은 대리 지표입니다. 🔴 보류 8건은 전부 합성 닻이라, 닻 자체가 엉뚱한 사람이면 분포가 낮은 것이 당연합니다 — 위 사후 관찰은 (가)로 단정하는 것이 아니라 「이 표본에서 (가)로 보인다」입니다. (나)를 약화시켰을 뿐 배제하지 못했습니다.

🔴 6회차 (구제 시점 외양 문턱) — 대가는 없었고, 진짜 벽이 처음 보는 표본에서 재현됐습니다 (2026.09.09)

5회차에서 CFjNxCZhn_8 을 망가뜨린 단 하나의 구제가 닻 대비 외양 유사도 최저(0.55) 였습니다. 근거: agent/eval/pending18_gate/ · 사전 등록 1a4722c.

🔴 문턱 추가가 아니라 불일치 제거였습니다. 같은 트래커에서 재획득은 이미 닻 대비 app ≥ 0.6 을 요구하는데 구제만 면제였습니다. 구제는 「잃을 뻔한 대상을 되찾는 사건」이라 성격이 재획득에 가깝습니다. 이번에도 새 상수는 0개입니다(REACQ_TAU 를 그대로 씁니다).

판정: 불합격 (A·C·D·E 만족, B·F 불만족).

✅ 몰랐던 것에 답이 나왔습니다 — 대가가 없었습니다

사전 등록이 「이 회차의 값」으로 지목한 질문이 「막히는 구제 중에 일을 하고 있던 것이 있는가」 였습니다. 10%p 이상 후퇴한 클립 0건입니다. X6dC9pu5H3k(7/7)·sGKeqfxwq5E(4/4)·N5zWQkoLM3M(4/4)의 구제는 전부 통과했고, CFjNxCZhn_8 의 하나만 막혔습니다.

🔴 그리고 IeDin6oB-IY 의 구제 18회는 거의 일을 안 하고 있었습니다 — 전부 사라졌는데 엉뚱이 119 → 122, 3프레임만 나빠졌습니다.

🔴 예측이 빗나갔습니다 — 구제 사건은 서로 독립이 아닙니다

「37회 중 20회쯤 막힌다」고 적었는데 막힌 것은 3회, 통과 17회, 합쳐 20회입니다. 나머지 17회는 아예 일어나지 않았습니다. IeDin6oB-IY 에서 첫 구제 하나를 막자 나머지 17회의 조건 자체가 사라졌습니다. 「사건 집합을 문턱으로 가른다」는 그림이 틀렸습니다 — 트랙을 바꾸는 개입은 사건 수를 정적 집합으로 세면 안 됩니다.

🔴 D는 만족했지만 고친 게 아니라 되돌린 것입니다
  4회차 5회차 6회차
엉뚱 35 212 34
커버 유지 29% 88% 29%

클립이 4회차 상태로 복귀했을 뿐입니다. 그리고 이것은 제가 같은 실수를 반복한 자리입니다 — 3회차에서 엉뚱(C)과 커버리지(D)를 짝으로 둔 이유가 바로 이 상충인데, 6회차 D 를 엉뚱만으로 썼습니다. 한 축만 보는 기준은 되돌림을 개선으로 적습니다. 소급해 고치지 않고 결함으로 남깁니다 — 5회차 E 결함에 이어 같은 종류로 두 번째입니다.

🔴 F가 걸렸는데 문턱 탓이 아닙니다 — 이것이 이 회차의 가장 큰 값입니다

1~5회차가 한 번도 안 쓴 8건을 처음 열었습니다(4회차 사전 등록이 「구현 회차에는 이 3건에 없던 클립이 있어야 한다」고 적은 조건입니다). 새 컷을 만들지 않고 기존 닻 규칙이 통과시키는 것을 전부 썼습니다.

F 불만족 사유는 커버 50% 미만이 3건인데 — 🔴 문턱을 꺼도 커버가 똑같습니다(19%/19% · 16%/16% · 1%/1%). 문턱이 떨어뜨린 클립은 0건입니다.

  커버 <50%
주 표본 25건 4건
보류 표본 8건 3건 — YNMHMKb5Md4 는 1%

5회차가 「미해결」로 남긴 커버리지 붕괴가 처음 보는 클립에서 더 나쁜 비율로 재현됐습니다. 25건에 맞춰진 현상이 아니라 설계의 성질이라는 것이 독립으로 확인됐습니다. 뿌리는 K = 1 거부입니다.

   
자기 검사 문턱을 끄면 주 표본 전수에서 5회차와 출력 동일
순환성 「엉뚱」의 정의가 문턱과 같은 양(app(닻)<TAU)을 써서, 주 지표에서 구제된 프레임을 뺐습니다(세 회차에서 같은 프레임을)
기준 B 🔴 여전히 불만족 — 다만 원인을 알았습니다. 그 구제는 app 0.64로 정당했고, 열린 이유는 IoU 게이트(MIN_ANCHOR_IOU=0.3)입니다. 외양 문턱으로 닿는 문제가 아닙니다
사후 관찰 막힌 구제 app 최대 0.58 · 통과한 것 최소 0.64 — 문턱 0.6이 빈틈에 놓였습니다. 값은 미리 박은 것이라 튜닝이 아닙니다
확인 cd agent && uv run python eval/pending18_gate/measure_gate.py (GPU 불필요)

⚠️ C·D 는 5회차 데이터에서 이미 예측 가능해서 사전 등록에 「증거로 치지 않는다」고 미리 적었습니다. ⚠️ w-AQcjcoDyA(보류)가 구제 19회로 엉뚱 88 → 2 인데, 구제의 몫과 3회차 설계의 몫을 이 회차 출력으로는 못 가릅니다 — 「구제가 88 → 2 를 했다」고 적으면 과장입니다.

다음은 커버리지 붕괴 정면입니다.

🔴 5회차 (상류 처방) — 한 클립을 대가 없이 고쳤고, 4회차 진단은 절반이 틀렸습니다 (2026.09.09)

4회차가 지목한 상류(임계 미달로 버려진 검출)를 이어가기에 한해 되살려 봤습니다. 근거: agent/eval/pending18_rescue/ · 사전 등록 2cc290a.

🔴 새 상수가 0개인 회차였습니다. 밴드 하한 0.3 은 production 검출 post-process, 상한 0.5 는 PERSON_ELIGIBLE_THRESHOLD, IoU 문턱은 MIN_ANCHOR_IOU 입니다. 1~3회차는 회차마다 상수를 더했고 매번 그 상수가 문제였습니다(K 가 41 ↔ 1). 구제 대상은 새 검출이 아니라 production 이 이미 만들어 놓고 버리는 것이라 구현도 값쌉니다.

판정: 불합격 (5기준 중 A·E 만족). 핵심 예측 D가 빗나갔습니다.

✅ 그래도 — 네 회차 만에 처음으로 대가 없이 고친 것이 나왔습니다
클립 엉뚱(현) 4회차 5회차 커버
X6dC9pu5H3k 57 57 (변화 0%) 0 (100% 감소) 100% 유지

그 프레임들에서 통과 후보의 IoU 가 0.00 입니다 — 대상이 통과 집합에 아예 없었고, 점수 0.42~0.49 로 밀려 있었을 뿐입니다. 되돌려 놓으니 커버리지를 하나도 잃지 않고 엉뚱이 사라졌습니다.

🔴 그런데 이 클립은 4회차가 「갱신이 표류를 가린다」로 분류한 셋 중 하나였습니다. 분류가 틀렸습니다 — 「변화 0%」 안에 서로 다른 두 원인이 섞여 있었습니다.

🔴 D가 빗나갔습니다 — 커버리지 붕괴는 다른 사건입니다
클립 커버4 커버5 회복 구제
3R1kvNrGJK0 42% 42% 🔴 1
6hrcRyIYTrA 41% 41% 🔴 0
bh6Cvz2orzQ 35% 35% 🔴 0
CFjNxCZhn_8 29% 88% ✅ 1

잃은 프레임을 사유별로 세니 밴드 [0.3, 0.5) 에 되찾을 것이 없었습니다 (3R1kvNrGJK0 173프레임 중 1 · 6hrcRyIYTrA 184 중 0 · bh6Cvz2orzQ 196 중 0). 대상은 버려진 것이 아니라 후보에 있는데 외양 검사가 거부한 것이고, 구제로는 닿지 않습니다.

그래서 4회차의 그림을 고쳐 적습니다 — 두 사건이었습니다.

사건 원인 상태
갈아탐 대상이 임계 미달로 버려진다 ✅ 실증됨 (X6dC9pu5H3k)
커버리지 붕괴 대상이 후보에 있는데 거부된다(K=1) 🔴 미해결, 구제로 안 닿음

🔴 닫은 경로: 「재획득까지 구제를 열기」. 열어 봐야 닿을 자리가 0~1프레임입니다. 가 볼 필요가 없습니다.

🔴 4회차의 표제 사례를 정정합니다 — f169 를 되찾아도 안 나아졌습니다

4회차에 「sGKeqfxwq5E 는 300프레임 중 1프레임(f169)이 나머지 131프레임의 대상을 정했다」고 적었습니다. 이번에 그 프레임을 실제로 되찾았고 (점수 0.48 · IoU 0.76 · 이후 프레임 채워짐), 클립 결과는 엉뚱 35 → 36 입니다. 커버리지만 88 → 90% 로 조금 올랐습니다.

「한 프레임이 나머지를 정했다」는 이 클립에서 성립하지 않습니다. 4회차는 production 트랙을 보고 그렇게 읽었는데, 3회차 설계 위에서는 거부·재획득이 이미 그 자리를 다르게 처리하고 있었습니다.

🔴 유령을 하나 잡았는데, 표가 붙어 있었습니다

CFjNxCZhn_8 은 커버가 29 → 88% 로 올랐는데 엉뚱이 35 → 213 이 됐습니다. 구제 1회가 한 일이고, 그 하나가 37회 중 닻 대비 외양 유사도 최저값(0.55) 이자 유일하게 TAU = 0.6 아래인 것이었습니다. 구제 규칙이 IoU 만 보고 외양을 안 본 것이 원인입니다.

🔴 여기서 문턱을 추가하지 않았습니다. 이 결과를 보고 상수를 정하면 그 한 클립에 맞추는 일이고, 1~3회차가 매번 걸린 함정입니다.

🔴 그리고 제가 박은 기준 하나가 결함이었습니다

기준 E(「엉뚱이 는 클립」)를 production 대비로 정의해서, 213 < 266 이라 CFjNxCZhn_8 이 E 를 통과했습니다. 실제로는 4회차 대비 큰 후퇴입니다. 소급해 고치지 않고 결함으로 남깁니다 — 다음 사전 등록은 「직전 회차 대비」도 함께 봅니다. (D 의 「후퇴 10%p」 조항은 이것을 잡았습니다.)

   
자기 검사 구제를 끄면 25건 전수에서 3회차와 출력 동일 → 차이는 구제 하나에서 온 것
발동 37프레임 · 8/25클립 (예측 상한 43·11 안) ✅
기준 B 🔴 불만족 — 깨끗한 IYFifBJ9lH8 이 1프레임 달라짐. 해는 없지만 바를 내리지 않았습니다
기준 A ✅ 자동 경로 비트 동일 — B-6 재실행 없음
확인 cd agent && uv run python eval/pending18_rescue/measure_rescue.py (GPU 불필요)

⚠️ 「고쳤다」가 아닙니다. 구제된 박스가 진짜 대상인지는 라벨 없이 못 말합니다. 합성 닻은 「옳은 사람」이 아니고, 「엉뚱」은 app<0.6 대리 지표입니다.

🔴 4회차 (표본 25건) — 진짜 원인은 검출 임계값이었습니다 (2026.09.09)

상수를 또 만지면 3건에 맞추는 일이라, 처방을 고정하고 표본만 늘렸습니다. 근거: agent/eval/pending18_scale/ · 사전 등록 1ff5f9d.

먼저 — 「다인 10건」은 주석의 수였습니다. phaseA_metadata.csv 가 다인 10 · 단일 10 · 미검증 19 인데, 후보 캐시로 실제 검출을 세면 (라벨이 필요 없습니다) 다인 프레임 100+ 인 클립이 25건입니다 — 미검증 15건이 한 번도 안 쓰였습니다. 표본이 10건이었던 것은 데이터가 없어서가 아니라 주석이 없어서였습니다.

✅ 사전 예측이 맞았습니다. 「커버리지 붕괴가 우연이 아니면 여럿 나온다」 → 4/25 에서 났습니다. 3회차 D 실패는 설계의 성질입니다. 🔴 그리고 그 넷이 하필 가장 크게 좋아진 클립들입니다(99·90·87·98%) — 고치는 것과 포기하는 것이 같은 축에 있습니다. 엉뚱 프레임이 는 클립은 0건입니다.

🔴 갱신은 표류를 통째로 가립니다 — 13/25가 변화 0%

클립 닻 기준 app<0.6 갱신된 ref 기준
idueIYDAbZc 143프레임 0프레임
sYl2jCqsSKo 114프레임 0프레임

매 걸음이 작으면 어디로 가든 따라갑니다. 네 회차의 그림이 여기서 완성됩니다 — 두 실패가 서로의 처방입니다:

참조 잡는 것 놓치는 것
고정(1·2회차) 급격한 갈아탐 정당한 표류를 실패로 봄 → K가 41까지
갱신(3·4회차) 표류를 흡수 느린 갈아탐과 표류를 구분 못 함

같은 신호가 두 가지를 뜻하므로 문턱으로는 안 갈립니다.

🔴 그래서 위를 봤더니 — 대상은 사라진 게 아니라 버려졌습니다

1회차가 「겹치는 후보가 1개뿐」이라 했던 그 프레임의 임계 미달 검출까지 열어 봤습니다. sGKeqfxwq5E f169 (직전 트랙 박스 기준):

점수 통과 트랙과 IoU  
0.91 통과 0.12 ← 트랙이 고른 것(다른 사람)
0.48 🔴 미달 0.76 ← 진짜 대상. 버려졌습니다

IoU 0.76 짜리 검출이 점수 0.48 로 PERSON_ELIGIBLE_THRESHOLD = 0.5 에 0.02 차이로 걸렸습니다.

얼마나 흔한가  
프레임 43 / 5,641 (0.8%) — 드뭅니다
그런 프레임이 있는 클립 11 / 25 — 흔합니다

🔴 sGKeqfxwq5E 는 그런 프레임이 정확히 1개이고 그것이 f169 — 갈아탄 바로 그 자리입니다. 300프레임 중 1프레임이 나머지 131프레임의 대상을 정했습니다. 드문 사건이 영구적 결과를 낳으므로 빈도로 중요도를 재면 안 됩니다.

🔴 정정 (5회차, 2026.09.09). 이 문장은 틀렸습니다. 5회차에서 f169 를 실제로 되찾았는데 클립 결과는 엉뚱 35 → 36 이었습니다 — 한 프레임이 나머지를 정하지 않았습니다. production 트랙을 보고 읽은 것인데, 3회차 설계 위에서는 거부·재획득이 그 자리를 이미 다르게 처리합니다. 상류 처방이 실제로 고친 클립은 X6dC9pu5H3k 이고, 그것은 이 4회차가 다른 통(「갱신이 표류를 가린다」)에 넣어 둔 클립입니다. 위 5회차 참고.

다음 회차는 외양이 아니라 상류입니다

   
시험할 것 이어가기에 한해 임계 미달 검출도 후보로 받기 — 트랙과 잘 겹칠 때만
왜 값싼가 자동 경로를 안 건드립니다 → 기준 A 유지, B-6 재실행 없음
🔴 하지 말 것 PERSON_ELIGIBLE_THRESHOLD 자체를 낮추지 말 것 — selector 동작 기준이라 B-1~B-6 전부 무효입니다

⚠️ 합성 닻은 「옳은 사람」이 아니고 새 15건엔 라벨이 없어 판정 회차가 아닙니다. 🔴 다만 위 「버려졌다」 결론은 대리 지표에 기대지 않습니다 — 점수와 IoU 는 세면 나옵니다.

3회차 — 보수적 갱신: 진단이 맞았고, 세 건 다 좋아졌습니다 (2026.09.09) · 여전히 불합격

2회차가 지목한 병목(정지 히스토그램)의 가운데를 시험했습니다 — 의심스럽지 않을 때만 참조 외양을 조금씩 옮깁니다. 근거: agent/eval/pending18_conservative/ · 사전 등록 ab5c733.

🔴 사전 예측이 맞았습니다 — K 가 41 → 1. 사전 등록에 「K 가 41보다 뚜렷하게 작아질 것」이라 적어 두었는데, 보정용 7건에서 app < 0.6 연속 구간이 0프레임이 됐습니다. 갱신을 켜자 표류가 통째로 사라졌습니다 — 2회차의 진단이 실측으로 확인됐고, O2GSaYqH8JY 가 갈아탐이 아니라 표류였다는 판단도 함께 확인됐습니다.

클립 1회차 재가중 2회차 거부 3회차 보수적 갱신
3R1kvNrGJK0 0% 74% 99%
Fz16t9SrF3U 0% 0% 53%
sGKeqfxwq5E 0% 0% 54%

둘이 0%에서 벗어난 것이 이번의 실질입니다. 다만 53·54%는 사전 등록한 70% 바에 못 미칩니다. 🔴 바를 내려서 통과시키지 않았습니다 — 그러면 사전 등록이 아무것도 아니게 됩니다.

기준 결과
A 자동 경로 비트 동일 ✅ — B-6 재실행 없음
B 보정용 7건 유지 ✅ 7/7
C 70%+ 감소 2건+ 🔴 1/3
D 커버리지 50%+ 🔴 미달 — 3R1kvNrGJK0 42%

🔴 D가 걸린 것은 기준이 제대로 일한 것입니다

3R1kvNrGJK0 은 엉뚱 프레임을 190 → 2 로 줄였는데 커버리지가 42%로 떨어졌습니다. 엉뚱한 사람을 안 보게 된 대신 아무도 안 보는 프레임이 늘었습니다. D 는 정확히 그 길(「전부 거부」가 만점을 받는 것)을 막으려고 넣은 기준이고, 설계대로 걸렸습니다.

원인은 K = 1 이고 뿌리는 보정 규칙입니다. 「깨끗한 클립이 안 깨지는 가장 짧은 값」은 하한이지 튼튼한 값이 아닙니다 — 같은 규칙이 2회차엔 41, 3회차엔 1이라는 양극단을 냈습니다.

다음 후보: 거부·재획득에 이력(hysteresis) · K 를 하한이 아니라 분위수+여유로 · 거부해도 커버리지를 지키는 길(자동 박스로 떨어지되 source 를 낮추기). 🔴 상수를 손으로 고쳐 다시 돌리지 않았습니다.

⚠️ 같은 시험 표본을 세 번째 쟀습니다. 여러 번 시험하면 우연히 통과할 확률이 올라갑니다 — 다음에 합격이 나와도 구현 회차에는 이 3건에 없던 클립이 있어야 합니다(사전 등록에 미리 적었습니다).

2회차 — 거부·재획득: 1건은 고쳤습니다. 막은 것은 정지 히스토그램입니다 (2026.09.09)

1회차가 가리킨 방향(재가중이 아니라 거부·재획득)을 새로 사전 등록하고 쟀습니다. 근거: agent/eval/pending18_reacquire/ · 사전 등록 807af0a.

🔴 자유도가 넷인데 갈아탄 클립이 실질 2건이라, 문턱을 깨끗한 7건에서만 정하고 갈아탄 3건은 그 문턱으로 한 번만 쟀습니다. 시험용을 보고 문턱을 고치면 측정이 아니라 외우기입니다.

기준 결과
A 자동 경로 비트 동일 ✅ — B-6 재실행 없음
B 보정용 7건 기준선과 동일 ✅ 7/7
C 엉뚱 프레임 70%+ 감소 2건+ 🔴 1/3 — 불합격
D 커버리지 50%+ 유지 ✅ (「전부 거부」로 이긴 것이 아닙니다)
클립 엉뚱(현) 엉뚱(거부) 감소 커버리지
3R1kvNrGJK0 190 49 74% 58% 유지
Fz16t9SrF3U 49 49 0% 100%
sGKeqfxwq5E 76 76 0% 100%

되긴 됩니다. 가장 심한 클립에서 엉뚱한 사람으로 분석된 프레임이 190 → 49 로 줄었고 커버리지를 58% 지키면서 그렇게 됐습니다 — 방향 자체는 살아 있습니다.

🔴 나머지 둘이 0%인 이유는 처방이 아니라 보정 규칙입니다

「깨끗한 7건이 하나도 안 끊기는 가장 짧은 길이」가 41프레임으로 나왔습니다. 그래서 41프레임보다 짧게 어긋나는 둘은 거부가 발동조차 안 했습니다.

K 를 그렇게 끌어올린 것은 깨끗한 O2GSaYqH8JY 의 40프레임 연속 구간인데, 열어 보니 갈아탄 것이 아니라 같은 사람이 표류한 것이었습니다.

클립 닻 기준 최소 직전 프레임 기준 최소
O2GSaYqH8JY (깨끗) 0.49 0.85 ← 끊긴 적 없이 서서히 변함
3R1kvNrGJK0 (갈아탐) 0.08 0.65
Fz16t9SrF3U (갈아탐) 0.17 0.32

🔴 그래서 진짜 병목 — 정지 히스토그램의 딜레마

  대가
히스토그램을 갱신하면 갈아탄 뒤의 외양을 배워 잘못된 대상에 고착 (1회차가 피한 것)
갱신 안 하면 옳게 따라가는 트랙도 시간이 지나 닻에서 멀어져 문턱을 41까지 올려야 안 깨짐 (이번 회차)

둘 다 실패 방향이 있고 지금 설계는 한쪽 끝에 서 있습니다. 다음 후보는 보수적 갱신(app 이 높을 때만 갱신)이고, 두 뿔을 함께 풉니다. 🔴 K 를 낮춰 다시 돌리지 않았습니다 — 새 사전 등록이 붙어야 합니다.

🔴 처방 (가) 외양 모델을 쟀습니다 — 재가중으로는 원리적으로 안 됩니다 (2026.09.09)

배선이 끝나 남은 것이 처방 하나였으므로 (가)를 사전 등록하고 쟀습니다. 근거: agent/eval/pending18_appearance/ · 사전 등록 19f248f(프로토타입 코드보다 먼저) · GPU 불필요.

사전 등록 기준 결과
A 자동 경로 비트 동일 ✅ — 지정 없으면 같은 객체를 그대로 반환. B-6 재실행 없음
B 깨끗한 7건 유지 ✅ 7/7
C 갈아탄 3건 중 2건+ 수리 🔴 0/3 — 불합격
D 갈림이 줄지 않을 것 ✅

🔴 값이 한 자리도 안 바뀌었습니다 — 그래서 팠습니다

「덜 고쳤다」가 아니라 10건의 점프·갈림이 기준선과 완전히 동일했습니다. 보통 그러면 버그라서, 부족했다고 넘기지 않고 기전을 찾았습니다.

점프가 난 프레임에 고를 것이 하나뿐이었습니다.

클립 점프 겹치는 후보 전체 후보
3R1kvNrGJK0 f110 (넓이 5.5배) 🔴 1개 8개
sGKeqfxwq5E f169 (넓이 2.6배) 🔴 1개 7개
Fz16t9SrF3U f111 0개 → 끊김 (가림 계열)

타자의 박스가 그 프레임에서 후보 목록에 없고, 나머지 후보는 직전 박스와 IoU 가 정확히 0.00 이라 애초에 이어갈 수 없습니다. 남는 것이 심판 박스 하나뿐이라 트랙은 고를 여지 없이 그리로 갑니다.

곱셈 가중치는 후보가 하나면 argmax 를 바꿀 수 없습니다 — IoU × f(app) 에서 후보가 하나면 그것이 최대입니다, f 가 무엇이든. 🔴 「가중치를 잘못 골랐다」가 아니라 「이 형태로는 안 된다」입니다. E-1·E-2 를 구현 전에 걸러낸 것과 같은 종류의 결론입니다(미결 7번).

🔴 이 항목이 적어 둔 기전을 고칩니다

위에 원인을 「크고 가만히 선 사람이 IoU 흡인원」 이라고 적었는데, 맞지만 충분하지 않습니다. 흡인이 일어나려면 먼저 진짜 대상이 후보에서 사라져야 합니다. 맞게 적으면:

「검출 구멍 → 유일한 후보로 강제 이동 → 되돌아오지 못함」

세 번째 마디가 특히 중요합니다 — 한 번 옮겨 가면 새 대상이 자기 자신과 IoU 0.9+ 로 이어져 영원히 고착됩니다.

외양 신호 자체는 잘 갈립니다 — 쓸 자리가 없었을 뿐입니다

선택된 트랙의 외양 유사도 중앙값이 깨끗 0.81~0.98 대 갈아탐 0.26~0.72 입니다. 🔴 다만 프레임 단위로는 겹칩니다 — 깨끗한 O2GSaYqH8JY 의 최소 0.49 가 갈아탄 sGKeqfxwq5E 의 최소와 같습니다. 문턱 하나로 자르는 설계는 여기서 오탐을 냅니다.

🔴 두 관측이 독립적으로 만났습니다. 3R1kvNrGJK0 의 「app < 0.6」이 190/300 = 63.3% 인데, 위에서 프레임을 그려 눈으로 재어 적은 63% 와 같습니다. 방법이 완전히 다른데 같은 값입니다.

다음 — 재가중이 아니라 거부·재획득

🔴 여기서 구현하지 않았습니다. 사전 등록이 재가중 하나를 재기로 했고, 새 설계에는 새 사전 등록이 붙습니다. 방향만 남깁니다.

   
필요한 것 유일한 후보라도 외양이 어긋나면 거부하고(끊김으로 보내고) 대상이 다시 나타나면 재획득
왜 지금 구조에는 「아무것도 고르지 않는다」는 선택지가 없습니다 — 겹치는 후보가 하나라도 있으면 반드시 고릅니다
🔴 위험 문턱이 낮으면 위 겹침 때문에 깨끗한 클립이 끊깁니다. 프레임 하나가 아니라 연속 N프레임을 봐야 할 가능성이 큽니다
공짜인 것 자동 경로를 안 건드리므로 B-6 재실행 없음이 유지됩니다

⚠️ 한계: 갈아탄 것이 3건이고 그중 하나는 가림 계열이라 실질 표본이 2건입니다. 다만 이 회차의 결론(재가중은 원리적으로 안 된다)은 판독에 기대지 않습니다 — 후보가 1개라는 것은 세면 나옵니다.

🔴 에이전트 쪽은 열었습니다 (2026.09.04, 커밋 56e0ada) — 가운데 두 칸이 남았습니다

「언제 할 것인가」에 “지금은 아니다, 하나만 떼어낸다”라고 적어 두었는데, 파이프라인에 이 단계를 넣기로 결정되어(사용자) 에이전트 쪽 입구까지 함께 열었습니다. 계약이 정해지기 전에도 되고, 지정이 없으면 동작이 한 비트도 안 바뀝니다.

구간 상태 소유
화면에서 박스 지정 ✅ 있었다 (AnalysisStage.tsx) 백성검
이 사람으로 분석 → S3 저장 ❌ 다른 버튼이다 — 저장이 disabled={!done}이라 가짜 리포트가 끝나야 눌린다 백성검
POST /videos 본문 ❌ 박스가 없다 정어진
큐 소비자 → 워커 ❌ 없다 (17번) 정어진
에이전트 입구·기록 ✅ 열었다 정상호

에이전트가 지금 받는 것 — HTTP와 CLI 둘 다:

POST /api/analyze/video?subject_box=0.39,0.35,0.12,0.4&subject_at_ms=4200
uv run python scripts/analyze_s3.py <s3키> --rubric … \
    --subject-box 0.39,0.35,0.12,0.4 --subject-at-ms 4200

🔴 화면 픽셀 좌표는 422로 거부합니다. 조용히 클램프하면 엉뚱한 사람을 분석하고도 “지정대로 했다”고 답하게 됩니다. 정규화 0~1만 받습니다.

결과 봉투에 subject 블록이 생겼습니다 — source가 auto/specified/ fallback이고, 못 맞췄으면 why에 이유가, 그리고 프레임별 선택 박스 시계열이 정규화 좌표로 들어 있습니다. 관측(12번)에도 평면 스칼라로 subject_source·subject_fallback·subject_anchor_iou 등이 남습니다.

🔴 정어진 님께 — 박스를 S3로도 보내 주셔야 합니다

분석은 supersub-ai EC2에서 도는데, 재시작마다 퍼블릭 IP가 바뀌므로 (이번에 실제로 겪었습니다: <이전 IP> → <이전 IP 2>) “백엔드가 EC2에 요청” 방식은 매번 깨집니다. EC2가 S3를 폴링하는 pull 쪽이 자연스러운데, 그러면 EC2는 백엔드 DB를 보지 않습니다(IAM도 S3만 열려 있습니다).

즉 subject_box·subject_at_ms가 DB에만 저장되면 워커가 그 값을 볼 방법이 없습니다. 영상과 함께 S3에 올라와야 합니다 — 사이드카 JSON (videos/<키>.subject.json)이든 객체 메타데이터든 방식은 자유입니다. 브라우저가 S3에 직접 PUT하므로 지금 IAM 정책(EC2는 videos/ 읽기 전용)을 건드리지 않습니다.

✅ 사이드카를 올려도 안전해졌습니다 (2026.09.04, b899e65). 스캐너가 접두사 아래를 훑을 때 영상 확장자만 고르므로, videos/<UUID>/ 안에 JSON 이 함께 있어도 그것을 분석에 넘기지 않습니다. 17번 「제가 한 것」 참고.

그리고 🔴 새 워커 프로세스 이름을 autostop.conf의 BUSY_PATTERN에 추가해 주세요 → 🔴 이 요청을 정정합니다 (2026.09.08). 넣으면 폴링 프로세스가 큐가 빈 동안에도 “작업 중”이라 인스턴스가 영영 안 꺼집니다. 워커가 analyze_s3.py를 별도 프로세스로 부르므로 목적(“분석 도중에 안 꺼진다”)은 기존 패턴으로 이미 지켜집니다. 근거는 jin 18번의 「BUSY_PATTERN 은 늘리지 않았습니다」.

   
확인 uv run python scripts/analyze_s3.py --help 에 --subject-box가 보이면 에이전트 쪽은 된 것입니다

🔴 실클립으로 확인했습니다 — 닻은 되고 이어가기는 신뢰할 수 없습니다 (2026.09.04)

3R1kvNrGJK0(다인 클립, 자동이 심판/포수를 잡는 것으로 알려진 클립)을 T4에서 자동·지정 두 번 돌려 비교했습니다. 절차와 산출: agent/eval/subject_pick/

된 것 — 지정이 실제로 자동을 뒤집습니다.

   
닻 프레임 90 (지정 3000ms, 격자 어긋남 0.0)
찍은 박스와 고른 후보의 IoU 0.805 — 펜스 너머 타자를 제대로 잡았습니다
자동이 고른 박스 (1148, 200, 217, 524) — 심판
지정이 고른 박스 (944, 303, 134, 341) — 타자
둘 사이 IoU 0.000 (완전히 다른 사람)

🔴 실패한 것 — 트랙이 도중에 심판으로 갈아탔습니다.

자동과 갈린 프레임이 110/300(37%) 뿐인데, 구간이 딱 나뉩니다 — 0~109는 갈리고 110~299는 자동과 동일합니다. 프레임을 그려 확인했습니다: 105프레임에서 타자가 심판 앞을 지나며 두 박스가 겹치고, 112프레임부터 선택이 심판 위에 있습니다. 150·250프레임에서 타자는 화면 오른쪽에서 뛰고 있는데 선택은 계속 심판입니다.

즉 63%의 프레임을 찍지 않은 사람으로 분석했습니다.

🔴 continuity_breaks가 이 실패를 구조적으로 못 잡습니다 — 0이었습니다

그 카운터는 “겹치는 후보가 하나도 없다”를 셉니다. 그런데 신원이 바뀔 때는 겹침이 넉넉합니다. 끊긴 적이 없으니 0이고, 0을 “잘 따라갔다”로 읽으면 정확히 반대로 읽는 것입니다.

원인은 IoU 탐욕 추적의 비대칭입니다 — 크고 가만히 선 사람이 IoU 흡인원입니다. 타자는 빠르게 움직여 자기 직전 박스와의 IoU가 낮아지는데, 심판은 서 있어서 근처 무엇과도 잘 겹칩니다. 프론트의 personTrack.ts가 움직임·겹침에 색 히스토그램까지 쓰는 것이 우연이 아니었습니다.

이번에 한 것과 하지 않은 것

  • 한 것: 인접 프레임에서 선택 박스 넓이가 2배 이상 뛴 횟수를 area_jumps(결과)·subject_area_jumps(관측)로 냅니다. 이 사례에서 정확히 갈아탄 자리를 집었습니다 — 지정 트랙은 area_jumps = 1이고 그 한 번이 110프레임, 넓이비 5.5배입니다. 같은 클립의 자동 트랙은 0입니다. 🔴 막는 값이 아니라 세는 값입니다 — 카메라로 다가오면 넓이는 실제로 커지므로 이걸로 후보를 거르면 정상 동작을 막습니다. 그리고 한 클립에서 맞았다고 신호가 검증된 것은 아닙니다
  • 안 한 것: 임계값을 손봐 이 클립을 통과시키지 않았습니다. 한 클립에 맞추는 것이고, 이 프로젝트가 반복해서 겪은 “동작점을 옮긴 것을 나아진 것으로 착각”하는 형태입니다

🔴 그래서 source: "specified"를 전 구간 보장으로 읽으면 안 됩니다

지금 그 값의 뜻은 “닻 프레임에서 찍은 사람을 찾았다” 까지입니다. 화면에 “지정하신 분으로 분석했습니다”라고 쓰면 위 63%에서 거짓말이 됩니다. 붙이신다면 area_jumps를 함께 보고 “도중에 다른 사람으로 바뀌었을 수 있습니다” 를 남겨 주세요 (백성검 님·정어진 님).

다음 처방 — 아직 고르지 않았습니다

   
가. 외양 모델 (색 히스토그램) 프론트 personTrack.ts가 이미 그렇게 하고 있습니다. 새 의존성이 없고 겹침만으로는 부족하다는 결론을 이미 낸 코드입니다
나. 크기·이동 일관성 넓이 비·예측 위치를 매칭 점수에 넣습니다. 값이 임의적이라 정답 없이 고르기 어렵습니다
다. 전용 트래커 🔴 ultralytics가 AGPL-3.0이라 15번이 먼저 풀려야 합니다

🔴 어느 쪽이든 정답이 없으면 “나아졌다”고 말할 수 없습니다. 다만 verify_pick.py가 “갈렸는가”와 “닻에서 겹쳤는가”는 정답 없이 재므로, 처방을 넣기 전에 클립을 늘려 얼마나 자주 갈아타는지부터 세는 것이 순서입니다. 그것을 아래에서 했습니다.

🔴 다인 클립 10건 전수 — 갈아탄 의심 3/10 (2026.09.04)

평가셋 39클립 중 single_or_multi = 다인인 10건 전수입니다. 박스는 프레임 60을 보고 그렸고, 다시 그려 넣어 눈으로 확인했습니다. 산출: agent/eval/subject_pick/picks/

clip source 닻 IoU 갈림 점프(지정) 점프(자동) 끊김
3R1kvNrGJK0 🔴 uncertain 0.805 37% 1 0 0
Atzrde5uGcM specified 0.609 7% 0 0 0
Fz16t9SrF3U 🔴 uncertain 0.714 15% 2 0 2
GS-PcxmaHmQ specified 0.559 47% 0 0 0
IYFifBJ9lH8 specified 0.491 14% 0 0 1
O2GSaYqH8JY specified 0.435 11% 0 0 0
ZMy0t-CSZiU specified 0.522 0% 0 0 0
cDRi9AzrapA specified 0.484 0% 0 0 0
sGKeqfxwq5E 🔴 uncertain 0.317 63% 1 0 0
xMIUw5mi3Eo specified 0.526 0% 0 0 0

닻은 10/10 성공했습니다 — 폴백이 하나도 없습니다. 눈으로 그린 헐거운 박스(IoU 0.317~0.805)로도 후보를 다 찾았고, MIN_ANCHOR_IOU = 0.3은 이 표본에서 아무것도 막지 않았습니다(sGKeqfxwq5E가 0.317로 아슬아슬).

지정이 아무것도 안 바꾼 클립이 3건입니다(갈림 0%). 타자가 곧 가장 큰 사람이라 자동과 결과가 같습니다 — 다인이라고 다 갈리는 것이 아닙니다.

🔴 의심 신호를 눈으로 검증했습니다 — 5건에서 어긋남 0

area_jumps가 진짜 갈아탐을 집는지를 프레임을 그려 확인했습니다.

클립 신호 실제 (그려서 확인)
3R1kvNrGJK0 점프 1 ✅ 110프레임에서 타자 → 심판
Fz16t9SrF3U 점프 2 ✅ 110에서 박스가 조각나고 123에서 심판으로
sGKeqfxwq5E 점프 1 ✅ 169프레임에서 타자 → 포수
GS-PcxmaHmQ 점프 0 ✅ 갈림 47%인데 끝까지 타자를 유지 (자동은 코치)
IYFifBJ9lH8 점프 0 ✅ 멀어지며 박스가 점진적으로 줄 뿐 같은 사람

잡은 3건이 전부 진짜였고, 안 잡은 2건이 전부 진짜 깨끗했습니다. 표본 5건에서 오탐·미탐이 0입니다.

🔴 그래도 “검증된 신호”라고 쓰지 않습니다. 5건이고, 박스를 제가 그렸으며, 제가 그 프레임을 판독했습니다 — AI 단독 판독은 정답으로 승격하지 않는다는 규칙이 여기에도 걸립니다. 말할 수 있는 것은 “지금까지 어긋난 사례가 없다” 까지입니다.

그래서 30%를 어떻게 읽어야 하나

  • “다인 클립의 30%에서 트랙이 갈아탄다” — 이 10건 표본에서는 그렇게 보입니다. 다만 갈아탄 3건 중 2건은 눈으로도 확인했고 1건은 Fz16t9SrF3U도 확인했으니 셋 다입니다
  • 🔴 “사용자 트래픽의 30%”가 아닙니다. 이 10건은 Kinetics에서 온 경기 중계·연습 영상이고, 서비스에 올라올 생활체육 영상의 인원 분포는 아직 모릅니다(미결 8번의 관측이 그것을 재려고 있는 값입니다)
  • 분모가 더 작습니다 — 갈림 0%인 3건은 지정이 무의미했으므로, “지정이 실제로 일을 한 7건 중 3건(43%)에서 갈아탔다”가 더 가까운 읽기입니다

🔴 주장을 사실에 맞췄습니다 — specified_uncertain

추적은 아직 못 고쳤습니다. 그래서 못 고친 것을 했다고 말하지 않게 했습니다: area_jumps > 0이면 source가 specified_uncertain 으로 내려가고 why에 사유가 붙습니다. 위 표의 3건이 그렇게 나옵니다.

auto는 내리지 않습니다 — “이 사람을 따라갔다”고 주장한 적이 없어서 낮출 주장이 없습니다(넓이 점프는 그래도 셉니다).

   
확인 uv run python eval/subject_pick/verify_pick.py <영상> --box x,y,w,h --at-ms N 로 한 건, summarize_picks.py <폴더> 로 표
하지 말 것 continuity_breaks == 0을 “잘 따라갔다”로 읽지 마세요. 갈아탄 3건 중 2건이 끊김 0이었습니다
하지 말 것 area_jumps로 후보를 거르지 마세요 — 세는 값입니다. 카메라로 다가오면 넓이는 정당하게 커지고(IYFifBJ9lH8이 그 예), 가림으로 조각나기도 합니다(Fz16t9SrF3U)

이것은 결함이 아니라 새 기능이다. 지금까지의 항목은 있는 동작이 틀렸다는 것이었는데, 이건 아직 없는 것을 만들지 말지의 문제다.

현황 — 화면에는 있고 서버로는 안 나간다

   
어디에 www/src/components/analysis/AnalysisStage.tsx (백성검 영역). api.py의 인라인 JS가 아니다
무엇을 박스다 — 끌어서 그린 네모(dragStart/dragMove/dragEnd, 짧은 탭은 MIN_BOX=0.04로 걸러낸다)
어느 프레임에서 사용자가 고른다. 묶는 동안 판 안에 재생·탐색 컨트롤을 따로 두었고, 긋기 시작하면 영상이 멈춘다
좌표계 원본 기준 0~1이다. toVideoBox가 object-fit: contain의 레터박스 여백을 이미 걷어낸다
시각 subject.at = video.currentTime 밀리초
자동 선택지 ‘자동으로 고르기’ 버튼이 있다 — box: null이고 계약의 side 미지정과 같은 뜻이다
🔴 전송되는가 안 된다. POST /videos 본문은 sport_code·storage_key·duration_ms·width·height 다섯이고 대상 박스가 없다. side조차 안 보낸다

대신 브라우저가 혼자 따라간다. lib/personDetector.ts(MoveNet MultiPose Lightning, tfjs)로 매 프레임 사람을 검출하고 lib/personTrack.ts가 움직임 + 겹침 + 색 히스토그램으로 내 사람을 잇는다. 손으로 그린 네모를 검출된 사람에 맞춰 주는 snapToDetection도 이미 있다. 즉 방식 1이 프론트에는 이미 구현돼 있다 — 서버에 없을 뿐이다.

전 프레임 추적 — 세 방식

드래그는 한 프레임인데 분석은 전 프레임이다.

  구현 규모 기존 코드 재사용 실패 모드 라이선스
1. 후보 매칭 + continuity 작다. _largest_person_box를 “직전 선택(또는 지정 박스)과의 IoU를 가점으로 쓰는” 선택자로 넓히는 것 하나 eval/phaseA/eval_selectors.py:66의 mode B. 다만 그 파일의 W_B는 pose_quality 항을 뺀 뒤 정규화한 임시값이고 production 가중치가 아니다(eval_b1/eval_config.json이 그렇게 적어 두었다) 첫 매칭이 틀리면 continuity가 그 오류를 굳힌다 — 8번이 이미 관측한 성질이다. 사람이 찍으면 그 전제가 참이 되지만, 찍은 프레임에 후보가 없으면 그대로 무너진다 새 의존성 0. Apache-2.0 그대로
2. 전용 트래커(ByteTrack 등) 중간. 도입 + ID 수명 관리 없다 가장 적다. 대신 틀렸을 때 되짚을 표면이 넓어진다 🔴 여기서 막힌다. ultralytics가 AGPL-3.0이고(15번) EC2에 --extra tracking을 일부러 안 깔았다. 서비스 경로에 넣으려면 소스 공개 의무 판단이 먼저다
3. 박스에 직접 포즈 첫 프레임만 보면 최소 없다 단독으로 성립하지 않는다 — 다음 프레임 박스가 없어 1이나 2가 여전히 필요하다. 게다가 ViTPose는 top-down이라 박스 안에 사람이 있다고 가정하고 무조건 관절을 낸다. 엉뚱한 박스를 줘도 신뢰도로 안 드러난다 무변화

권고 — 1번. 새 의존성이 없고, 라이선스가 안 열리고, 규칙이 이미 오프라인 평가를 통과한 유일한 것이며(B-1·B-2), 실패하면 지금 동작(_largest_person_box) 으로 그대로 떨어질 수 있다.

⚠️ 다만 eval_selectors.py는 “production을 읽지도 import하지도 않는다”는 규칙으로 쓰인 복제본이다. 그 수식을 서비스로 옮기면 같은 규칙이 두 벌이 되고, 그것이 미결 10번이 낸 사고의 형태다. 옮길 때 어느 쪽이 정본인지 먼저 정할 것.

좌표·프레임 매핑

  • 좌표는 이미 맞다. 프론트가 정규화 0~1로 보내면 백엔드는 x*W, y*H만 하면 된다. RT-DETR도 post_process_object_detection(target_sizes=[(h,w)]) 라 같은 픽셀 좌표계이므로 IoU를 바로 잴 수 있다. 🔴 표시 해상도를 그대로 보내면 안 된다 — 이미 그렇게 안 되어 있으니 계약에 “정규화 좌표”라고 못박을 것
  • 프레임은 labels.json 재매핑과 같은 산술의 다른 방향이다. k = round(at_ms/1000 × sampled_fps), 원본 프레임 = k × step. targets.py:103 remap_label_frame은 격자→격자이고 이쪽은 시각→격자인데, 같은 함정 하나를 공유한다 — 그 순간이 격자에 없을 수 있다. targets.py는 그럴 때 예외를 던지지만 서비스는 던지면 안 된다. 가장 가까운 격자 프레임으로 붙이고 그 사실을 기록한다(어긋남은 최대 step/2 프레임)
  • 극단 비율은 지금은 못 만난다. 규격 검사가 width > 1920 or height > 1080을 반려하므로 4K도, 1080x1920 세로도 서비스 경로에 못 들어온다 (fastapi/app/analysis/domain/rules/video_rules.py:76). 상한을 손대는 순간 열린다
  • 🔴 회전 메타데이터는 확인하지 않았다. 브라우저의 videoWidth/videoHeight는 회전을 반영한 값이고 cv2.VideoCapture가 같은지는 안 봤다. 폰 세로 촬영에서 둘이 갈리면 x/y가 통째로 뒤바뀐다. 위 상한을 올리기 전에 실측할 것

실패 케이스와 폴백

케이스 처리
찍은 자리에 후보가 없다 (O2GSaYqH8JY 형태 — 그 클립은 라벨된 프레임의 후보가 전부 1개였다) 앞뒤 프레임으로 창을 넓혀 재시도(프론트가 WAIT_MAX=20으로 하는 것과 같은 발상). 끝내 없으면 자동 선택으로 떨어지되 그 사실을 응답에 담는다. 🔴 조용히 떨어지면 사용자는 자기가 찍은 대로 분석된 줄 안다
추적이 끊긴다 지금 파이프라인엔 “끊김”이 없다 — 프레임마다 독립이고 후보 0개면 zeros((17,3))이다. continuity를 넣으면 직전 박스를 언제 버릴지 규칙이 생긴다. eval_selectors.py:79-81이 “후보 0개 프레임에서 연속성을 끊는다”로 이미 정해 뒀으니 그대로 가져오는 것이 안전하다
두 사람이 겹친 곳을 찍었다 최댓값만 고르지 않는다. 2위와의 마진을 보고 마진 미만이면 애매한 것으로 처리한다(프론트의 SWITCH_MARGIN이 같은 발상). 애매하면 되묻거나 자동으로 떨어진다
찍은 시각이 분석 창 밖이다 업로드 상한이 60초인데 에이전트 창은 10초다(target 30 · DEFAULT_MAX_SECONDS). 35초 지점을 찍으면 그 프레임이 아예 없다. jin 구역 11번과 같은 축이고, 그 항목이 20초라고 적은 것은 옛 동작점 값이다

API 계약

  • api_video에 선택 인자 둘: 정규화 박스와 그 시각. 지금의 rubric·side 옆에 나란히 붙는다. 미지정이면 지금 동작(_largest_person_box)이 그대로다 — side와 같은 규칙이다(“사람이 지정할 수 있게 열어 두고, 없으면 자동”)
  • side와 직교한다. side는 대상을 고른 뒤 그 사람의 어느 팔·발인지고 (6번), 이건 누구인지다. 넷 조합이 전부 유효하다
  • 🔴 응답이 무엇을 실제로 썼는지 돌려줘야 한다 — 사람 지정인지, 자동인지, 지정이 실패해 자동으로 떨어졌는지. 이것 없이는 아래 「확인 방법」이 성립 안 한다
  • 서비스 계약(POST /videos)에도 같은 필드가 필요한데 그 계약은 정어진 영역이다. 17번의 「가~라」가 먼저 풀려야 붙일 자리가 생긴다

🔴 확인 방법 — “찍은 사람이 실제로 분석됐는가”

기능을 넣으면 검증 부담이 생긴다. 지금 구조로는 자동 확인이 불가능하다.

  • PoseResult.candidate_counts는 선택 이전 관측이고, 주석이 “누가 대상이었는지는 여기서 알 수 없다”고 명시한다(pose.py:126-128). 선택된 박스가 결과에 안 남는다
  • 그래서 최소 전제는 선택 박스 시계열을 결과에 남기는 것이다. 그러면 (1) 지정 프레임에서 지정 박스와 선택 박스의 IoU를 재서 기계적으로 확인하고 (2) render_tracked_clip이 이미 있으므로 스켈레톤이 누구에게 붙었는지 눈으로 본다
  • 회귀 기준: 지정이 없을 때 결과가 지금과 소수 끝자리까지 같아야 한다

미결 8번은 격하되는가 — 아니다

  1. 평가 경로는 여전히 auto다. B-1~B-6도 6번 판독도 39클립 전부 사람 지정이 없다
  2. 서비스 경로도 대부분 auto다. ‘자동으로 고르기’가 있고, S3 직접 업로드(17번)와 워커 경로에는 사람이 아예 없다. 사람 지정은 한 경로의 한 선택지다
  3. 지정이 있어도 selector가 필요하다. 지정은 한 프레임이고 나머지는 자동으로 이어야 한다 — 그것이 곧 continuity고, A-pose냐 B-pose냐 그 질문 자체다. 지정은 그 질문의 초기값을 참으로 만들 뿐 질문을 없애지 않는다

바뀌는 것: 8번이 “continuity를 언제 신뢰할 것인가“로 방향을 튼 것과 정확히 같은 방향이다. 사람 지정은 “처음 잡은 대상이 맞다”를 보장하는 장치이므로 continuity 쪽에 유리한 논거가 된다 — 그러나 A/B를 가르는 실험 결과는 아니다.

340건 산정은 그대로 유효하다. 8번이 “고를 것이 없는 판정이 몇 건이나 섞였는지 안 셌다”고 남겨 둔 것을 셌다.

   
340건의 근거(B-4) A와 B가 갈린 프레임에서 나왔다. 그런데 후보가 1개면 세 selector가 코드상 같은 것을 고른다(eval_selectors.py:83-84) → 불일치 집합에 단일후보는 구조상 0건이다
프레임 정확도 쪽(B-1 라벨 97건) 39건(40.2%)이 단일후보다. 36클립 중 11클립은 라벨된 프레임이 전부 단일후보다

즉 검정력 산정은 오염되지 않았고, 오염된 것은 프레임 정확도 쪽이다 — 거기서 “selector가 맞혔다”의 40%는 고를 것이 없어서 맞은 것이다. O2GSaYqH8JY가 그 한 사례다.

라벨 수집 — 될 수도 있는데 지금은 한 건도 안 쌓인다

드래그 하나가 subject 라벨 하나다(프레임 + 박스 + 누가). 8번·6번이 못 구한 사람 라벨(2번 병목)이 사용자 트래픽에서 나온다 — 다만 브라우저 밖으로 안 나가므로 지금은 0건이다.

정답으로 승격할 수 있는가는 미정이다. B-5 선례(“AI 단독 판독은 승격 불가”)의 사유에는 안 걸린다 — 사람이 찍은 것이니까. 그러나 다른 사유가 있다.

  • 사용자는 훈련된 라벨러가 아니다. “누구를 분석할지”는 선호일 수 있고 정답과 다르다
  • 이해관계자다. 자기 영상에서 자기를 찍는다 — 정확할 근거이면서 편향의 근거다
  • 한 프레임뿐이다. 나머지 프레임의 정답은 여전히 없다

승격 기준을 정하려면 최소한 (i) 같은 클립을 둘 이상이 찍었을 때의 일치율 (ii) 지정과 auto가 갈린 비율 (iii) 사람이 재검수한 표본이 필요하다. (iii)은 여전히 사람 검수자를 요구한다(2번). 지금 정할 것은 승격 여부가 아니라 수집을 시작할지다 — 안 쌓으면 나중에 기준이 생겨도 소급이 안 된다.

다른 항목과의 관계

항목 영향
6번(스윙 측) 없다. 대상을 고른 뒤의 문제다. 다만 둘 다 “auto가 못 미더워 사람이 지정한다”는 같은 형태라 계약에서 나란히 서야 한다
9번(host RAM) 없다. 후보 매칭은 이미 낸 검출 결과를 쓰므로 추론도 메모리도 안 는다. 선택 박스 시계열은 300×4 float다
12번(관측 sink) 🔴 여기에 추가해야 한다 — 지정이 있었는가 · 자동으로 떨어졌는가와 그 사유 · 지정 프레임이 창 안이었는가 · 매칭 IoU. 이것이 있어야 “사람 지정이 얼마나 자주 실패하는가”를 정답 없이 잰다. build_record는 평면 구조 계약이 있으므로 스칼라로만 넣을 것
15번(AGPL) 방식 2를 고르면 열린다. 방식 1은 안 열린다
17번(S3 큐) 워커 경로에는 사람이 없다 — 그쪽은 영영 auto다
jin 11번(60초 vs 창) 지정 시각이 창 밖일 수 있다. 실제로 난다

언제 할 것인가 — 지금은 아니다. 하나만 떼어낸다

받아 갈 곳이 아직 없다. S3에 올라와도 분석이 안 돌고(17번), 계약 변경은 남의 영역이라 17번의 「가~라」가 먼저다. 지금 만들면 쓰이지 않는 인자가 하나 는다.

다만 최소 전제 하나는 지금 해도 된다 — 선택된 박스를 결과와 관측에 남기는 것. 작고 독립적이고, 이 기능이 없어도 8번의 관측이 같이 좋아진다(지금은 누가 대상이었는지 사후에 알 방법이 없다).

   
확인 grep -n "body: JSON.stringify" -A 8 www/src/components/analysis/AnalysisStage.tsx 에 대상 박스가 보이면 프론트가 이미 보내고 있는 것이다. grep -n "def api_video" -A 3 agent/src/supersub_agent/api.py 가 rubric·side 말고 더 받으면 백엔드가 이미 받는 것이다 — 2026.09.04 기준 둘 다 아니다
하지 말 것 지정 실패를 조용히 자동으로 떨어뜨리지 말 것. 사용자는 자기가 찍은 대로 분석된 줄 안다. 그리고 agent/의 selector 규칙과 eval_selectors.py의 복제본을 갈라 둔 채 양쪽을 고치지 말 것(미결 10번의 형태)
이번에 한 것 조사와 등록뿐이다. 코드는 하나도 안 고쳤다
  • 담당: 미정 · 제기: 정상호 · 기한: 미정 (17번이 풀린 뒤 다시 볼 것)

19. 종목별 공개 데이터셋 셋이 우리가 재려는 것과 안 맞는다 (2026.09.04)

🔴 2026.09.11 — 야구·농구 어댑터는 지웠습니다(39번). 아래 조사 결과는 닫힌 경로 기록으로 남깁니다 — 되살릴 일이 생겨도 같은 조사를 다시 하지 마세요. 축구(SoccerNet) 쪽 판단은 그대로 유효합니다.

축구·야구·농구 클립을 D드라이브(/mnt/d/sports_dataset)로 배치 수집해 분석하는 파이프라인을 만들었다(agent/scripts/dataset_pipeline/, 실행법은 그쪽 README). 파이프라인은 돈다. 문제는 데이터다 — 지정된 세 출처를 실제로 조회해 보니 셋 다 명세와 다르고, 셋 다 우리 파이프라인이 재려는 것을 재기에 맞지 않는다.

종목 명세 실제 지금
⚽ SoccerNet Clips-720p-10s HF SoccerNet 조직에 그런 저장소가 없다. 720p 원본은 gated=manual에 파일 미게시(NDA). 대안 SushantGautam/SoccerNet-10s-5Class(10초, 파일 하나씩 → 선택 다운로드 가능) 🔴 224p huggingface-cli login + 약관 동의하면 된다
⚾ 메타데이터 필터 후 선택 다운로드 hbfreed/Picklebot-130K 존재. 영상이 단일 28.4GB tar.xz — 파일 단위 선택 다운로드가 원리적으로 불가 CSV 필터는 된다. 영상은 28.4GB를 받아야 한다
🏀 PL-NBA pre-trimmed 100개 HF에 없다. 논문(arXiv 2608.19646)·GitHub(holhouse/PL-NBA-Dataset)은 실재하나 클립이 바이두넷디스크로만 배포 자동 불가. 사람이 받아 둔 폴더를 읽게 만들었다

카탈로그는 실제로 확인했다 — 축구 27,240건(Shots 5,456 · Goal · Foul · Throw-in · Ball out of play), 야구 12,965건(Called Strike 8,240 · Ball 4,725).

🔴 받아도 지표를 믿을 근거가 없다 — 이것이 판단이 필요한 부분이다

  • 해상도: 축구 224p · 야구 224×224. RT-DETR + ViTPose top-down 이라 화면 안에서 선수가 작으면 손목·발목을 못 믿는다
  • 프레임레이트: 야구가 15fps 다. 동작점이 30인데 미결 7번이 “15fps에서는 임팩트가 격자에 아예 없는 경우가 많아 측정 자체가 성립하지 않았다“고 적어 두었다. 클립마다 저fps 경고가 뜬다
  • 단위가 다르다: 셋 다 이벤트 클립(방송 화면·여러 선수·카메라 전환)이고 우리 루브릭은 (종목, 동작) 으로 한 선수의 한 동작을 본다(미결 3번)
  • 정답이 없다: 이벤트·판정 라벨뿐 자세 정답이 아니다. 정확도 확보 단계 문서가 정리한 네 층 중 어느 층도 이 데이터로는 안 열린다
  • 라이선스: PL-NBA 상업적 이용 금지(연구용 한정) · SoccerNet 계열 NDA — 미결 15번과 같은 축이다

🔴 그래서 이 데이터로 낸 지표를 “정확해졌다”의 근거로 쓸 수 없다. 결과 JSON 마다 그 단서를 한 줄로 박아 두었고, 산출물은 <root>/<종목>/results/에 따로 쌓여 agent/eval/로 들어가지 않는다 — 39클립 평가셋(B-1~B-6)과 섞으면 안 된다.

판단해 주실 것

   
가 이 해상도로 계속할 것인가. 224p로 낸 지표를 어디에 쓸지 정해야 한다. “파이프라인이 도는지 확인”까지면 충분하고, 지표를 근거로 쓰려면 다른 소스가 필요하다
나 야구 28.4GB를 받을 것인가. 이 출처의 성질이라 우회로가 없다. 받으면 D드라이브 212GB 중 28GB를 쓴다
다 농구를 계속 넣을 것인가. 바이두넷디스크는 사람이 받아야 하고 상업적 이용이 금지돼 있다
  • 확인: uv run python -m scripts.dataset_pipeline.run --sport basketball --rubric rubrics/basketball_jump_shot.yaml → 클립이 없으면 어디에 두라고 말하고 멈춘다(실제로 그렇게 동작하는 것을 확인했다)
  • 담당: 미정(가·다는 PM 판단) · 제기: 정상호 · 기한: 미정
🗂 후보 조사 기록 — 대안 셋·포즈 데이터셋 탐색·타격 54건. 펼쳐 봅니다

✅ 대안 셋을 검토했고, 이미 가진 클립이 더 낫다 (2026.09.04)

“Roboflow 포즈 / 농구 개인 클립 / UCF101 축구로 바꾸자”는 제안을 받아 셋 다 확인했다. 셋 다 안 된다.

제안 확인한 것 판정
Roboflow baseball pose human-baseball-strike-pose 68장 등 — 정지 이미지다 🔴 파이프라인에 못 넣는다. impact_frame·각속도·팔로스루는 전부 시계열에서 나온다. 이미지로는 어느 것도 안 만들어진다
농구 개인 클립 · SpaceJam 171×128 · 16프레임 🔴 지금 224p보다 더 작다
UCF101 축구 320×240 · 25fps. 그리고 재조사 금지 목록에 있다 — 미결 5번이 “클립 단위 라벨만, 공 검출 0건“으로 닫았다 🔴 해상도가 개선이 아니고, 공이 안 잡히면 plant_foot_to_ball_offset가 안 나온다

대신 저장소가 이미 들고 있는 것이 셋보다 낫다.

가진 것 해상도 · fps 성격
data/goldenset/soccerkicks_video 19건 522×358 ~ 1280×720, 24~30fps 페널티·프리킥 = 단독 선수 단일 동작
data/bball_shot.mp4 · bball_layup_trim.mp4 1920×1080, 24fps 슈팅·레이업
data/baseball_pitch_trim.mp4 2160×3840, 25fps 투구

돌렸다 — 축구 킥 19건 중 18건 성공 (315초, --local-dir). 1건은 품질 게이트가 “임팩트 추정 프레임(0)이 구간 경계”로 정확하게 반려했다. 🔴 공이 15/18에서 검출됐다 — UCF101이 “공 검출 0건”으로 닫힌 것과 대비된다. 결과: /mnt/d/sports_dataset/soccer/results/batch_0000.json

🔴 찾아본 결과 — 지금 당장 받을 수 있는 포즈 정답은 Penn Action 하나다

후보 동작 해상도 정답 받을 수 있나
Penn Action 야구 투구·타격·골프·테니스 등 15종, 2,326건 640×480 🟢 프레임마다 13관절 + 가시성 + 좌우 🟢 바로 — HTTP 200, 3.24GB, 인증 없음
SportsPose 야구 투구·축구·테니스·배구·점프, 24명 7카메라 🟢 3D 포즈 176k ⚠️ 신청 필요(학술용)
AthletePose3D 12개 스포츠 동작 미확인 🟢 2D+3D ⚠️ 라이선스 동의 · 비상업 한정

🔴 Penn Action 은 닫힌 경로 목록에 있다. 정확히 구분해야 한다 — 미결 5번이 닫은 것은 「릴리스 프레임 이벤트 정답의 출처」로서다(“이벤트 없음”). 여기서 쓰려는 것은 프레임별 관절 정답이고 그건 다른 질문이다. 닫힌 판정을 뒤집는 것이 아니라 다른 용도로 쓰는 것이며, 쓰기로 하면 그 구분을 근거로 남길 것.

이것이 열어 주는 것: 지금까지 ViTPose 키포인트가 맞는지 잰 적이 없다. 정확도 확보 단계 문서의 네 층은 전부 “키포인트는 맞다”를 전제로 서 있다. Penn Action 은 그 전제를 처음으로 검사하게 해 준다 — 층 1 아래의 층이다.

농구는 여전히 빈칸이다. 단독 선수 · 720p · 30fps · 공개 라이선스를 다 만족하는 농구 데이터셋을 못 찾았다. 가진 bball_shot.mp4(1080p) 한 건이 최선이다.

그래도 남는 한계: Penn Action 도 640×480이라 720p가 아니고, 어느 후보도 루브릭 등급 정답(층 4)이나 임팩트 시점 정답(층 3)은 주지 않는다. 자체 촬영이 유일한 경로라는 결론은 그대로다.

✅ 영상이 필요 없다 — 관절 시계열만으로 돌아간다 (2026.09.04)

“굳이 영상이 아니어도 된다”는 조건으로 다시 봤다. 그러면 문제가 훨씬 쉬워진다.

extract_features 가 받는 것은 영상이 아니라 (T, 17, 3) 배열이다. 영상은 그 배열을 만들려고 있을 뿐이라(RT-DETR → ViTPose), 배열을 이미 들고 있으면 그 단계가 통째로 빠지고 GPU 도 필요 없다. 그리고 features.py 가 실제로 읽는 관절은 세어 보니 12개뿐이다 — 좌우 × (어깨·팔꿈치·손목·엉덩이·무릎·발목). NOSE 는 상수만 있고 안 쓰이며 눈·귀는 아예 안 읽는다.

JHMDB 로 돌렸다 — 사람이 붙인 15관절, 928클립, 14MB, CC BY 4.0.

  클립 루브릭 결과
⚽ kick_ball 36 football_instep_shot 33/36
🏀 shoot_ball 40 basketball_jump_shot 35/40
⚾ swing_baseball 54 없다 🔴 못 돌렸다 — 아래

관절 순서는 기억이 아니라 기하로 확인했다(200클립의 관절별 중앙 y가 face → neck → 어깨 → 팔 → belly → 엉덩이 → 무릎 → 발목 순으로 정렬되고 좌우쌍이 인접). 매핑 검사 6건을 테스트에 넣었다 — 매핑이 틀리면 예외 없이 무릎각이 팔꿈치각 자리에 앉는다.

온전성 검사가 통과했다. hip_rotation_range_deg 중앙값이 축구 74.1° 대 농구 14.3° 다 — 슈팅은 골반을 크게 돌리고 점프슛은 안 돈다. 측정 사슬이 동작 차이를 기대한 방향으로 갈랐다.

🔴 이 경로가 못 하는 것 셋 (결과 JSON 에 박아 두었다):

  • 품질 게이트가 헛돈다. JHMDB 는 가림 여부를 안 줘서 신뢰도를 1.0으로 채웠다. LIMB_MIN_CONFIDENCE 가 한 번도 안 걸린다 — 영상 경로의 features_ok 와 같은 뜻이 아니다
  • 시간을 못 낸다. 원본 fps 가 주석에 없어 known: false 로 두고 초를 안 붙인다(미결 7번 E-3 — 모르는 격자에서 지어내지 않는다)
  • 공 궤적이 없다. 도구 의존 지표는 안 나오고 그 항목은 판정에서 빠진다

실패는 전부 같은 원인이다 — 임팩트가 마지막 프레임에 잡혀 경계 규칙에 걸렸다. JHMDB 클립이 15~40프레임(중앙 21~30)으로 짧아서 동작 전후가 잘려 있다. 클립 길이가 이 데이터셋의 한계다.

🔴 야구 타격 54건이 루브릭을 기다리고 있다 — 미결 3번 ✅ 해소 (2026.09.04)

루브릭을 만들어 54건을 돌렸다. agent/rubrics/baseball_batting.yaml (status: draft — 미결 3번의 “종목당 한 동작”에 야구는 투구가 이미 열려 있다. 스크립트는 status를 안 보므로 닫힌 채로 돈다). 46/54 측정 통과, 실패 8건은 전부 같은 원인(임팩트가 마지막 프레임)이다. 기록: agent/eval/jhmdb_batting/README.md

🔴 임계값을 이 분포에 맞춰 고치지 않았다 — 정답이 관절이지 자세 등급이 아니라, 맞춰 봐야 동작점을 옮기는 것이다. 실측 분포는 루브릭 파일 머리에 적어 두었고 지도자 검수(2번) 때 함께 올린다. 아래 20번도 이 실행에서 나왔다.

아래는 해소 전 서술이다. swing_baseball 54클립을 못 돌렸다. 가진 야구 루브릭은 baseball_pitching(투구)뿐이고, 투구 루브릭을 타격에 쓰는 것은 미결 3번이 이미 기각한 오류다(미결 17번 「하지 말 것」에도 같은 경고가 있다).

이것이 미결 3번의 우선순위를 올린다. 지금까지 타격 루브릭이 없는 것은 “쓸 데가 없어서” 미뤄져 있었는데, 이제 사람이 붙인 관절 정답 54건이 기다리고 있다. 루브릭이 생기는 즉시 GPU 없이 돌아간다.

  • 실행: uv run python scripts/analyze_keypoints.py --root <joint_positions> --action kick_ball --rubric ...
  • 받는 곳: https://files.is.tue.mpg.de/jhmdb/joint_positions.zip (14MB, 인증 없음)
  • 🔴 CC BY 4.0 이라 상업적 이용이 허용된다 — PL-NBA(비상업)·SportsPose(학술용)와 다르다. 미결 15번의 라이선스 축에서 유일하게 깨끗한 후보다

넷째 후보 — 3DSP(축구 슛 200클립). 관절 층은 열고, 나머지는 그대로다 (2026.09.10)

사용자가 calvinyeungck/3D-Shot-Posture-Dataset (AutoSoccerPose, CVPRW 2024)을 받아 왔다. 성격 파악: agent/eval/dataset_3dsp/.

위 표의 축구 행이 안고 있던 세 문제 중 둘이 풀린다. 224p 대신 사람이 손으로 붙인 2D 관절이 오고, fps 를 안다(25fps, position_step=40ms). 관절 12개가 다 있어 scripts/analyze_keypoints.py 로 그대로 들어온다.

🔴 나머지는 그대로다.

  • 정답이 관절이지 자세 등급이 아니다. 위에 적은 네 층 중 관절 층 하나만 열린다 — 지도자 검수(2번)를 대신하지 못한다
  • 선수가 작다. 높이 중앙값 63.5px 에 무릎각 지터 13~15도. 루브릭 구간 폭이 20도라 무릎각 검증에는 못 쓴다. vru_basketball(70px)을 뺀 그 이유다. 몸통은 지터 4.0도라 trunk_lean 만 다룰 만하다
  • 라이선스가 SoccerNet 이다. 저장소는 Apache-2.0 이지만 이미지는 방송 화면 크롭이라 연구용 한정 — 미결 15번 축 그대로다. 내부 검증까지만 쓰고 🔴 크롭·어노테이션을 이 공개 저장소에 커밋하지 않는다
  • 공이 없고(도구 의존 지표 제외), 이벤트 단위 클립이며(20프레임), 영상이 없다(100×100 크롭 .jpg 뿐이라 pose.py 를 못 태운다)

그래서 위 「판단해 주실 것」 가·나·다는 그대로 열려 있다. 이 데이터는 축구 한 종목의 관절 층을 열 뿐, 야구·농구와 해상도·정답 문제를 풀지 않는다. 쓴 곳은 미결 37번 4회차다.

20. 루브릭 구간의 상한 초과가 0등급으로 나간다 — 측정 실패를 감점으로 만든다 (2026.09.04)

🔶 지금 상태 (2026.09.17): 고치지 않기로 했습니다 — (가)·(나)가 임계값을 옮기는 길이라 근거가 지도자 검수였고, 검수 없이 가기로 했습니다(2번). 🔴 결함이 없어진 것이 아니라 out_of_band 드러내기가 그 자리를 대신하고 있습니다. 재개 조건: 지도자 섭외가 가능해지면.

상위 등급 구간의 위를 닫는 규칙(scoring._parse_bands)은 오측정이 만점이 되는 것을 막으려고 생겼다 — 투구 실클립의 골반 회전 181.1도가 “40도 이상”에 걸려 장점으로 표시된 사건이 근거였다. 그 규칙은 맞다.

그런데 닫은 위쪽이 갈 곳이 0등급뿐이다. 밴드는 값 범위를 빠짐없이 덮어야 하고(grade_for가 안 덮이면 예외), 0등급 말고 “판정하지 않음”으로 보낼 자리가 없다. 그래서 “쟀는데 못했다”와 “제대로 못 쟀다”가 같은 0점이 된다.

이것은 이 프로젝트가 다른 자리에서는 지키는 원칙과 어긋난다 — 도구 미검출·사지 신뢰도 미달은 0점이 아니라 제외이고(가중치 재정규화), _drop_implausible도 같은 뜻이다. 「촬영 조건으로 선수를 감점하지 않는다」.

실제로 새어 나가는 양이 작지 않다. 타격 루브릭을 JHMDB 46클립에 대었을 때:

항목 0등급 그중 상한 초과
hip_shoulder_separation 15건 8건
follow_through 20건 12건

baseball_pitching도 같은 구조다(release_arm_extension의 「172도 초과」, hip_shoulder_separation의 「70도 초과」). 타격만의 문제가 아니다.

처방 후보 — 어느 쪽도 아직 고르지 않았다:

   
가. 지표별 상한을 PLAUSIBLE_RANGE로 올린다 측정 실패는 _drop_implausible이 이미 빼 준다. 다만 상한은 종목마다 다르다 — 타격의 분리각 60도와 투구의 70도는 같은 값이 아니다
나. 밴드에 excluded 구간을 새로 둔다 정직하지만 scoring.py 변경이라 기존 평가(B-2~B-6)의 판정 입력이 바뀐다
다. 그대로 두고 결과에 표시만 한다 점수는 안 바뀌고 근거 문장만 “측정 범위를 벗어남”으로 갈린다

🔴 지금 고치지 않은 이유: 어느 쪽이 옳은지가 임계값 검수(2번)에 달려 있다. 상한이 검수로 확정되면 (가)가 그냥 맞는 답이 되고, 그때까지는 무엇을 “범위 밖”이라 부를지 자체가 임시값이다.

📏 크기를 전 루브릭으로 넓혀 쟀다 (2026.09.08) — 처방은 여전히 안 골랐다

근거: agent/eval/pending20_band_ceiling/ (스크립트·출력·결과). 위 수치는 타격 46클립에서만 나온 것이었고 나머지는 “같은 구조다”라는 문장뿐이었다. 확인했다.

분류기가 맞다는 근거: 타격에서 위 표의 8/15 · 12/20 이 그대로 재현됐다. 판정은 production(Criterion.grade_for)을 쓰고 상한은 bands 에서 읽는다.

실측 0등급 상한 초과
전체 117건 23건 (20%)
baseball/batting (draft, JHMDB 46) 85 21
football/instep_shot (active, 18편) 31 2 ← swing_knee_extension
basketball/jump_shot (active, 1편) 1 0

🔴 active 루브릭에서도 샌다. 타격(draft)만의 문제가 아니다.

구조 점검 — 6개 루브릭 30개 항목이 전부 상한이 닫혀 있다. 열린 항목이 하나도 없다. 「타격만의 문제가 아니다」는 이제 추정이 아니라 확인된 사실이다 — 다만 실제로 새는 빈도는 종목마다 다르다(타격 21/85 대 축구 2/31).

⚠ baseball/pitching 은 실측하지 못했다. Track 2 의 1편이 features_ok=0 이라 표본이 0이다. 구조 점검으로만 확인했다. 농구 둘도 표본이 각 1편이라 0이 나온 것이 “안 샌다”는 뜻이 아니다.

⚠ 하한 쪽(아래 절)은 이번에 재지 않았다. PLAUSIBLE_RANGE 를 좁히는 것은 features 가 달라져 B-6 재실행을 부른다 — 밴드 상한만 봤다.

✅ (다)를 넣었다 (2026.09.08) — 점수는 그대로, 표시만

breakdown[]에 out_of_band 를 더했다. 0등급이 구간 위에서 왔으면 "above", 아니면 빈 문자열이다. 미결 9번이 timebase.limited_by 로 한 것과 같은 형태다 — 결함을 보이게 두되 동작점은 안 옮긴다.

   
붙는 자리 scoring.aggregate 한 곳뿐이다. api.py에서 또 붙이지 않는다(23·24번과 같은 규약)
점수·등급 한 비트도 안 바뀐다. features를 안 주면 필드가 빈 문자열이고 나머지는 동일 — test_out_of_band_does_not_move_the_score가 검사한다
B-6 재실행을 안 부른다. selector_downstream.py는 features를 안 넘기고 CSV도 그대로다(git 차이 0)
실측 확인 축구 18편에 대어 10건에 표시가 붙었다 (swing_knee_extension 0등급 「풀린 채찍」 등)
테스트 251 → 253
하지 말 것 🔴 이걸 선수 화면에 그대로 내지 말 것. 임계값이 검수 전이라 band와 같은 취급이다(24번) — 지금은 개발 확인용이다

(가)·(나)는 여전히 안 골랐다. 이 표시는 그 선택을 막지 않는다.

🗂 아래쪽 끝 측정 (2026.09.09) — 층이 하나 더 있다는 것과 실측. 펼쳐 봅니다

🔴 재면서 드러난 것 — 층이 하나 더 있다

항목은 「상한 초과 → 0등급」으로 적었는데 실제로는 사다리다. 상한을 막 넘긴 값은 대개 1등급으로 간다 — 1등급 구간이 2등급 구간을 감싸기 때문이다. 0등급은 더 멀리 넘어야 걸린다.

초과가 가능한 22개 항목 중 13개가 1등급, 9개가 곧바로 0등급이다. football_inside_pass는 네 항목 전부 1등급으로 받는다 — (가)·(나)를 고를 때 참고할 본보기가 이미 저장소 안에 있다는 뜻이다.

그리고 PLAUSIBLE_RANGE 안에서는 어느 값도 등급 구간을 벗어나지 않는다. grade_for가 예외를 내는 조합이 있긴 하지만 전부 그 범위 밖이라 _drop_implausible이 먼저 걸러낸다 — 도달하지 않는다.

   
확인 uv run python eval/jhmdb_batting/grade_dist.py eval/jhmdb_batting/features_swing_baseball.json — 항목별 0등급 수가 나온다
하지 말 것 상한을 없애지 말 것. 그러면 181.1도가 다시 만점이 된다

🔴 아래쪽 끝도 같다 — 각도의 하한이 기하 한계이지 생리 한계가 아니다 (2026.09.04 추가)

위는 루브릭 밴드 얘기였는데, 실클립을 돌려 보니 PLAUSIBLE_RANGE 자체가 같은 결함을 갖고 있다. 각도 지표의 하한이 전부 0.0인데 그것은 joint_angle이 arccos 기반이라는 기하학적 최소이지 사람 관절이 갈 수 있는 범위가 아니다.

타격 루브릭을 실클립 4건에 돌린 값:

clip 지표 값
C7icGyrdROM support_elbow_angle_at_impact 5.4도
YNMHMKb5Md4 plant_knee_angle_at_impact 24.3도
YNMHMKb5Md4 swing_knee_angle_at_impact 29.0도

사람 팔꿈치는 5도까지 접히지 않고, 서서 스윙하는 사람의 무릎이 24도일 수도 없다. 전부 오측정인데 _drop_implausible을 통과해 0등급으로 채점된다. features.py의 주석도 “사람 몸에서 나올 수 있는 범위로 잡되, 실측 분포가 쌓이면 다시 본다”고 적어 두었다 — 이제 실측이 쌓였다.

🔴 그래도 지금 고치지 않는다. PLAUSIBLE_RANGE를 좁히면 빠지는 지표가 생기고, 그것은 features 딕셔너리가 달라진다는 뜻이라 B-2~B-6과 비교가 끊긴다(미결 11번). 어느 값을 하한으로 둘지도 임계값 검수와 같은 성격의 결정이다. 근거: agent/eval/jhmdb_batting/realclip/README.md 3절.

📏 아래쪽도 크기를 쟀다 (2026.09.09) — 팔꿈치 문제이고, draft 안에 갇혀 있다

위 상한 회차가 「⚠ 하한 쪽은 이번에 재지 않았다」고 남긴 구멍을 채웠다. 근거: agent/eval/pending20_band_floor/ · 사전 등록 933a277(측정 코드보다 먼저). src/ 는 안 고쳤고 포즈도 다시 안 뽑았다 — GPU 불필요, 수초.

🔴 이 항목의 수치를 하나 정정한다. 위에 support_elbow 최악을 5.4도로 적었는데(실클립 4건 기준), JHMDB 46편에는 2.2도가 있다. 더 나쁘다.

그리고 네 관절각이 전혀 다르게 행동한다 — 항목은 「각도의 하한」이라고 지표 구분 없이 적었지만 팔꿈치 문제다.

지표 실측 최소 어디서
support_elbow_angle_at_impact 🔴 2.2도 JHMDB 타격 46
swing_elbow_angle_at_impact 🔴 6.8도 JHMDB 타격 46
plant_knee_angle_at_impact 24.3도 실클립 4건뿐
swing_knee_angle_at_impact 29.0도 실클립 4건뿐

JHMDB 46편의 무릎은 최소 95.6·106.6도로 깨끗하다. 무릎이 낮게 나온 것은 이 항목이 인용한 실클립 4건뿐이고, 그 4건은 JHMDB 46 에 없는 별도 표본이다.

하한값은 고르지 않았다 — 그것이 임계값 검수(2번)의 성격이고, 분포를 보고 그으면 이미 기각된 경로다(ho 34번). 대신 후보 하한 L 의 민감도 곡선을 냈다. 격자 {5,…,40} 은 데이터를 보기 전에 박았다.

L(도) 걸리는 값 그중 지금 0등급 빼면 등급 문자 변화
20 7 4 2
30 12 7 4
40 16 8 4

🔴 상한 회차와 결론이 갈린다 — 아래쪽은 active 를 안 움직인다

등급 문자가 바뀐 것은 전부 baseball/batting 이고 그 루브릭은 draft 다. football/instep_shot(active)·농구 둘은 어느 L 에서도 등급이 안 움직인다 — 축구의 팔꿈치 최소가 109.2도·72.2도로 넉넉하다. 상한은 active 축구에서도 2건이 샜는데(위 표) 아래쪽은 이 표본에서 그렇지 않다.

   
말할 수 있는 것 결함은 실재하고 항목이 적은 것보다 크다. 다만 지금 나가는 루브릭의 등급은 안 움직인다 — 급한 수리가 아니다
🔴 말할 수 없는 것 「무해하다」 — 근거 문장에는 2.2도가 그대로 실린다 · 「타격은 draft 니 괜찮다」 — draft 를 active 로 올리는 것이 남은 일감이고, 올리는 순간 이 4건이 사용자 등급이 된다
한계 투구는 이번에도 못 쟀다(features_ok=0, 표본 0) · 농구는 각 1편 · 축구 support_elbow 는 18편 중 10편에만 있다 · 「걸리는 값」은 오측정의 상한이지 건수가 아니다
  • 사전 등록에서 표본 하나를 더했다: 이 항목이 근거로 든 4건이 JHMDB 46 에 없어, 빼면 「무릎은 깨끗하다」로 잘못 보고하게 된다. 격자·대상 지표·보고 항목은 그대로다. 더한 뒤 이 항목의 수치(24.3·29.0·5.4)가 그대로 재현됐다 — 같은 것을 보고 있다는 확인이다
  • 확인: cd agent && uv run python eval/pending20_band_floor/measure_floor.py

  • 담당: 정상호 · 제기: 정상호 · 기한: 미결 2번(지도자 검수)과 함께 → 🔴 검수 없이 가기로 했습니다 (2026.09.17, 2번 「검수 없이 갑니다」). (가)·(나)는 임계값을 옮기는 길이라 근거가 검수였습니다 — 안 옮깁니다(B-6 재실행도 안 부릅니다). 🔴 결함이 없어진 것이 아니라 고치지 않기로 한 것이고, out_of_band 드러내기가 그 자리를 대신하고 있습니다. 섭외가 가능해지면 다시 엽니다 — 크기는 위·아래 둘 다 쟀다. 남은 것은 임계값이고 그쪽이 검수 대상이다

21. hip_rotation_range_deg가 못 잰 것을 0.0으로 지어낸다 (2026.09.04) ✅ 해소 (2026.09.08)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

22. 골반 회전 지표가 카메라 축을 함께 채점한다 (2026.09.04)

🔶 지금 상태 (2026.09.17): 타당성 조사는 끝났고(이 지표가 회전의 24%만 담는다 — 결론 유효), 임계값은 unverified 인 채로 둡니다. 옮기려면 검수가 필요한데 검수 없이 가기로 했습니다(2번). 재개 조건: 지도자 섭외.

hip_rotation_range_deg는 좌우 골반을 잇는 선의 화면 평면 각도 변화폭이다 (features._axis_deg). 그런데 회전이 카메라를 향하는 축 둘레로 일어나면 그 선은 화면에서 돌지 않고 짧아진다 — 같은 회전이 촬영 각도에 따라 다른 값이 된다.

타격 실클립 4건이 전부 0등급이었다(6.9 · 10.6 · 0.0 · 3.5도). 타격에서 하체가 안 도는 일은 없다.

재 봤다 — 강한 형태는 틀렸고 약한 형태가 남는다

준비~임팩트 구간에서 골반 축의 각도 변화폭과 단축률을 나란히 쟀다 (JHMDB 46클립, GPU·정답 불필요):

   
각도 변화폭 중앙 16.7도 (25% 12.1 · 75% 27.4)
축 단축률 중앙 0.298 (25% 0.216 · 75% 0.414)
두 값의 상관 +0.568
각도 30도 미만인데 단축률 0.3 이상 11/46 (24%)

상관이 +0.568이라 “무관한 양을 잰다”는 강한 주장은 틀렸다. 화면 평면 각도도 같은 회전을 어느 정도 따라간다. 남는 것은 각도가 회전의 일부만 담는다는 약한 형태이고, 그 24%에서는 “골반이 잠겼다”가 회전이 없어서가 아니라 카메라 축 때문이다.

🔴 실클립과 JHMDB의 분포가 다르다 — 아직 못 가른다

실클립 4건은 0~11도인데 JHMDB 46클립 중앙은 16.7도다. JHMDB는 사람이 붙인 관절이고 실클립은 RT-DETR→ViTPose를 거친다. n=4로는 지표 문제인지 포즈 추정 품질 문제인지 구분되지 않는다. 실클립을 더 돌려야 한다 — 평가셋에 usable_for_phase_B = yes가 7건이고 그중 3건은 품질 게이트에서 반려됐으므로, 분모를 늘리려면 maybe 6건까지 내려가야 한다.

축구도 같은 지표를 쓴다

football_instep_shot(active)의 hip_rotation 항목이 같은 지표다. 인스텝 슈팅은 골반이 화면 평면 안에서도 도는 동작이라 사정이 나을 수 있지만 확인된 바 없다. 축구 클립으로 같은 검사를 돌리면 답이 나온다 — --action kick_ball(JHMDB 36건)이면 그대로 된다.

✅ 축구도 쟀다 — 이 결함은 축구에서 약하다 (2026.09.09)

근거: agent/eval/pending22_hip_axis/RESULTS.md. 정답·GPU 불필요, 약 1분.

🔴 먼저 정정 — 위의 「축구는 --action kick_ball 이면 그대로 된다」가 틀렸다. hip_axis_span 이 임팩트 사지를 "arm" 으로 박아 두고 있었다. 야구 타격은 impact_limb: arm 이라 맞지만 축구는 leg 다 — 그대로 돌리면 팔로 준비 구간을 잘라 엉뚱한 창에서 골반을 잰다. 루브릭에서 읽도록 고쳤고(--rubric), 야구 기준선이 그대로 재현되는 것으로 수정이 동작을 안 바꿨음을 확인했다(16.7 · 0.298 · +0.568 · 11/46).

  야구 타격 (46) 축구 kick_ball (33)
각도 변화폭 중앙 16.7도 95.1도
각도 30도 미만 35/46 (76%) 8/33 (24%)
🔴 안 돌았는데 짧아짐 11/46 (24%) 6/33 (18%)
각도·단축률 상관 +0.568 +0.713

인스텝 슈팅은 골반이 화면 평면 안에서 실제로 크게 돈다. 「사정이 나을 수 있지만 확인된 바 없다」고 적어 둔 추측이 확인됐고 맞았다. 🔴 사라진 것은 아니다 — 18%는 여전히 「안 돌았는데 짧아졌다」이고, 야구보다 낮을 뿐이다.

🔴 대신 반대쪽 문제가 드러났다 — 축구에서는 변별력이 없다

  야구 타격 축구 인스텝
2등급 구간 30~120 25~180
실측 분포 중앙 16 중앙 48
그 선이 낸 등급 대부분 0등급 잘함 14 · 보통 0 · 아쉬움 1

같은 지표가 종목마다 정반대로 쏠린다. 야구는 카메라 축 때문에 안 돌아 보여 대부분 감점되고, 축구는 실제로 크게 돌아 15편 중 14편이 만점이다. 2등급 구간 25~180도가 사실상 모두를 통과시킨다.

이쪽은 타당성이 아니라 임계값 문제이므로 검수 대상이고, 임계값 검수 서식(eval/pending2_bands/)이 이미 그 자리를 지도자에게 그대로 보여 준다.

   
확인 cd agent && uv run python eval/jhmdb_batting/hip_axis_check.py --root <joint_positions> --action kick_ball --rubric football_instep_shot.yaml
하지 말 것 밴드를 낮춰 해결하지 말 것. 숫자는 퍼지지만 위 18%에서는 여전히 카메라 축을 채점한다 · 🔴 축구 쪽을 올리는 것도 실측 분포만 보고 정하지 말 것 — 이미 기각된 「분포에 맞춰 긋기」가 된다
근거 agent/eval/pending22_hip_axis/RESULTS.md · agent/eval/jhmdb_batting/realclip/README.md 4절
  • 담당: 정상호 · 제기: 정상호 · 기한: 미결 2번(지도자 검수)과 함께 → 타당성 조사는 끝났다. 남은 것은 임계값이고 그쪽이 검수 대상이다 → 🔴 검수 없이 가기로 했습니다 (2026.09.17, 2번 「검수 없이 갑니다」). 임계값은 unverified 인 채로 둡니다 — 타당성 결론(회전의 24%만 담는다)은 그대로 유효하고, 고치지 않기로 한 것입니다

23. 근거 문장이 자기 등급과 반대로 말한다 — 감점 문장에서만 (2026.09.04)

점수는 틀리지 않는다. 등급은 scoring.Criterion.grade_for가 수치 구간으로 정하고 모델은 문장만 쓴다(judge.py). 그런데 화면에 나가는 것은 문장이다.

타격 실클립 4건의 근거 문장 19개를 확정 등급·칭호와 나란히 읽었다.

   
2등급 문장 8건 전부 정확
0·1등급(감점) 문장 11건 중 6건이 어긋난다

결함이 감점 쪽에만 몰려 있다. 그리고 감점 문장이야말로 어긋나면 안 되는 쪽이다 — 선수가 왜 깎였는지를 반대로 읽는다.

어긋나는 방식이 둘이다

(가) 등급 번호를 틀리게 말한다 (1건)

0등급 골반 회전 — “골반 회전 각도 6.9도로 1등급 기준(18도 미만)에 해당하며” — 18도 미만은 0등급이다

(나) 감점을 칭찬으로 서술한다 (5건). 이쪽이 더 나쁘다:

확정 등급 · 칭호 모델 문장
0등급 「함께 도는 몸통」 “골반이 상체보다 먼저 열리는 특징이 확인되었다”
1등급 「흔들린 상체」 “대칭적인 자세 유지가 확인되었습니다”
1등급 「짧아진 마무리」 “추가 감속 없이 배트 속도 유지에 적합한 기술”
1등급 「덜 펴진 리드 팔」 “스윙 궤도가 최적 범위(115~140도) 내에 위치하여 자연스러운 공 접촉”
1등급 「덜 열린 골반」 “먼저 열리는 정도가 적절합니다”

🔴 정보가 없어서가 아니다

build_prompt는 확정 등급을 명시하고(← 확정된 등급), 세 등급 정의를 전부 주고, band_text로 그 등급의 수치 구간까지 문장으로 확정해 준다. 시스템 프롬프트도 「주어진 등급을 전제로 씁니다. 등급을 다시 판정하거나 반박하지 않습니다」라고 적어 두었다. 다 주고도 틀린다 — EXAONE 1.2B가 주어진 것을 옮기지 못하는 것이고, 경계값 비교를 재현되게 틀렸던 것(미결 4번)과 같은 성질이다.

한 가지는 우리가 만든 자리다. 시스템 프롬프트의 예시가 "임팩트 시 무릎각 141.7도로 2등급 기준…" 이라 문장에 등급 번호와 구간을 쓰라고 가르친다. 모델이 틀릴 수 있는 숫자를 형식이 요구하는 셈이다. 「상체 유지」의 오독도 우리 쪽 원인이 있다 — 그 항목은 구간이 0을 가운데 둔 좌우 대칭인데(타격은 좌타/우타로 부호가 갈린다), 모델이 그 구간의 대칭을 자세의 대칭으로 읽었다.

처방 — 아직 고르지 않았다

   
가. 문장에서 등급·구간 표기를 뺀다 (권고) 모델은 자세 서술만 쓰고, 등급 번호·구간·칭호는 코드가 붙인다 — 루브릭이 grades[g]·titles[g]로 이미 갖고 있다. (가) 형태가 구조적으로 사라진다. 「총점은 모델이 아니라 코드가 계산한다」는 설계 원칙의 연장이다
나. 검사기를 둔다 문장이 언급한 등급 번호가 확정 등급과 다르면 버리고 grades[g] 문구로 대체. (가)는 잡지만 (나)는 못 잡는다 — 칭찬인지 지적인지는 문자열로 안 갈린다
다. 모델을 키운다 미결 4번. 8GB에 들어가는 더 큰 모델이 먼저다

(나)를 문자열 검사로 잡으려 하지 말 것. 그 검사가 또 틀리고, 틀린 검사는 “검사했다”는 인상만 남긴다.

🗂 1~2회차 기록 — 처방 「가」 구현과 앵커 되살리기. 펼쳐 봅니다

🔴 처방 「가」를 구현하고 사전 등록한 검사로 돌렸다 (2026.09.07)

(가)는 없어졌다. (나)는 없어지지 않고 자리를 옮겼다. 판정 규칙과 합격선은 문장을 만들기 전에 고정했다(agent/eval/pending23_evidence/PREREGISTRATION.md).

무엇을 고쳤나 — judge.py의 시스템 프롬프트와 build_prompt에서 등급 번호와 기준 구간을 뺐다. 프롬프트에 없는 숫자는 베껴 쓸 수 없다. 등급·칭호·구간은 scoring.aggregate가 title·band 필드로 싣는다 — 화면이 붙이면 된다.

  before after 합격선
R1 「등급」이라는 낱말 17 / 19 0 0
R2 구간 표기(25~60) 11 / 19 0 0
R3 지어낸 수치 19 / 19 4 보고만

R1·R2 합격. 등급·총점은 19/19 저장된 결과와 같다(문장만 바뀌었다). R3가 함께 줄어든 것은 before의 지어낸 수치 대부분이 베껴 쓴 기준 구간이었기 때문이다.

그런데 사람이 읽으니 (나)가 반대쪽으로 옮겨갔다. 감점 문장의 역방향 서술은 6/11 → 1/11로 줄었는데, before에서 8건 전부 정확했던 2등급 문장이 8건 중 4건 잘한 것을 감점처럼 서술한다 — 2등급 「감아 도는 팔」에 “마무리 구간이 다소 짧게 느껴지며 … 연습이 필요하다”. 방향 오류 총량은 6/19 → 5/19로 거의 그대로다.

가설: 등급 번호를 빼면서 앵커의 등급 표시까지 함께 뺐다. 어느 어투가 어느 수준인지 짝지어 주던 단서가 사라졌고, 1.2B에게는 「← 이번 판정」 표시 하나가 얇다.

덤으로 본 것 둘, 사전 등록에 없어서 1회차 판정에 넣지 않았다: 내부 용어가 샜다 (“실클립에서 가짜 굴곡 가능성”), 영어가 섞였다(“약간의 forward lean가”).

2회차 — 앵커에 잘함/보통/아쉬움을 되살렸다 (2026.09.07)

뜻은 고쳐졌다. 그런데 구간 표기가 한 건 되살아나 합격선을 넘지 못했다. 판정 기준은 이번에도 문장을 만들기 전에 고정했다(사전 등록 부기).

번호가 아닌 낱말로 수준 정의와 앵커를 짝지었고, 끝맺음 문구도 수준에 맞췄다 — 잘한 항목에까지 「고칠 점을 붙여라」를 요구하던 것을 「무엇이 좋았는지」로 바꿨다. 둘을 한 회차에 묶어서 어느 쪽 덕인지는 가리지 못한다.

역방향 문장 before after after2
2등급 0 / 8 4 / 8 0 / 8
감점(0·1등급) 6 / 11 1 / 11 1 / 11
합계 6 / 19 5 / 19 1 / 19

🔴 그런데 R2(구간 표기)가 0 → 1건이다. “최적 수준(25~60도)에 근접했다” — 25~60도는 [잘함] 수준 정의 문구에 들어 있는 숫자다. 루브릭의 grades[g]가 구간을 품고 있어 프롬프트에 남아 있었고, 그 수준에 이름을 붙여 주자 베꼈다. 1회차 진단이 뒤집어서도 맞았다 — 프롬프트에 있으면 언젠가 베낀다. 그 한 문장이 역방향 1건과 같은 문장이다 (남의 구간을 읽고 자기 감점을 “근접했다 / 좋으나”로 정상화했다).

되돌리지 않았다 — 역방향 5건과 구간 표기 1건 중에는 뒤가 가볍다. 선수가 읽고 다치는 것은 등급과 반대로 말하는 문장이지 숫자 하나가 아니다. 다만 합격선은 합격선이라 「통과」로 적지 않는다.

남은 갈래는 가-2: 루브릭에 숫자 없는 수준 설명을 넣고 프롬프트는 그것만 쓴다. R2의 마지막 원인이 사라지지만 루브릭 5개의 스키마가 늘어난다. 지도자 검수(미결 2번)에서 수준 문구를 다시 쓸 때 함께 넣는 편이 싸다 — 같은 19문장으로 3회차를 바로 돌리지 않는다. 검사기(처방 나)는 여전히 안 만든다

✅ 기준선을 축구로 옮겼습니다 · 그리고 R2 가 0 이 됐습니다 (2026.09.16)

제가 프롬프트를 고쳐서 다시 쟀습니다 — 43번 ㉱ 2회차가 측정값에 단위를 붙였는데, 그 프롬프트를 재고 있는 것이 이 항목입니다. 고친 사람이 다시 재는 것이 맞습니다.

  after2 (2026.09.07) after3 (2026.09.16)
R1 「등급」 낱말 0 0
R2 구간 표기 🔴 1 ✅ 0
R3 지어낸 수치 2 2
판정 불합격 ✅ 합격

등급·총점은 저장된 결과와 19/19 일치(문장만 달라졌습니다).

🔴 「단위를 붙여서 R2 가 0 이 됐다」고 적지 않습니다. after2 이후 프롬프트 변경이 둘입니다(09-11 코드 심벌 → 라벨 · 09-16 단위). 어느 쪽 덕인지 이 측정으로는 못 가릅니다.

🔴 야구 픽스처는 이제 제품이 아닙니다 — 그래서 옮겼습니다

19문장 기준선은 야구 타격이고 그 루브릭은 39번(축구 단일 종목 전환)에서 지워졌습니다. 위 재측정도 git show 로 임시로 되살려 돌리고 바로 치웠습니다 (rubrics/ 잔여 0건). 재는 대상이 제품이 아닌 상태라, 같은 질문을 살아 있는 루브릭에 묻는 자리를 만들었습니다.

   
새 자리 eval/pending23_evidence/football_evidence.py → evidence_football.json
판정 🔴 같은 검사기(check_evidence.py, 사전 등록된 R1·R2·R3)를 한 줄도 안 고치고 씁니다
결과 축구 2루브릭 64문장 — R1 0 · R2 0 · R3 14 (보고만) → 합격
🔴 비교하지 않습니다 문장 수도 루브릭도 달라서 야구 19문장과 직접 견주지 않습니다. 야구 쪽은 2026.09.07 까지의 기록으로 남기고, 앞으로의 기준선은 이쪽입니다
🔴 옮기자마자 새것이 보였습니다 — 무차원 비율을 cm 로 지어냅니다

R3 14건의 다수가 plant_foot_position 한 항목입니다:

“디딤발이 공 옆 어깨너비 0.1배(약 6cm) 에 붙어 …” · “0.95배(약 38cm)”

plant_foot_to_ball_offset 은 어깨너비 배수(무차원) 인데 모델이 어깨너비를 약 60cm 로 가정해 환산합니다. 프롬프트에 cm 는 한 글자도 없습니다.

🔴 오늘 단위 변경 때문이 아닙니다 — ratio 는 꼬리표가 빈 문자열이라 그 줄이 변경 전후 바이트 동일입니다(확인함). 원래 있던 결함이고, 축구로 옮기니 보인 것입니다. 형태는 43번 ㉱ 2회차와 같습니다: 빈자리를 모델이 채웁니다. 처방 후보는 「배(어깨너비 기준)」처럼 기준을 문장에 넣는 것인데, 같은 회차에 섞지 않고 여기 적어 둡니다 — 43번 ㉱ 3회차 자리입니다.

  • 확인: cd agent && uv run python eval/pending23_evidence/football_evidence.py && uv run python eval/pending23_evidence/check_evidence.py evidence_football.json
  • 🔴 역방향(감점을 칭찬으로)은 여전히 안 셉니다 — 문자열로 안 갈립니다. 그 판정은 사람이 읽어야 하고, 지도자 검수(2번)와 함께입니다 → 🔴 검수는 없어졌습니다 (2026.09.17, 2번 「검수 없이 갑니다」). 아래 절.

🔴 (나)를 읽었습니다 — 살아 있고, 원인이 구조적입니다 (2026.09.17)

64문장을 등급·측정값과 나란히 읽었습니다. 모델을 다시 안 돌렸습니다 — 09-16의 evidence_football.json 을 그대로 읽었고, src/ 는 한 줄도 안 고쳤습니다(조사 회차).

기전 — 양방향 구간의 방향을 모델이 찍습니다

build_prompt 는 세 등급의 grades[g](수준 정의)를 전부 프롬프트에 넣습니다. 그런데 양방향 구간의 grades[g] 는 두 방향을 한 문자열에 담습니다:

1 | 170도 초과(굴곡 부족) 또는 135~150도(과굴곡)

측정값이 어느 조각인지는 안 알려 줍니다. 그래서 모델이 고르고, 틀립니다.

🔴 50번이 09-16에 가른 것은 titles·card_lines 뿐입니다 — 그건 코드가 고르는 문구라 방향이 맞습니다. 모델이 읽는 grades 는 안 갈랐습니다. 그래서 같은 판정에서 카드 문장은 맞고 근거 문장은 틀리는 일이 납니다.

실제로 어긋난 것 — 양방향 14문장 중 5문장
항목 · 측정값 앉은 조각 모델이 쓴 말
디딤발 무릎 굽히기 140.2 [135,150] = 과굴곡 “약간의 굴곡 부족이 관찰됨” 🔴 정반대
디딤발 무릎 굽히기 144.8 같음 같은 문장 🔴
차고 난 뒤 마무리 4.8 [3,8] = 짧음 “…슈팅과 유사한 마무리로 판단된다” 🔴 반대 조각(25도 초과=과한 마무리)의 말
차고 난 뒤 마무리 6.2 같음 “팔로스루가 짧고 방향성 있었으나 슈팅 수준을 넘었다” 🔴 같은 형태
상체 기울기(패스) 1.3 · 0등급 [None,2] = 젖혀짐 “충분히 젖혀져 있어 패스 방향이 명확했으나” 🔴 감점을 칭찬으로 — (나) 그 자체

전부 「반대 조각의 말을 끌어온다」 한 가지 형태입니다. 계산으로 확인되는 어긋남이라(측정값이 어느 조각인지는 산술입니다) 취향 판단이 아닙니다.

🔴 양방향이 아닌 자리에서도 하나 나왔습니다: 상체 기울기(패스) 21.5도, 1등급 [15,25](앞으로 숙임) 단일 구간인데 “상체가 과도하게 뒤로 젖혀져 있었다”. 옆 등급(0등급 「젖혀진 상체」)의 말을 끌어왔습니다 — 양방향을 가르는 것만으로는 다 안 막힙니다.

  • 재현: cd agent && uv run python eval/pending23_evidence/two_way_bands.py — 🔴 세지 않고 띄워만 줍니다. 판정은 사람이 합니다(이 항목이 처음부터 적어 둔 「문자열로 가르려 하지 말 것」 그대로입니다)
🔴 R2 「0건 합격」이 「구간 표기가 사라졌다」를 뜻하지 않습니다

R2 정규식은 두 수를 이은 표기(135~150)만 봅니다. 위 140.2 문장의 “기준 상한 170도 미만” 은 단일 임계값이라 R2 를 피해 R3(보고만)로 갑니다. 즉 합격선이 있는 기준을 비껴가는 샘길이 있습니다.

🔴 그래서 정규식을 지금 안 고칩니다. 사전 등록이 “결과를 보고 정규식을 고치지 않는다” 고 적어 두었고, 고치면 그 회차의 합격이 뜻을 잃습니다. 고치려면 새 사전 등록입니다.

다음 한 걸음 — 가-2 가 고아가 됐고, 지금이 그 시점입니다

2회차가 남긴 갈래가 가-2(루브릭에 숫자 없는 수준 설명을 넣고 프롬프트는 그것만 쓴다)인데, 미루면서 적은 이유가 “지도자 검수(미결 2번)에서 수준 문구를 다시 쓸 때 함께 넣는 편이 싸다” 였습니다. 🔴 그 검수가 없어졌습니다 (2번 「검수 없이 갑니다」). 더 싼 시점은 안 옵니다.

가-2 가 위 둘을 동시에 없앱니다 — 숫자가 프롬프트에서 사라지고(R2·R3), 수준 설명을 조각마다 쓰면 방향도 모델이 안 찍습니다. titles·card_lines 가 이미 조각마다 쓰는 구조라 같은 모양입니다.

   
🔴 선행 사전 등록 — 문장이 바뀌므로 판정 기준을 먼저 굳힙니다
점수 안 바뀝니다 — grades 는 표시·프롬프트용이고 등급은 bands 가 정합니다. B-6 재실행 없음
비용 모델을 다시 돌려 64문장을 새로 받아야 합니다(GPU)

🔴 가-2 를 돌렸습니다 — 불합격 (C), 다만 두 가지가 드러났습니다 (2026.09.17)

사전 등록(PREREGISTRATION_plain.md)을 코드에 손대기 전에 커밋하고 (73aeb18) 돌렸습니다. 결과 RESULTS_plain.md.

  기준 before after 합격선  
A R1 「등급」 낱말 0 0 0 ✅
B R2 구간 표기 1 0 0 ✅
C R2′ 경계 숫자 누출 5 4 0 🔴
D 방향 오독 (사람이 읽음) 16/30 12/30 악화 금지 ✅
E 판정 입력·등급 불일치 — 0/80 전건 ✅
F·G 적재 검사 · 테스트 — 555 → 561 — ✅

C 하나 때문에 불합격입니다. 5 → 4 는 줄어든 것이지 0 이 아닙니다.

🔴 C 가 남은 이유 — anchors 에 경계 숫자가 그대로 있었습니다

grades 에서 숫자를 뺐는데 문장이 여전히 170·165 를 말합니다. 출처는 앵커였습니다 — 프롬프트가 앵커를 「예시 어투」로 넣는데 그 문장이 경계를 품고 있습니다(“기준 상한 170도 초과”). 남은 4건이 정확히 그 두 항목이고, grades 에서 오던 누출(15~35)은 이번에 사라졌습니다 — 두 출처가 갈립니다.

🔴 앵커를 지금 고쳐서 다시 재지 않습니다. 사전 등록이 「결과를 보고 문구를 고쳐 다시 재지 않는다」고 적었습니다. 그건 가-3 + 새 사전 등록입니다.

짝 기준 둘 다 맞았습니다
  • P-1 ✅ before 에서 두 번째 조각 오독이 첫 조각보다 많습니다 (10/15 대 6/15). 🔴 한 번도 안 뽑아 본 쪽이 실제로 더 틀렸습니다 — 표본이 반쪽이었던 것이 그냥 흠이 아니라 틀린 쪽을 가리고 있었습니다
  • P-2 ✅ C 가 줄어도 R3 는 0 이 안 됩니다(17 → 16). 무차원→cm 환산은 다른 원인입니다(43번 ㉱)
🔴 D 가 이 회차의 진짜 결과입니다 — 12건이 남았습니다

16 → 12 로 줄었지만 절반쯤은 안 없어졌고, 남은 것의 성격이 다릅니다: 단일 구간에서도 옆 등급의 말을 끌어옵니다(23.5·26.5·34.8·37.2 — 전부 「뒤로 젖혀져」인데 앞으로 숙인 값입니다). 조각을 갈라 주는 것만으로는 안 됩니다. 자리를 옮기기도 했습니다 — 고쳐진 넷(140.2·176.5·179.8·182.2)이 있는 대신 새로 틀린 셋(42.0·85.8·91.0)이 생겼습니다.

🔴 D 는 AI 판독이라 정답이 아닙니다. 문장별 판정을 direction_reading_20260917.json 에 남겼습니다 — 사람이 다시 읽으라고 둔 것입니다. 판정이 갈릴 수 있는 3건을 표시했고, 그 셋을 다 반대로 세도 16 → 14 라 「악화 아님」은 안 뒤집힙니다.

  • 확인: cd agent && uv run python eval/pending23_evidence/check_boundaries.py evidence_plain_after.json --show
  • 다음(가-3): ⑴ 앵커에서 경계 숫자 빼기 ⑵ 프롬프트에서 다른 등급 문구를 아예 빼는 안을 재기. 🔴 ⑵는 대가가 있을 수 있습니다 — 1회차에서 앵커 등급 표시를 뺐다가 어투가 무너졌습니다. 재고 정합니다

✅ 가-3 을 돌렸습니다 — 합격. 다만 예측 하나가 빗나갔습니다 (2026.09.17)

사전 등록 PREREGISTRATION_anchors.md(코드 전에 커밋 e196fd7) · 결과 RESULTS_anchors.md. ⑵(다른 등급 문구 빼기)는 안 했습니다 — 먼저 잰 것이 앵커였고, 그것으로 C 가 닫혔습니다.

  기준 가-2 가-3 합격선  
A·B R1 · R2 0 · 0 0 · 0 0 ✅
C R2′ 경계 숫자 🔴 4 0 0 ✅
D 방향 오독 (사람 판독) 12/30 7/30 악화 금지 ✅
E 판정 입력·등급 동일 — 0/80 80/80 ✅
F·G 검사가 무는가 · 테스트 561 565 — ✅
기전이 맞았습니다 — 모델은 앵커의 방향을 따라갑니다

가-2 결과를 대조하니 앵커가 앉은 조각이 측정값과 다르면 오독률 63%(10/16), 같으면 14%(2/14)였습니다. 양방향 등급 8곳이 전부 앵커가 하나였고 그 하나가 한 조각만 대표했습니다. 조각마다 앵커를 두고, 프롬프트가 판정 등급은 값이 앉은 조각의 앵커만 싣게 했습니다.

  • 🔴 앵커를 지우지 않았습니다 — 1회차에서 앵커를 줄였다가 2등급 문장이 무너진 적이 있어, 고르는 것만 했습니다. 다른 등급 앵커는 전부 그대로입니다
🔴 P-1 이 빗나갔습니다 — 「거의 맞았다」로 적지 않습니다

6건 이하로 예측했고 7건입니다. 선은 결과 전에 그었습니다. 어긋난 16자리의 오독률이 「같다」쪽(14%)까지는 안 내려왔다는 뜻입니다.

✅ 대신 P-2 가 이 회차의 계기였습니다. 「follow_through 79.2·125.8 은 앵커가 이미 같은 조각이라 이번 변경이 안 닿는다」고 미리 짚었고 정확히 그 둘이 그대로 틀렸습니다. 고쳐졌다면 제 기전 설명이 틀린 것이었습니다.

🔴 뜻밖 — R3 가 16 → 2, 그리고 앞서 적은 것을 정정합니다

무차원→cm 환산을 이 항목이 “빈자리를 모델이 채운다” 로 적고 43번 ㉱ 3회차 자리로 미뤄 뒀는데, 실제로는 앵커가 가르치고 있었습니다 — “어깨너비 0.29배(약 12cm)”. 사전 등록의 「앵커에 자기 값 외 숫자 금지」에 걸려 6건을 뺐더니 사라졌습니다. 적어도 이 절반은 앵커 문제였습니다.

남은 7건 — 한 항목에 4건이 몰려 있습니다

inside_pass/follow_through 가 지표 둘을 쓰는 유일한 항목이고, 실패 형태가 두 번째 지표로 반대 방향을 주장하는 것입니다 — 굴곡 4.8도(짧음)에 “지속 시간이 길어져 슈팅처럼 과도하게 뻗은”. 🔴 그 둘(4.8·6.2)은 가-2에서 맞았다가 이번에 틀렸습니다 — 줄었어도 자리를 옮깁니다. inside/trunk_lean 29.8·32.2 는 조각 앵커를 줘도 안 고쳐졌습니다 — 남은 기전이 하나 더 있고 지금은 무엇인지 모릅니다.

  • 확인: cd agent && uv run python eval/pending23_evidence/check_boundaries.py evidence_anchors_after.json
  • 🔴 항목을 닫지 않습니다 — R1·R2·R2′ 는 닫혔지만 (나) 방향 오독이 7건 남았습니다. 이 항목이 원래 물은 것이 그것입니다

🔴 남은 4건을 재 보니 절반이 제 계기였습니다 (2026.09.17, 조사 회차)

사전 등록 PREREGISTRATION_second_metric.md(코드 전, 01e2262) · 결과 RESULTS_second_metric.md. src/·rubrics/ 무변경, 42문장.

follow_through 는 measured_by 가 둘인 유일한 항목이라, 밴드 지표를 고정하고 지속 시간만 1·4·11 로 흔들었습니다(실측 113건의 최소·중앙·상위 사분위).

자리 지속 1 4 11 무엇이었나
굴곡 4.8·6.2 (짧음) ✅ ✅ 🔴 4.8 만 계기가 만든 것
굴곡 79.2·125.8 (과함) 🔴 🔴 🔴 지속 시간과 무관

🔴 평가 스크립트가 비밴드 지표를 상수 10.0 으로 채워 왔습니다 (features.setdefault(code, 10.0)). 실측 중앙은 4이고 프롬프트 앵커는 잘함 4·아쉬움 1프레임이라, 10.0 은 앵커 어느 것보다 커서 「길다」로 읽힙니다. 실측 중앙값에서는 그 오독이 안 납니다.

🔴 바로 위 가-3 절의 서술을 정정합니다 — 거기 「모델이 두 번째 지표를 근거로 반대 방향을 주장한다」고 적었는데, 4.8·6.2 에 대해서는 「평가 스크립트가 넣은 상수를 근거로」가 맞습니다. 제품이 그런다는 근거가 아닙니다. 미결 45번 1회차·52번 1회차와 같은 형태입니다 — 내 앞 단계가 맞게 돌고 있는가.

✅ 그리고 남은 절반의 원인을 찾았습니다 — 잘함 문구를 앞에 베낍니다

79.2·125.8 은 지속 시간 셋 전부에서 “팔로스루가 짧고 방향을 유지하며 마무리됐다. 슈팅처럼 크게 뻗은 …” 으로 시작합니다. 루브릭의 잘함 수준 문구가 “찬 뒤 다리가 짧게, 방향을 남기며 멈춘다” 입니다 — 거의 그대로입니다.

🔴 판정 등급 문구는 맞게 씁니다(뒤쪽 「슈팅처럼 크게 뻗은」). 문제는 그 앞에 옆 등급 문구를 붙여 문장이 스스로 모순된다는 것입니다.

짝 기준

P-1 ✅(1프레임에서 「과하다」류 0) · P-3 ✅(등급 변경 0/42 — 밴드 지표 하나가 정합니다) · P-2 는 절반(11프레임에서 4.8 은 재현, 6.2 는 안 됨). 🔴 「맞았다」로 안 적습니다.

🔴 그리고 앞서 그 이유를 「문장 생성이 흔들린다」로 적은 것을 정정합니다 (같은 날). 이 회차는 1·4·11 을 쟀고 가-3 은 10.0 이었습니다 — 10.0 을 안 쟀으므로 「같은 조건」이 아니고, 흔들림의 근거가 못 됩니다. 말할 수 있는 것은 11프레임에서 4.8 은 틀리고 6.2 는 안 틀린다는 것뿐입니다.

  • 확인: cd agent && uv run python eval/pending23_evidence/second_metric_report.py
  • 다음은 둘: (A) setdefault 상수를 실측값으로 — 🔴 A 가 먼저입니다. 계기가 곧아야 (B)를 잽니다 · (B) 옆 등급 문구 베끼기(가-3 의 ⑵). 🔴 둘 다 새 사전 등록입니다 — A 는 앞선 회차와의 비교가 끊기고, B 는 빼는 대가가 있습니다(1회차에서 앵커를 줄였다가 2등급 문장 8건 중 4건이 무너졌습니다)

✅ A 를 돌렸습니다 — 합격, 짝 기준 넷이 전부 맞았습니다 (2026.09.17)

사전 등록 PREREGISTRATION_filler.md(코드 전, c672817) · 결과 RESULTS_filler.md. 고친 것은 eval/ 과 앵커 하나뿐입니다 — src/·루브릭 문구·bands 무변경, B-6 재실행 없음.

  기준 가-3 A  
A·B·C R1 · R2 · R2′ 0 0 ✅
D 방향 오독 (사람 판독) 7/30 5/30 ✅
E·F·G 밴드값 동일 · 검사 · 테스트 565 0/80 · 뭄 · 567 ✅

P-1 오독 5건 이하 → 5건 ✅ · P-2 79.2·125.8 여전히 틀림 ✅ · P-3 등급 변경 0/80 ✅ · P-4 ✅

🔴 P-4 가 제일 값이 나갔습니다 — 판정은 재현됩니다

바로 위에서 「문장 생성이 흔들린다」고 적었다가 정정한 그 자리를 재 봤습니다. 프롬프트가 안 바뀐 66문장이 전부 비트 동일합니다. 🔴 「흔들린다」는 말은 근거가 없었습니다. 같은 프롬프트는 같은 문장을 냅니다 — 앞선 회차들의 비교가 그만큼 단단하다는 뜻이기도 합니다.

🔴 문장이 없는 측정값을 말하고 있었습니다

상수 10.0 이 문장에 그대로 나가고 있었습니다 — “팔로스루도 10프레임만 지속되어” · “지속 시간 10.0프레임도 충분하나”. 평가 표본의 문장들이 재지도 않은 프레임 수를 말하는 모양이었습니다. 🔴 제품에는 이 결함이 없습니다 — 서비스 경로는 실제 측정값을 넘깁니다. 평가 계기만의 결함이라 고친 것도 eval/ 뿐입니다.

남은 5건 — 성격이 하나로 모입니다

follow_through 79.2·125.8(“팔로스루가 짧고 방향을 유지하며” ← 잘함 문구) · trunk_lean 29.8·32.2 · hip_rotation 85.8. 🔴 전부 옆 등급 문구를 끌어오는 것이고, 그것이 B 가 겨냥하는 자리입니다. 이제 계기가 곧으니 B 를 잴 수 있습니다.

  • 🔴 기준선이 바뀌었습니다 — 가-2·가-3 숫자는 10.0 위에서 났고 그대로 기록으로 남깁니다. 이 회차부터가 새 기준선(evidence_filler_after.json)입니다
  • 확인: cd agent && uv run python eval/pending23_evidence/check_boundaries.py evidence_filler_after.json

✅ B 도 돌렸습니다 — 합격, 그런데 예측 넷 중 둘이 빗나갔습니다 (2026.09.17)

사전 등록 PREREGISTRATION_other_levels.md(코드 전, a2e9df6) · 결과 RESULTS_other_levels.md. 수준 목록에 판정 등급 하나만 싣습니다. 🔴 앵커는 셋 다 남겼습니다(1회차에서 앵커를 줄였다가 2등급 문장 8건 중 4건이 무너졌습니다) · 항목 취지·bands 무변경, B-6 재실행 없음.

  기준 A B  
A·B·C R1 · R2 · R2′ 0 0 ✅
D 방향 오독 5/30 3/30 ✅
E 🔴 잘함 문장 오독 0/22 0/22 ✅
F·G 밴드값 · 테스트 567 0/80 · 569 ✅
🔴 예측이 양쪽으로 틀렸습니다 — 이게 이 회차의 제일 큰 결과입니다

P-2: 「trunk_lean 29.8·32.2 는 그 말이 항목 취지에만 있으니 수준 문구를 빼도 안 고쳐진다」 → 둘 다 고쳐졌습니다. 출처 추적은 맞았는데 (그 말은 정말 취지에만 있었습니다) 그 다음 추론이 틀렸습니다 — 수준 문구 셋이 경쟁하지 않게 되자 모델이 판정 등급 문구에 붙었고 취지의 말을 안 끌어왔습니다.

반대쪽도: 「hip_rotation 85.8 은 [아쉬움] 수준 문구가 출처니 고쳐진다」 → 안 고쳐졌습니다. 그 문구를 뺐는데 같은 말을 합니다.

🔴 프롬프트에서 문구를 찾아 「여기서 온다」고 짚는 것은 가설이지 인과가 아닙니다. 이번에 양쪽으로 다 틀리며 그것이 드러났습니다. P-1(2건 이하 예측 → 3건)도 빗나갔습니다.

🔴 새로 나온 것 셋

⑴ 영어가 다시 섞였습니다 — “충분히 wasn’t fully extended“. 1회차에 있다가 2회차에 사라졌던 결함이고, 줄인 대가일 수 있습니다(안 쟀습니다). ⑵ 잘함 문장이 길어졌습니다 — 수준 문구가 빠진 자리를 모델이 말로 메웁니다. ⑶ 🔴 R3 의 허용오차가 단위를 안 봅니다. plant_foot_to_ball_offset 은 0~1 비율인데 허용오차가 0.55(각도용)라, 측정 0.1 에 “어깨너비 0.22배“(앵커 값을 그대로 벤 것)라고 써도 안 걸립니다. 지금 안 고칩니다 — 사전 등록된 판정 기준이라 새 사전 등록 사안입니다.

남은 3건과 다음

hip_rotation 85.8 · instep/trunk_lean 1.8·23.5(🔴 악화 — A 에서는 맞았습니다). 줄어도 자리를 옮깁니다 — 이 항목에서 네 번째 보는 형태입니다.

다음은 넷이고 🔴 한 회차에 하나씩 합니다(이번에 예측이 틀린 이유가 여러 경로를 한꺼번에 본 탓이라): ⑴ 85.8 의 경로 가르기 ⑵ trunk_lean 악화 ⑶ 영어 혼입 세는 기준 ⑷ R3 허용오차의 단위.

🔴 C 를 돌렸습니다 — 85.8 의 말은 항목 취지에서 옵니다. 그런데 방향 오류는 거기서 오지 않습니다 (2026.09.17)

사전 등록 PREREGISTRATION_source_trace.md(코드 전, cfa64ad) · 결과 RESULTS_source_trace.md · 판독 direction_reading_trace_20260917.json. 위 ⑴만 했습니다. 조사 회차라 src/ 도 rubrics/*.yaml 도 안 고쳤습니다 — dataclasses.replace 로 항목 사본을 만들어 넘겼고, 제품 프롬프트는 B 그대로입니다. B-6 재실행 없음.

2×2 제거 실험 — 항목 취지 × 옆 등급 앵커를 각각 끄고 80문장씩 넷. 재현 확인 통과(C0 을 다시 만드니 14/14 한 글자도 안 다름).

  기준 C0(=B) C1 취지끔 C2 앵커끔 C3 둘다  
A·B R1 · R2 0 0 0 0 ✅
C R2′ 경계 누출 0 0 0 1 🔴 C3 불합격
D 방향 오독 /30 5 9 3 3 보고만
E 잘함 오독 /22 1 0 1 1 보고만
F·G 밴드값 · 테스트 — 80/80 80/80 80/80 · 569 ✅
(참고) R3 지어낸 수치 2 1 5 8 보고만
🔴 어구의 출처는 취지 하나입니다 — 앵커 축으로는 안 움직입니다

hip_rotation 8문장 중 「인사이드 면」이 나온 수: 취지 켬이면 7·7, 끔이면 4·3. 앵커를 꺼도 안 바뀝니다. 🔴 P-1 이 틀렸습니다 — [잘함] 앵커에 “인사이드 면이 공을 향해 열렸다” 가 완성형 그대로 있는데도 취지를 끄면 그 말을 안 끌어옵니다. 앵커는 어투를 주고 취지는 말을 줍니다.

🔴 그런데 어구와 방향 오류는 다른 경로입니다 — 이게 제일 큰 결과입니다

85.8 한 자리를 네 팔로 나란히 놓으면:

  문장 어구 방향
C0 “인사이드 면이 공에 충분히 닿지 않아” 있음 🔴 틀림
C2 “지나치게 많이 돌렸다. 인사이드 면을 공에 대는 데 필요한 각도보다 과도하게” 있음 ✅ 맞음

🔴 어구를 그대로 쓰면서 방향은 맞습니다. 그러니 「인사이드 면」이라는 말 자체가 결함이 아니고 취지를 지운다고 방향이 고쳐지지도 않습니다. 85.8 의 방향을 고친 것은 옆 등급 앵커를 끈 쪽입니다. 이 항목에서 다섯 번 「문구를 찾아 여기서 온다」고 짚으며 어구의 출처를 곧 오류의 원인으로 읽었는데, 그 둘이 갈라진다는 것을 처음 측정했습니다.

🔴 닫는 경로 둘

⑴ 「취지를 뺀다」 — 닫습니다. 방향 오독이 5 → 9 로 거의 두 배입니다 (“차는 다리가 완전히 펴진 상태였으나” · “기준 범위 중앙에 근접했으나”). 항목 취지가 방향을 잡아 주고 있었습니다 — 「무엇을 하려는 동작인가」가 없으면 모델은 기준과의 거리로만 말하고 그 부호를 자주 틀립니다. ⑵ 「옆 등급 앵커를 끈다」 — 닫습니다. R3 가 2 → 5 → 8 이고 C3 에서는 경계값까지 샜습니다(“더 넓은 각도(예: 150도 이상)로” ← 150 은 밴드 경계). 🔴 앵커가 숫자를 붙잡고 있었습니다 — 어투 본보기인 줄만 알았는데 「말해도 되는 숫자는 이것뿐」이라는 울타리이기도 했습니다. 1회차의 대가 (잘함 문장 붕괴)는 재현되지 않았고(P-4 빗나감) 다른 축이 무너졌습니다.

🔴 그리고 — 판독이 재현되지 않습니다. 다음 회차는 이것부터입니다

C0 은 B 와 같은 파일·같은 문장인데 B 에서 D 3/30 · E 0/22 로 적은 것이 이번 판독에서 D 5/30 · E 1/22 입니다. 문장도 규칙도 판독자도 안 바뀌었습니다. 늘어난 자리는 follow_through 79.2·125.8(B 가 「고쳐진 4건」으로 적은 둘 — “과도하게 뻗은 마무리다. 추가 굴곡이 부족해” 처럼 첫 절은 맞고 뒤 절이 반대인 문장을 앞 절만 보고 넘겼습니다)과 swing_knee_extension 148.8.

🔴 앞서 적은 D 숫자들을 정정합니다 — 회차를 건너는 대조(5 → 3 같은 개선 서술)는 판독 차이만큼 흔들립니다. 이 회차 안의 네 팔 대조는 한 판에 자리별로 나란히 읽어서 성립합니다. 🔴 D 에 합격선을 안 둔 것이 이 회차를 구했습니다 — 두었으면 흔들린 자리에서 합격/불합격이 갈렸을 겁니다.

다음 (전부 새 사전 등록)

⑴ 🔴 판독을 먼저 고칩니다 — 나머지 숫자가 전부 여기 얹혀 있습니다. ⑵ 85.8 은 앵커를 끄지 말고 좁히는 길(측정값은 남기고 문장만 줄이기)이 숫자 울타리를 지키는지. ⑶ 판정 메타언어 누출(“판정 수준인 보통 판정과 일치하며” — 「등급」이 아니라 R1 을 피합니다)·영어 혼입은 세는 기준부터.

   
확인 cd agent && uv run python eval/pending23_evidence/check_boundaries.py evidence_trace_C3.json --show — R2′ 1건
하지 말 것 🔴 어느 팔도 제품에 올리지 않습니다. C1·C2·C3 은 전부 닫힌 경로입니다(위)

✅ D 를 돌렸습니다 — 틀린 쪽은 B 회차였습니다. 계기는 C 를 그대로 재현합니다 (2026.09.17)

사전 등록 PREREGISTRATION_reading.md(코드 전, e012918) · 결과 RESULTS_reading.md · 판독 reading_pass1.json·reading_pass2.json. 바로 위 「다음」 ⑴만 했습니다. 🔴 제품을 안 고쳤습니다 — src/도 루브릭도 그대로고, 문장도 다시 안 만들었습니다(같은 JSON을 읽습니다).

판독을 문장 단위 → 절 단위로 바꿨습니다. 산출물이 wrong/ok 한 글자가 아니라 절 목록 + 절마다 판정입니다. 쪼개기는 코드(split_clauses.py)라 결정론적이고, 자리마다 맞는 방향과 반대 조각의 말을 나란히 놓습니다.

  기준 결과 합격선  
A 1차·2차 불일치 0/30 (절 단위 0/44) ≤1 ✅
B C 회차 5자리를 잡는가 5/5 5/5 ✅
C·D·E 빈 칸 · 결정론 · 테스트 0 · 동일 · 569 — ✅
🔴 B 3/30 과 C 5/30 중 C 가 맞았습니다

두 번 읽어 두 번 다 C 와 똑같은 5자리였습니다. 🔴 예측(P-1 「절을 다 보면 더 나온다」)이 빗나간 것이 좋은 소식입니다 — 새 계기가 과하게 잡지 않는다는 뜻이고, 숫자가 안 움직였으므로 같은 것을 재면서 재현만 얻었습니다.

그래서 진단이 바뀝니다. 「판독이 구조적으로 안 재현된다」가 아니라 「B 회차 한 번의 실행이 빠뜨렸다」입니다 — 빠뜨린 자리가 전부 첫 마침표 다음이었던 것과 맞습니다. 🔴 앞서 「회차를 건너서 D 를 나란히 놓지 말라」고 적은 것은 그대로 둡니다. 어느 회차가 빠뜨렸는지 알 방법이 여전히 없기 때문입니다 — 오늘 B·C 를 가른 것은 계기가 생긴 뒤입니다.

🔴 판정 규칙에 구멍이 있습니다 — 둘이 규칙 밖에서 세어졌습니다

규칙은 「반대 조각의 방향을 주장하면 오독」인데, wrong 5건 중 둘은 반대 조각을 주장하지 않습니다:

자리 절  
instep/trunk_lean 1.8 “약간 뒤로 젖혀져” 맞는 방향도 반대 조각도 아닙니다 — 맞는 방향을 부정하는 제3의 주장
instep/trunk_lean 23.5 “기준 범위 내” 방향을 아예 안 말하고 「문제없다」고 합니다

두 회차 다 관례로 wrong 을 줬습니다. 🔴 규칙 문구로는 근거가 없습니다. 이번엔 안 고쳤습니다(사전 등록 6절). 넓히면 앞 회차 숫자와 비교가 또 끊기므로, 넓힐지가 판단이고 새 사전 등록입니다.

🔴 사전 등록의 「과하게 쪼갠다」가 부분적으로 틀렸습니다

30자리 중 19가 한 덩어리입니다(「덜 열려」 같은 어미가 경계 목록에 없습니다). 즉 그 자리들은 덜 쪼갭니다 — 안 난다고 적은 방향의 실패입니다. 이번엔 빠뜨렸던 셋이 전부 문장 경계 뒤라 안 물렸지만, 한 덩어리 안에 숨은 절은 여전히 못 잡습니다. 운이 좋았던 것이지 계기가 막은 것이 아닙니다.

🔴 증명 못 한 것 — 2차가 1차 기억에 오염됐습니다

같은 세션의 같은 판독자입니다. 순서를 섞고 자리 이름을 가렸지만 문장은 기억합니다. 불일치 0 은 재현성의 낙관적 상한이고 「재현된다」가 아닙니다.

🔴 진짜 검증은 다른 세션이 reading_sheet1.json 의 빈 칸을 채우는 것입니다. 이번 회차는 그것을 못 했습니다.

   
확인 cd agent && uv run python eval/pending23_evidence/split_clauses.py --out /tmp/x.json — 30자리·절 44개, 두 번 돌려 동일
하지 말 것 🔴 앞 회차의 D 숫자를 새 계기로 다시 재서 갈아 끼우지 않습니다 — 옛 숫자는 옛 계기의 기록입니다 · 🔴 규칙을 결과 보고 넓히지 않습니다(위)

🔴 E 를 돌렸습니다 — 불합격, 그리고 되돌렸습니다. 가설은 맞았는데 대가가 컸습니다 (2026.09.17)

사전 등록 PREREGISTRATION_anchor_values.md(코드 전, b296b35) · 결과 RESULTS_anchor_values.md · 판독 reading_E.json·reading_E_grade2.json. C 가 남긴 유일한 길(앵커를 끄지 말고 좁히기)을 쟀습니다.

옆 등급 앵커의 문장만 빼고 측정값은 남겼습니다. 가설은 「앵커 한 줄이 두 일을 한다 — 측정값이 숫자를 가두고 문장이 방향을 끈다」였습니다.

  기준 C0 E 합격선  
A·B·C R1·R2·R2′ 0 0 0 ✅
D R3 지어낸 수치 2 1 2 이하 ✅
E 방향 오독 /30 5 2 5 초과 불합격 ✅
F 🔴 잘함 문장 오독 /22 1 6 1 이하 🔴 불합격
G·H 밴드값 · 테스트 — 80/80 · 569 — ✅
✅ 가설은 맞았습니다 — 두 역할이 실제로 갈라집니다

C2(앵커 통째 제거)는 방향을 고치면서 지어낸 수치를 2 → 5 로 올렸는데, 문장만 빼니 R3 가 1건입니다. 울타리가 섰습니다. 겨냥한 85.8 도 고쳐졌습니다 — “지나치게 많이 회전되어“(C0 는 “인사이드 면이 공에 충분히 닿지 않아”). 감점 쪽 방향 오독 5 → 2.

🔴 그런데 잘함 문장이 무너졌습니다 — 같은 자리를 세 번째로 밟았습니다

1 → 6/22. 새로 무너진 다섯 중 넷이 패스인데 공이 뜨는 것을 칭찬합니다:

“짧은 스윙이 공을 효과적으로 위로 전달하는 데 도움이 되었다” “공을 효과적으로 공중에 띄울 수 있어 좋았다”

루브릭 취지는 「슈팅처럼 끝까지 펴면 공이 뜨고 세기 조절이 안 된다」입니다. 🔴 수준별 「값」만 남기고 「뜻」을 빼면 모델이 뜻을 지어냅니다. 감점 쪽은 「지나치다/부족하다」가 값에서 읽히지만 칭찬 쪽은 「왜 좋은가」가 값에 없습니다. 🔴 값만 남기는 것이 아예 없는 것보다 나빴습니다 — C2 는 잘함 오독이 1/22 로 안 무너졌습니다. 반쪽 단서가 없는 단서보다 해롭습니다.

되돌렸습니다

사전 등록한 관문이 계약이라 judge.py 를 E 이전으로 되돌렸습니다. 얻은 것이 작지 않지만 잘함 22문장은 선수가 칭찬으로 읽는 자리이고 거기서 여섯 배가 됐습니다. 🔴 검사 독스트링에 「이 길을 재 봤고 닫혔다」를 숫자와 함께 적어 뒀습니다 — 안 적으면 다음 세션이 또 합니다. (ANCHOR_HEADER 상수만 남겼습니다 — 검사가 머리말로 프롬프트를 자르다 IndexError 로 죽어 「검사가 깨졌다」가 「성질이 깨졌다」를 가렸습니다. 동작은 한 글자도 안 바뀝니다.)

덤 — 🔴 루브릭 앵커가 결함 문구를 가르칩니다

instep/trunk_lean 2등급 앵커의 문장이 “기준 범위 내” 입니다 — D 회차가 23.5 에서 오독으로 센 바로 그 표현입니다. 모델이 지어낸 것이 아니라 루브릭이 그렇게 쓰라고 가르치고 있습니다. 사전 등록에 없어 판정엔 안 넣었고, 고치는 것은 별도 회차입니다(🔴 앵커를 고치면 eval/ 의 채우는 값도 바뀌어 A 회차 기준선이 흔들립니다).

   
확인 cd agent && uv run pytest tests/test_scoring.py::test_the_other_grades_keep_all_their_anchors -q — 되돌린 상태를 지킵니다
🔴 하지 말 것 「값만 남기기」를 다시 재지 않습니다 — 쟀고 닫혔습니다. 남은 갈래는 어느 등급의 문장을 남길지이고 새 사전 등록입니다

✅ F 를 돌렸습니다 — 아무것도 안 빼고 차례만 바꿨습니다. 합격, 다만 한 문장 차이입니다 (2026.09.17)

사전 등록 PREREGISTRATION_anchor_order.md(코드 전, aa2f8e4) · 결과 RESULTS_anchor_order.md · 판독 reading_F.json·reading_F_grade2.json.

1회차·C·E 가 전부 「덜 준다」였고 셋 다 무언가를 무너뜨렸습니다. 이번엔 아무것도 안 빼고 같은 것을 다른 차례로 줬습니다 — 앵커 정렬을 등급 순 → 측정값 순으로. C 가 짚어 둔 구조입니다: 양방향 구간에서는 사다리가 값에 대해 단조가 아닙니다(잘함 24 → 보통 45 → 아쉬움 5).

  기준 C0 E F 합격선  
A·B·C R1·R2·R2′ 0 0 0 0 ✅
D R3 지어낸 수치 2 1 0 2 이하 ✅ 최선
E 방향 오독 /30 5 2 4 5 초과 불합격 ✅
F 🔴 잘함 오독 /22 1 6 1 1 이하 ✅ 경계
G·H 밴드값 · 테스트 — — 80/80 · 569 — ✅
🔴 판정이 한 문장에 걸렸습니다 — 먼저 밝힙니다

합격선이 1건인데 결과가 1건입니다. 경계에 놓인 문장이 하나 있습니다 — inside/follow_through 19.1: “…다리가 충분히 뒤로 젖혀져 방향을 유지하며 마무리했다”. ok 로 셌습니다 — 「충분히」는 적당하다는 뜻이지 과하다가 아니고, 2등급은 적당한 구간이라 그 서술이 맞습니다. (E 에서 같은 자리를 wrong 으로 센 것은 “비교적 깊게” 였기 때문이고 「깊게」는 「짧게」의 직접 반의어입니다.)

🔴 wrong 으로 세면 2/22 라 불합격입니다. 다른 판독자가 다르게 볼 수 있고 그러면 결론이 뒤집힙니다. 합격을 그대로 믿지 마시고 이 한 줄을 먼저 보십시오 (reading_F_grade2.json 에 근거를 남겼습니다).

✅ 무엇이 좋아졌나 — 빼지 않고

R3 가 0건입니다(C0 2 · C2 5 · C3 8 · E 1). 85.8 도 고쳐졌습니다 — “골반이 지나치게 많이 열렸다“. 🔴 E 가 무너뜨린 잘함 둘이 회복됐습니다(132.2·142.8 — E 에서는 공을 띄우는 것을 칭찬했습니다). 2등급에 개선 요구를 붙이던 사족도 사라졌습니다.

🔴 그런데 방향 오독은 E 보다 나쁘고(2 → 4) 자리를 또 옮겼습니다

악화 둘(hip_rotation 129.2 · instep/trunk_lean 26.5)이 같은 모양입니다 — 맞는 방향을 말하고 나서 뒤집습니다: “지나치게 많이 열렸다. 인사이드 면이 … 도움이 되지만” · “깊이 숙이면서 … 약간 과도하게 뒤로 젖혀졌다“. 🔴 「줄어도 자리를 옮긴다」가 여섯 번째입니다.

🔴 예측 P-5 가 틀렸습니다 — 차례는 모든 자리를 흔듭니다

「한 방향 항목은 등급 순과 값 순이 같으니 안 바뀐다」고 봤는데 양방향 67% · 한방향 64% 로 사실상 같습니다. 값이 작을수록 좋은 항목은 두 순서가 서로 뒤집히기 때문입니다. 🔴 그래서 이 변경은 제가 말한 것만큼 좁지 않습니다 — 80문장 중 52개를 건드렸고, 얻은 것이 정말 사다리 덕인지 이 회차는 못 가릅니다.

   
확인 cd agent && uv run python eval/pending23_evidence/check_evidence.py eval/pending23_evidence/evidence_anchor_order.json — R1·R2 0 · R3 0
🔴 하지 말 것 「얻은 것이 사다리 덕」이라고 적지 않습니다 — 안 갈랐습니다(P-5). 가르는 회차가 다음입니다 → ✅ 갈랐습니다 (G, 아래)

✅ G 를 돌렸습니다 — 사다리 설명은 「방향」에서만 섭니다. 그리고 앞 회차 서술을 정정합니다 (2026.09.17)

사전 등록 PREREGISTRATION_order_source.md(세기 전, cce83a9) · 결과 RESULTS_order_source.md · 계기 order_source.py. 관측 회차라 새 문장을 안 만들었고(GPU 안 씀) src/ 도 루브릭도 안 고쳤습니다. 판독도 다시 안 했고 reading_F*.json 을 그대로 읽었습니다.

자리를 두 축으로 갈랐습니다 — ㉮ 양방향인가 · ㉯ 앵커 차례가 실제로 바뀌었는가. 둘 다 코드로 판정되어 판독이 안 들어갑니다.

✅ 계기 검사 통과 — 대조가 성립합니다

차례가 안 바뀐 자리 18개 중 문장이 바뀐 것 0개. 차례바뀜은 52/62(84%) 가 바뀌었습니다. 프롬프트가 한 글자도 안 달라진 자리에서는 문장도 한 글자도 안 달라졌습니다.

판정 — 축마다 다릅니다
얻은 것 어느 칸  
방향 고쳐짐 3 · 악화 2 전부 양방향 ✅ 사다리 설명이 섭니다
R3 2 → 0 양방향 1 · 한방향 1 🔴 흩어집니다 — 출처 미상
잘함 「회복」 2 둘 다 한방향 🔴 사다리와 무관

30자리 중 양방향이 24개인데 방향 변화 5건이 전부 거기 몰렸습니다. 방향 오독은 반대 조각을 고르는 문제이고 사다리를 펴면 고를 것이 줄어든다는 설명이 이 축에서는 섭니다.

🔴 정정 — F 에 적은 「잘함 회복」은 얻은 것이 아니었습니다

「E 가 무너뜨린 잘함 둘이 회복됐다」고 적었습니다. 사실이지만 견줄 대상을 잘못 골랐습니다 — 그 둘은 C0 에서도 ok 였습니다. F 가 되찾은 것이 아니라 E 가 깨뜨린 것이 안 깨진 것이고, 기준선이 C0 이므로 이 축에서 F 의 이득은 0입니다(잘함 오독 C0 1/22 · F 1/22). 게다가 두 자리는 한 방향이라 사다리와 아예 무관합니다.

🔴 제 설명이 틀리는 쪽이 값진 결과인 것이 이 항목에서 다섯 번째입니다 (B·C·D·F·G).

결론 — 실험 회차를 열지 않습니다

실험을 부르던 조건이 「얻은 것이 흩어지는가」였는데, 방향은 안 흩어졌고 잘함은 애초에 얻은 것이 없었습니다. 남은 흩어짐은 R3 하나인데 지금 0건이라 더 좋아질 곳이 없어 실험 비용이 이득보다 큽니다.

   
확인 cd agent && uv run python eval/pending23_evidence/order_source.py — P-1 18곳 중 0곳
🔴 하지 말 것 R3 0건을 「사다리 덕」으로 적지 않습니다 — 한 방향 자리에서도 없어졌습니다. 출처 미상으로 남깁니다

🔴 H 를 돌렸습니다 — 겨냥한 것은 맞혔는데 총량이 그대로입니다. 되돌렸습니다 (2026.09.17)

사전 등록 PREREGISTRATION_one_way.md(코드 전, ef0d039) · 결과 RESULTS_one_way.md · 판독 reading_H.json·reading_H_grade2.json.

남은 오독 넷이 한 문장 안에서 양쪽을 다 말하는 모양이라, 끝맺음에 한 줄을 더했습니다 — 「자세가 어느 쪽으로 치우쳤는지는 한 방향으로만 말합니다」. 🔴 반대 방향을 이름으로 부르지 않았습니다(프롬프트에 있는 말은 언젠가 새어 나옵니다 — C 에서 두 번 확인).

  기준 F H 합격선  
A·B·C·D R1·R2·R2′·R3 0 0 — ✅
E 방향 오독 /30 4 2 2 이하 ✅
F 🔴 잘함 오독 /22 1 3 1 이하 🔴 불합격
G·H 밴드값 · 테스트 — 80/80 · 569 — ✅
🔴 틀린 문장의 총량이 그대로입니다 — 일곱 번째

F: 방향 4 + 잘함 1 = 5 · H: 방향 2 + 잘함 3 = 5. 감점 쪽은 반으로 줄고 새 악화도 없는데(129.2·26.5 가 고쳐졌습니다) 잘함 쪽이 정확히 상쇄했습니다.

inside/swing_knee_extension 132.2·142.8 (2등급) “짧은 스윙이 패스보다 슈팅에 가깝지만, 각도 조절이 완벽하지 않아”

🔴 금지가 한쪽에만 들었습니다 — 감점 문장에서는 균형 문구가 사라졌는데 칭찬 문장에서는 「하지만」이 남고 그 뒤가 결함 어휘로 채워졌습니다.

🔴 남은 오독 둘은 같은 문구이고 출처가 앵커 하나입니다

follow_through 79.2·125.8 이 둘 다 “추가 굴곡이 부족해” 입니다. 프롬프트에서 「추가 굴곡」이 나오는 자리가 하나뿐입니다 — [아쉬움] 앵커 문장(“추가 굴곡 1.5도로 임팩트 직후 스윙이 끊겼다”). 지표의 정식 라벨은 「임팩트 후 주동 고관절 굴곡」입니다. 132.2·142.8 의 「슈팅」도 같습니다 — 그 항목 0등급 앵커에 있습니다.

🔴 어휘가 등급을 넘어 샙니다. 다만 C 의 교훈도 함께 적습니다 — 어구를 짚은 것은 가설이지 인과가 아닙니다.

✅ 정정 — 앵커 「문장」을 고치는 것은 기준선을 안 흔듭니다

E 결과와 바로 위 항목에 「앵커를 고치면 eval/ 의 채우는 값이 바뀌어 A 기준선이 흔들린다」고 적었는데 절반만 맞습니다. filler() 가 쓰는 것은 앵커의 값(measured)이지 문장(evidence)이 아닙니다. 🔴 문장만 고치면 채우는 값은 한 글자도 안 바뀝니다. 그래서 다음 회차가 쌉니다.

되돌렸습니다

관문이 계약이고 총량이 안 줄었으므로 남길 이유가 약합니다. 되돌린 뒤 코드 변경은 주석뿐이고 프롬프트는 한 글자도 안 다릅니다. 🔴 주석에 숫자와 함께 남겼습니다 — 안 적으면 다음 세션이 「한 방향으로만 말하라고 시키면 되지 않나」를 또 합니다.

   
확인 cd agent && uv run pytest tests/ -q — 569 · 프롬프트는 F 상태 그대로
🔴 하지 말 것 「한 방향으로만」 한 줄을 다시 넣지 않습니다 — 쟀고, 총량이 안 줄었습니다

🔴 I 를 돌렸습니다 — 어구는 완전히 사라졌는데 행동은 안 바뀌었습니다. 되돌렸습니다 (2026.09.17)

사전 등록 PREREGISTRATION_anchor_text.md(루브릭 수정 전, 1b96cd02) · 결과 RESULTS_anchor_text.md · 판독 reading_I.json.

앵커 문장 자신이 규칙을 어기고 있었습니다. 프롬프트는 모델에게 「등급 번호와 기준 구간은 쓰지 마세요」라고 시키는데, 앵커 넷은 자세를 아예 안 말하고 “기준 범위 중앙” 처럼 구간만 말했습니다. 전수 조사로 앵커 7개만 골라 고쳤습니다(⑴ 구간 → 그 등급의 자세 서술 ⑵ 지표를 정본 라벨로). 🔴 measured 값은 한 글자도 안 바꿨습니다.

  기준 F I 합격선  
A·B·C R1·R2·R2′ 0 0 0 ✅
D R3 지어낸 수치 0 3 2 이하 🔴 불합격
E 방향 오독 /30 4 6 4 초과 불합격 🔴 불합격
G·H 밴드값 · 테스트 — 80/80 · 569 — ✅
I 「기준 범위」·「중간 구간」·「추가 굴곡」 20 0 늘면 불합격 ✅

(기준 F 는 안 읽었습니다 — D·E 가 이미 떨어져 판정이 안 바뀝니다.)

✅ 출처 추적이 처음으로 인과로 확인됐습니다

앵커에만 있던 세 어구가 문장에서 20건 → 0건입니다. C 회차가 「어구를 짚은 것은 가설이지 인과가 아니다」라고 적은 그 자리에서, 이번엔 제거로 확인됐습니다.

🔴 그런데 같은 것이 다른 말로 돌아왔습니다

“거의 수직에 가깝지만 기준 수준 미달” · “8프레임은 기준에 미달하나, 전체적인 마무리는 보통 수준이다”

🔴 판정 메타언어는 「어휘」가 아니라 「습관」입니다. 정확한 어구를 세는 기준 I 는 통과했는데 그 기준이 재려던 것은 안 고쳐졌습니다 — 기계적 계수가 약속한 것만 정확히 재고 빗나갈 수 있습니다.

🔴 새 결함 — 본보기를 좋게 만들수록 통째로 베낍니다

R3 3건 중 둘이 앵커 문장을 값까지 그대로 옮긴 것입니다: “기울기 -8.7도로 상체가 거의 세운 채로 changed”(−8.7 은 0등급 앵커 값) · “회전 범위 9.1도로 …, 회전 범위 19.8도로 …”(앵커 두 줄을 이어 붙였습니다).

고치기 전 앵커는 “기준 범위 중앙” 처럼 답으로 쓸 수 없는 조각이라 모델이 스스로 지어야 했습니다. 고친 뒤에는 그 자체로 완성된 답이라 베끼는 것이 가장 쉬운 길이 됐습니다. 🔴 본보기의 품질과 베끼기는 맞바꿈입니다 — 이 항목에서 처음 보는 축입니다.

되돌렸습니다 · 배운 것

git checkout agent/rubrics/ 로 원상복구했습니다. 🔴 measured 를 안 건드렸으므로 A 회차 기준선은 안 흔들렸습니다(밴드값·등급 80/80 동일) — H 에서 한 정정이 여기서 확인됐습니다.

   
확인 cd agent && grep -c '추가 굴곡' rubrics/*.yaml → 1·2 (원상복구됨)
🔴 하지 말 것 앵커를 다시 손대기 전에 「베끼기」를 세는 기준부터 만듭니다 — 앵커 값은 「지어낸 수치」가 아니라 「남의 측정값」이라 R3 가 성격상 반만 잡습니다
🔴 알림 루브릭 문구를 담당자가 고쳐 본 회차입니다(점수 무변경). 되돌렸으므로 지금 루브릭은 그대로입니다 — 박민호 님께 사실만 알립니다

📄 J — 코드가 조립한 문장을 곁에 놓았습니다 (2026.09.17, 자료 회차)

계기 template_sentences.py · 자료 SIDE_BY_SIDE_template.md(80자리 전문). 🔴 판정 회차가 아닙니다 — 기계 기준이 자명하게 통과해서, 통과를 「이겼다」로 읽으면 안 됩니다. 주장 대신 실제로 돌려 봤습니다: evidence_template_t1/t2.json 에 기존 검사기를 걸어 R1·R2·R2′·R3 전부 0건.

왜 만들었나. F 이후 세 번 연속(E·H·I) 「한쪽을 조이면 다른 쪽이 무너진다」가 났고 틀린 문장의 총량이 5 언저리에서 안 움직입니다. 그런데 방향은 이미 코드가 압니다 — grades_plain 이 「골반을 지나치게 많이 돌린다」라고 정확히 말하고, card_lines 는 아예 선수용 문장입니다. 「등급 판정을 모델에서 코드로 옮긴」 것과 같은 수를 문장에도 두는 길입니다.

자리  
F(모델) “…79.2도로 패스보다 슈팅처럼 과도하게 뻗은 마무리다. 추가 굴곡이 부족해 공이 공중에 뜨는 느낌이 강하다.” 🔴
T1 “임팩트 후 주동 고관절 굴곡 79.2도 — 패스인데 마무리가 슈팅처럼 크다”
T2 “…79.2도 — 패스인데 마무리가 슈팅처럼 큽니다”

🔴 대가는 「말의 가짓수」입니다. 80자리에서 모델은 76가지로 쓰는데 템플릿은 37~39가지입니다 — 같은 등급·같은 조각이면 값만 다르고 말은 똑같아서, 리포트를 여러 장 이어 보면 되풀이가 눈에 띕니다.

🔴 계수를 한 번 잘못 짰다가 고쳤습니다 — 값이 든 문장 전체를 세니 자리마다 달라 80에 붙었습니다(아무것도 안 잽니다). 계수가 재려는 것을 재는지부터 봐야 한다는 것이 I 회차에서 배운 것과 같은 자리입니다.

  • 묻는 것은 하나: 이 문장을 선수에게 보여 줄 만한가. 숫자가 아니라 제품 판단입니다
  • 담당: 박민호(제품 판단) · 정상호(정해지면 구현) · 제기: 정상호

🔴 남은 단계 — 여기서 멈춥니다 (2026.09.17 기준)

아홉 회차(가-2 ~ I)를 돌린 뒤의 현재 상태와 남은 갈래입니다. 이어받는 사람은 여기부터 보면 됩니다.

지금 제품 상태: F 회차(앵커를 측정값 순으로). 방향 오독 4/30 · 잘함 오독 1/22 · R3 0건. E·H·I 는 전부 되돌렸고 코드에 주석으로 「이 길은 재 봤고 닫혔다」를 숫자와 함께 남겼습니다.

  갈래 상태
① 템플릿 채택 여부 🔴 여기서 막혀 있습니다 — 제품 판단 대기. 자료는 위 J
②-가 채택하면 → 제품으로 옮기기 evidence 필드 모양이 그대로라 계약·화면 영향 없습니다. 다만 「무엇을 고치면 되는지 한 마디」가 사라지므로 카드 문구와 겹치는지 봐야 합니다
②-나 부분 채택(방향은 코드, 살은 모델) 모델에게 방향 문장을 주고 다듬게 하는 형태. 새 사전 등록
③ 더 큰 모델(2.4B·7.8B) 라벨 없이 됩니다. 🔴 3.5 계열은 원격 코드라 transformers 버전까지 고정해야 하고(미결 11번), 7.8B 는 로컬 8GB 에 안 들어가 EC2 가 필요합니다
④ 파인튜닝(LoRA) 🔴 라벨이 없어 막혀 있습니다. 권한 경로는 「모델이 쓰고 사람이 고른다」(이진 선택 500~1,000). 🔴 제 판독을 학습 목표로 쓰지 않습니다 — 오늘 제 진단이 다섯 번 틀렸고, 구우면 오독이 고정됩니다. 라이선스는 비상업이라 괜찮다고 확인받았습니다(2026.09.17) → 🔴 착수했다가 다음 주로 미뤘습니다 (2026.09.18, 아래)

④ 파인튜닝 — 준비까지 하고 멈췄습니다 (2026-09-18)

사용자 요청으로 「오늘 안에」 시작했다가 사용자 판단으로 다음 주로 미뤘습니다. 판정 회차가 아닙니다 — 사전 등록·계기·데이터까지입니다. 상세는 agent/eval/pending23_finetune/(정본은 그 폴더의 PREREGISTRATION.md, 이어받는 자리는 STATUS.md).

  • 라벨 문제를 이렇게 풀었습니다: 코드(R1·R2·R3·앵커 베끼기) → 담당자(코드가 낸 grades_plain 방향과의 대조) → 사람(무작위 30개 점검, 합격선 25/30). 🔴 「AI 단독 판독을 정답으로 승격」이 아니게 하려는 구조이고, 미달이면 학습을 안 돌립니다
  • 학습/평가를 가릅니다: instep_shot 42자리로 굽고 inside_pass 38자리로 잽니다. 기준선은 오늘 다시 쟀습니다(C0~J 의 80문장과 직접 비교 안 함)
  • 🔴 다음 주 첫 판단은 선별이 아니라 표집 온도입니다. temp 0.9 후보에서 한국어가 무너집니다(prosecutions · utilized · 「디트리머/디비전발」). 이대로 구우면 얻는 것이 방향 일치뿐이라 P-5(고유 문장 가짓수)에 걸릴 공산이 큽니다
  • 실측(RTX 3050 8GB): 문장 2.5초 · 후보 8개 묶어 6.0초 · 학습 1,400 tok/s (LoRA r=16 q/v, VRAM 5.4GiB) → 500쌍 3epoch 12분. GPU 는 병목이 아니고 EC2 도 필요 없습니다
  • 🔴 src/·루브릭·features 는 한 줄도 안 바뀌었습니다 — B-6 재실행을 안 부르고 되돌릴 것이 없습니다
  • 🔴 완성 전까지 에이전트에 관여하지 않습니다 (사용자 지시, 2026-09-18). 말로 두지 않고 검사로 박았습니다 — agent/tests/test_finetune_stays_out_of_the_product.py 가 ⑴ 제품 경로의 어댑터·LoRA 언급 ⑵ 의존성의 peft·trl ⑶ 구운 어댑터의 커밋을 막습니다. 제품에 넣기로 결정한 뒤 이 항목에 결정을 적고 그 검사를 함께 고칩니다

계기 쪽 잔여 — 제품이 아니라 재는 자입니다:

  • 🔴 「베끼기」를 세는 기준이 없습니다 (I 가 드러냄). 앵커 값이 문장에 그대로 나오는 것은 「지어낸 수치」가 아니라 「남의 측정값」이라 R3 가 반만 잡습니다. 앵커를 다시 손대려면 이것부터
  • 잘함 22자리에 절 단위 계기 미적용 — F 의 판정이 한 문장에 걸렸습니다
  • 🔴 판정 규칙에 구멍 (D 결과 2). 「반대 조각의 방향을 주장」이 「맞는 방향을 부정하는 제3의 주장」과 「방향을 안 말하고 문제없다고 하는 것」을 안 담는데 관례로 세고 있습니다. 넓히면 앞 회차와 비교가 끊기므로 넓힐지가 판단입니다
  • 영어 혼입 · 판정 메타언어 누출 — 세는 기준부터. I 가 메타언어는 어휘가 아니라 습관임을 보였으므로 어구 계수로는 부족합니다

🔴 닫힌 경로는 로드맵 4절에 전부 적혀 있습니다 — 취지 제거 · 옆 등급 앵커 제거 · 값만 남기기 · 「한 방향으로만」 · 앵커 문장 수선. 다시 재지 않습니다.

— 칭찬인지 지적인지는 문자열로 안 갈린다.

덤: 잘함/보통/아쉬움은 문장에 새지 않았다(0/19). 1회차의 내부 용어 누출과 영어 혼입도 사라졌다. 대신 근거 없는 인과가 늘었다 — “왼쪽으로 기울어져 있어 왼쪽 배트 궤적이 더 자연스럽게 전달되었다”(배트 궤적은 재지도 않았다). 「상체 유지」의 부호 대칭 오독은 아직 살아 있다.

   
확인 cd agent && uv run python eval/pending23_evidence/check_evidence.py evidence_after2.json --show — R1 0건 · R2 1건
상세 RESULTS.md·RESULTS_after2.md(판정) · SIDE_BY_SIDE.md·SIDE_BY_SIDE_after2.md(19문장 전문). 모두 agent/eval/pending23_evidence/
   
확인 uv run python eval/jhmdb_batting/realclip/evidence_audit.py --only-deductions — 확정 등급·칭호·구간과 문장을 나란히 찍는다. 판정은 사람이 한다
하지 말 것 등급 결정을 모델로 되돌리지 말 것. 그 경로는 이미 닫혔다(경계값 141.7 vs 140을 재현되게 틀렸다)
근거 agent/eval/jhmdb_batting/realclip/README.md 7절
  • 담당: 정상호 · 제기: 정상호 · 기한: 서비스 화면에 근거 문장이 붙기 전 (jin 구역 7번 「카드를 만드는 자리」가 그 시점이다) — (가)는 끝났고 (나)가 남았다 → 🔴 (나)를 읽었고 살아 있습니다 (2026.09.17, 위 절). 다음은 가-2 이고 사전 등록 + GPU 회차입니다. 검수를 기다리던 이유가 없어졌으므로(2번) 막힌 것은 없고, 안 한 것뿐입니다 → 🔴 가-2·가-3· A·B·C 까지 돌았습니다 (2026.09.17). 다음은 판독 계기 고치기입니다 — C 에서 같은 문장을 다시 읽으니 숫자가 달랐습니다(위 C 절). 처방이 아니라 재는 자를 먼저 고칩니다

24. 근거 문장에 등급이 안 적혀 있습니다 — 화면이 붙여 주세요 (2026.09.07) ✅ 해소 (2026.09.10)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

25. 부록 D.3의 metric_definition이 A안 결정과 어긋납니다 ✅ 해소 (2026.09.08)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

26. 워커는 올렸는데 백엔드 API가 응답하지 않습니다 ✅ 오진 — 의도된 정지였습니다 (2026.09.08) · 완전 종결

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

27. 분석 한 번이 곧 영구 저장입니다 — 저장을 누른 것만 남기고 싶습니다 ✅ 해소 (2026.09.08) — jin 24번이 앞서 있었습니다

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

28. 오버롤 등급이 계산은 되는데 화면까지 안 갑니다 — 읽기 경로에 실어 주세요 (2026-09-08 신설) ✅ 해소 (2026.09.11)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

29. 루브릭 항목 이름을 쉬운 말로 바꿨습니다 — rubricFocus.ts 를 맞춰 주세요 (2026-09-08 신설) ✅ 해소 (2026.09.10)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

30. 업로드 60초 상한을 정해 주세요 — 기술 판단은 끝났고 제품 판단만 남았습니다 ✅ 결정 (2026.09.11, 박민호)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

31. 제 스프린트 산출물 이름이 실제로 만든 것과 다릅니다 — 「RAG 검증」 ✅ 결정·반영 (2026.09.11, 박민호)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

32. player_vector 차원 — 하나로 정할 문제가 아닙니다 (2026-09-09 신설)

계약 문서에 남은 것 셋 중 3번이 “player_vector(SFR-005) — pgvector 는 깔렸고 차원 수가 1번에 걸려 있다” 인데, 그 1번(지표 코드 목록)이 오늘 풀렸습니다 (같은 파일 jin 23번). 그래서 답을 냅니다 — 다만 물으신 형태로는 답이 안 됩니다.

재료

측정 지표는 12개이고, 그중 impact_frame 은 빼야 합니다 — 클립을 어디서 잘랐는지에 따라 변하는 시각이라 선수의 성질이 아닙니다. 남는 것은 11개입니다 (각도 9 · 비율 1 · 초 1).

🔴 그런데 전역 11차원으로 두면 안 됩니다 — 이유 셋

⑴ 대부분이 결측입니다. 루브릭 하나가 쓰는 지표는 4~7개뿐입니다.

루브릭 쓰는 지표 비는 칸
축구 인스텝 슈팅 7 4
축구 인사이드 패스 6 5
야구 투구·타격 · 농구 점프슛 각 5 각 6
농구 레이업 4 7

⑵ 결측을 0으로 채우면 ho 21번을 벡터 수준에서 되풀이합니다. 그 항목이 고친 결함이 정확히 「못 잰 것을 0.0으로 지어낸다」였습니다. 여기서는 더 나쁩니다 — 0으로 채우면 축구 선수와 야구 선수가 서로 비는 칸에서 「똑같다」고 나와 유사도가 올라갑니다. 안 잰 것이 닮음의 근거가 됩니다.

⑶ 종목을 넘는 비교는 뜻이 없습니다. 「성향」은 플레이 스타일 유사도인데, 투수의 팔꿈치 각과 축구 선수의 그것을 견주는 것은 스타일 비교가 아닙니다. 게다가 스케일이 섞여 있어(각도 0~180 · 비율 0~3 · 초 0~1) 정규화 없이는 각도 차원이 유사도를 독식합니다.

권고 — 루브릭(종목·동작)마다 별도 벡터 공간

안 차원 평가
(가) 권고 루브릭별 4~7 결측이 없고, 스케일 정규화도 그 안에서만 하면 됩니다. 🔴 같은 루브릭끼리만 비교합니다
(나) 전역 11 + 결측 마스크 마스크 칸이 하나 더 필요하고, 유사도 계산이 마스크를 반드시 봐야 합니다. 안 보면 ⑵가 그대로

(가)가 맞다고 봅니다. 적합도는 「경기 지원 건에 종속」이고 경기의 종목은 team 이 정하므로(SFR-010), 한 비교 안에 두 종목이 섞일 일이 없습니다. jin 17번에서 「축이 종목이 아니라 루브릭이다」라고 정리한 것과 같은 결론입니다.

🔴 차원 수를 상수로 박지 마세요. 루브릭이 늘거나 항목이 바뀌면 달라집니다. 지금 값은 아래 명령이 냅니다.

cd agent && uv run python scripts/export_metric_definitions.py --include-draft

제가 아직 안 만든 것 — 만들 자리가 생기면 합니다

벡터를 누가 만드는가는 정해지지 않았습니다. 에이전트가 분석할 때 함께 내는 것이 자연스럽지만(지표의 뜻을 아는 쪽입니다), 지금 만들면 받을 데가 없어 쓰이지 않는 산출물이 하나 늘어납니다 — 서버에 pgvector 도 아직 소스 빌드 전이라고 적어 두셨습니다. 자리가 정해지면 제가 냅니다.

   
판단해 주실 것 (가)냐 (나)냐 · 벡터를 누가 만들어 넣는지(에이전트가 낼지, 백엔드가 analysis_metric 에서 조립할지)
확인 위 내보내기 명령 · agent/contracts/metric_definitions.yaml
하지 말 것 🔴 결측을 0으로 채우지 마세요(ho 21번) · 🔴 정규화 없이 코사인 유사도를 쓰지 마세요 — 각도 차원이 독식합니다 · 차원 수를 상수로 박지 마세요

🔴 정정 (2026.09.11) — jin 28번의 「확인해 주실 것」에 답합니다

표를 실물에 맞췄습니다. 두 가지가 겹쳐 있었습니다.

(1) 7 과 6 은 둘 다 맞습니다 — 서로 다른 양입니다. 위 표의 열 이름이 「쓰는 지표」였고 인스텝은 지표 7개가 맞습니다. jin 28번이 보신 6은 grade.football.instep_shot.* 의 행 수, 즉 채점 항목 수입니다. 갈리는 자리는 follow_through 하나로, 한 항목이 지표 둘을 씁니다 (swing_hip_flexion_after_impact_deg · follow_through_duration_frames). 인사이드 패스도 같습니다 — 항목 5 · 지표 6.

🔴 그래서 틀린 것은 숫자가 아니라 제 표가 「차원」의 뜻을 안 정한 것입니다. jin 28번 설계는 N 을 채점 항목 수로 잡으셨는데 제 표의 4~7 은 지표 수였습니다. 아래 (3)이 그 갈림에 답합니다.

(2) 표의 야구·농구 행이 없어졌습니다 (같은 구역 39번, 축구 단일 종목 전환). 지금 살아 있는 것은 둘뿐입니다:

루브릭 채점 항목 쓰는 지표 상태
축구 인스텝 슈팅 6 7 active
축구 인사이드 패스 5 6 draft

🔴 인사이드 패스의 지표는 인스텝의 진부분집합입니다. 그래서 살아 있는 루브릭 전체가 쓰는 지표도 7개이고, 시드에 남은 12개 중 팔 지표 4개는 지금 어느 루브릭도 안 씁니다(features.py 는 계속 냅니다 — 고치면 B-6 재실행이라 안 건드렸습니다).

🗂 근거 재검토 (2026.09.11) — 약해진 근거 둘과 결측 분포. 펼쳐 봅니다

🔴 제 원래 근거 셋 중 둘이 약해졌습니다 — 드러냅니다

  • ⑶ 「종목을 넘는 비교는 뜻이 없다」는 이제 해당 사항이 없습니다. 종목이 하나입니다. 이 근거는 접습니다.
  • ⑴ 「대부분이 결측이다」도 약해졌습니다. 인사이드 패스가 인스텝의 부분집합이라, 전역 7차원으로 두어도 늘 비는 칸은 1개뿐입니다. 「4~7 중 4」 같은 그림이 아닙니다.

그래도 (가) 루브릭별 공간이 맞다고 봅니다 — 다만 이유가 바뀌었습니다. 남는 근거는 ⑵(0채움) 과, 아래에서 새로 나온 것입니다. 그리고 어차피 비교가 한 루브릭 안에서 끝나므로(SFR-010) 나누는 값이 거의 공짜입니다.

🔴 새로 나온 것 — 결측이 한 루브릭 안에도 있습니다

축구 인스텝 18편의 지표별 실측 보유율을 셌습니다 (agent/eval/pending32_vector_basis/):

지표 보유
plant_knee · swing_knee · trunk_lean · swing_hip_flexion · follow_through_duration 각 18/18
hip_rotation_range_deg 15/18 — ho 21번의 처방이 보이는 것입니다(못 쟀으면 키를 안 넣습니다)
plant_foot_to_ball_offset 🔴 0/18

공 검출이 있어야 나오는 값이라 그 항목(plant_foot_position)은 이 18편에서는 매번 skipped[] 로 빠집니다(0점이 아니라 제외, 가중치는 재정규화됩니다).

🔴 정정 (같은 날 저녁) — 여기 처음에 「7번째 지표는 실측된 적이 없습니다」 라고 썼는데 틀렸습니다. 그날 저녁 실서버에서 그 항목이 채점됐습니다 (「디딤발이 공과 수평으로 어깨너비 0.216배 떨어져 나란하다」, 0등급). 공 검출은 살아 있습니다. 0/18 은 이 골든셋의 성질이지 기능의 성질이 아닙니다 — 🔴 표본의 성질을 기능의 성질로 일반화한 것이 제 오류의 형태입니다. 차원 논의에는 영향이 없습니다(거기서 필요한 것은 「이 표본에서 축을 채울 수 있는가」였습니다).

🔴 그래서 jin 28번 설계의 전제 하나가 이 자료에서는 안 섭니다 — 「같은 rubric_code 안에서는 모든 행의 꼬리가 똑같이 0 이라 코사인에 영향이 없다」. 꼬리가 클립마다 다르게 빕니다. 항상 비는 칸은 차원에서 빼면 되지만, hip_rotation 처럼 가끔 비는 칸은 그렇게 못 합니다 — 거기에 0을 넣으면 ho 21번이 고친 결함이 벡터 수준에서 되살아납니다.

(3) 그래서 축은 무엇인가 — 측정 지표값입니다 (등급이 아닙니다)

같은 15편에서 세 후보의 고유 벡터 수를 셌습니다. 같으면 두 선수가 벡터로 구분되지 않는다는 뜻입니다.

축 고유 벡터  
(A) 측정 지표값 (연속) 15/15 겹침 없음
(B) 항목별 등급 0/1/2 12/15 🔴 3편이 남과 구분되지 않습니다
(C) 항목별 stat 0~100 15/15 겹침 없음

(B) 등급은 축이 아닙니다. 15편에서 이미 20%가 겹칩니다 — 항목 5개 × 3단계면 자리가 243개뿐이고 실제 등급은 가운데로 몰립니다.

(A) 와 (C) 는 겹침으로 안 갈립니다. 갈리는 자리는 방향입니다. stat 은 「이상 구간에서 얼마나 떨어졌나」라 과하게 돈 선수와 덜 돈 선수가 같은 값이 됩니다. 「비슷한 플레이 스타일」에서 그 둘은 안 닮았습니다 — (A) 가 스타일 축이고 (C) 는 수준 축입니다. 이건 세어서 나온 결론이 아니라 정의에서 나온 것이라 재지 않았습니다.

🔴 위 계수는 사전 등록된 실험이 아니라 descriptive 입니다. 숫자를 보고 축을 고른 것이 아니라(구조가 먼저입니다) 구조가 실제 자료에서 얼마나 나타나는지를 세어 둔 것입니다. 그래서 합격/불합격 문장이 없습니다.

  • 재실행: cd agent && uv run python eval/pending32_vector_basis/count_bases.py (GPU·영상 불필요 — 이미 측정된 CSV 를 읽습니다) · 자세한 것은 같은 폴더 RESULTS.md
  • 표 정정에 맞춰 N 은 지표 수의 최댓값(지금 7)입니다. 🔴 여전히 상수로 박지 마세요 — 39번으로 루브릭이 줄었을 때 이 값도 함께 움직였습니다

  • 관련: jin 23번(지표 코드 정본) · jin 17번(축이 루브릭이다) · jin 28번(설계 회신 — 위 정정은 그 「확인해 주실 것」에 대한 답입니다) · ho 21번(0으로 지어내기) · 같은 구역 33번(적합도 3축 전체) · 39번(종목 정리)
  • 담당: 정어진(player_vector 설계·적재) · 정상호(표를 실물에 맞추기) ✅ 위 정정 (2026.09.11) · 제기: 정상호 · 기한: 스프린트 3

33. 스프린트 3의 「적합도 판단」은 지금 착수할 수 없습니다 ✅ 결정 (2026.09.11, 박민호)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

34. 지도자 섭외가 어렵다면 3장 검증 기준을 바꿔야 합니다 ✅ 결정 (2026.09.11, 박민호)

  • 위치: pending-archive.markdown의 ## ho 구역으로 이동됨

35. ✅ jin 25번(항목별 stat 코드) 회신 — 냈습니다 (2026-09-09)

정어진 님 항목이 아직 ho 에 안 와 있어 제 구역에 답합니다. 그 항목에 직접 쓰면 병합 충돌이 됩니다(그 항목은 jin 브랜치에 있습니다). 정본은 jin 구역 「metric_definition 시드에 항목별 stat 코드를 추가해 주세요」 이고, 이 글은 그 회신입니다.

ho 에 push 했습니다. 요청하신 두 가지를 냅니다.

1) 변경 요지

   
형식 stat.{sport}.{motion}.{criterion_id} · unit: score — 등급과 같은 축입니다
라벨 등급과 같은 규칙({루브릭 라벨} · {항목 이름})
--include-draft 등급과 동일하게 동작합니다
정본 agent/contracts/metric_definitions.yaml 에 stat_code_format·stat_unit·stat_range 를 선언했습니다 — 백엔드가 라벨·단위를 지어내지 않게
산출물 agent/scripts/export_metric_definitions.py --json 그대로
행 수 active 45행 · --include-draft 73행 → 🔴 낡았습니다. 아래 정정

🔴 정정 (2026.09.18) — 이 항목의 수치가 전부 낡았습니다

2026.09.09에 낸 값입니다. 2026.09.11에 축구 단일 종목으로 정리하면서 (39번) 야구·농구 루브릭을 지웠고, 그때 이 항목을 안 다시 쟀습니다.

  그때 적은 값 지금 실측 (2026.09.18)
active 행 수 45 25
--include-draft 73 35
가장 긴 code 48자 grade.basketball.jump_shot.release_arm_extension 47자 grade.football.instep_shot.swing_knee_extension
String(50) 여유 2자 3자

🔴 가장 긴 코드로 들었던 그 농구 코드는 이제 없습니다. 결론(「String(50) 안에 든다 · 컬럼 확장 불필요」)은 그대로 유효하고 오히려 여유가 늘었습니다 — 바뀐 것은 근거로 든 값입니다.

🔴 정어진 님 — 시드는 39번 쪽입니다. 20260910_seed_metric_definition.py 가 이 항목의 09-09 산출을 그대로 옮긴 것이라 grade.basketball.*· grade.baseball.* 행이 들어 있습니다. 39번의 「종목 코드가 남은 곳」 표에 그 파일이 이미 올라가 있고 담당도 정어진 님으로 적혀 있습니다 — 여기서 새로 요청하는 것이 아니라, 이 항목의 수치를 보고 시드를 맞추시면 안 된다는 표시입니다. 🔴 지금 깨지는 것은 없습니다(에이전트가 그 코드를 안 낼 뿐, 참조 테이블에 남아 있어도 적재는 안 깨집니다).

확인: cd agent && uv run python scripts/export_metric_definitions.py (맨 아래에 가장 긴 code 글자 수가 함께 나옵니다) · uv run pytest tests/test_metric_definitions.py -q → 10 통과(2026.09.18 실측)

impact_frame 은 행으로 그대로 뒀습니다 — 빼지 않았습니다.

2) 🔴 가장 긴 code — 48자입니다. 여유가 2자뿐입니다

grade.basketball.jump_shot.release_arm_extension     48자

String(50) 안에 듭니다 — 컬럼 확장은 지금 필요 없습니다. draft 까지 포함해도 48자가 최대입니다.

🔴 다만 여유가 2자라 오래 갈 값이 아닙니다. 항목 id 나 동작 이름이 조금만 길어지면 넘고, 넘은 채 적재하면 잘려서 다른 코드와 충돌합니다 — 그때는 조용히 틀린 값이 들어갑니다. 그래서 제 쪽에 검사를 걸어 두었습니다 (test_no_code_outgrows_the_backend_column). 제가 코드를 늘리면 제 테스트가 먼저 빨개집니다 — 정어진 님이 실서버에서 발견하시는 일은 없게.

🔴 하나 밝힙니다 — 등급과 stat 이 라벨을 공유합니다

주신 규칙대로 라벨을 등급과 같게 했더니 항목마다 같은 라벨이 두 행이 됩니다.

grade.football.instep_shot.follow_through   축구 · 인스텝 슈팅 · 차고 난 뒤 마무리
stat.football.instep_shot.follow_through    축구 · 인스텝 슈팅 · 차고 난 뒤 마무리

코드가 다르니 적재는 문제없지만, 라벨로 목록을 보여 주는 화면이 있으면 같은 이름이 두 번 뜹니다. 규칙을 주신 쪽이 정하실 일이라 그대로 두었고, 바꾸실 거면 제가 접미사(예: … (점수))를 붙이겠습니다 — 한 줄입니다.

   
확인 cd agent && uv run python scripts/export_metric_definitions.py — 맨 아래에 가장 긴 code 글자 수가 함께 나옵니다
확인 grep -n 'stat\.' agent/scripts/export_metric_definitions.py · uv run pytest tests/test_metric_definitions.py -q (10 통과)
하지 말 것 🔴 stat 을 등급과 같은 값으로 읽지 마세요 — 총점은 등급의 가중합이지 stat 의 평균이 아닙니다. 같은 unit: score 라 섞이기 쉽습니다
  • 관련: jin 25번(정본) · jin 23번(시드 마이그레이션) · min 9번 · paik 7번
  • 담당: 정상호(stat 코드 산출) ✅ 냈습니다 (2026.09.09) → 정어진(시드 마이그레이션) · 제기: 정어진 · 기한: 스프린트 3 초

36. 외부 검토 — 루브릭 6종의 근거 수준이 균일하지 않습니다 (2026-09-09 신설) — 4축 표를 냈습니다 (2026.09.10)

🔶 지금 상태 (2026.09.17): 4축 표가 「근거 수준이 균일하지 않다」를 드러내는 것이 지금의 답입니다 — 균일하게 만드는 길이 검수였고 안 받기로 했습니다(2번). 재개 조건: 지도자 섭외.

🗂 4축 표를 낸 회차 (2026.09.10) — 판정 분포·검사·대조 상세. 펼쳐 봅니다

✅ 2차 외부 검토가 들어왔고, 「다음」으로 적어 둔 4축 표를 냈습니다 (2026.09.10)

정본은 agent/contracts/rubric_evidence.yaml, 검사는 agent/tests/test_rubric_evidence.py 입니다. 커밋 27ff0da.

🔴 먼저 대조했습니다 — 검토가 없는 판을 읽었습니다

2차 검토가 인용한 항목 이름이 저장소와 다릅니다.

검토가 적은 것 저장소
리드 팔 뻗기 · 골반 먼저 열기 · 골반 회전 · 상체 균형 · 스윙 마무리 앞쪽 팔 뻗기 · 골반이 어깨보다 먼저 열리기 · 골반 돌리기 · 상체 자세 유지 · 휘두른 뒤 마무리
점프슛 「슛 후 팔로스루」(“이전에 수정한 것처럼”) 던진 뒤 손목 마무리
투구 trunk_tilt 칭호 「적절하게 넘어가는 상체」 등 「넘어가는 상체 / 곧추선 상체 / 젖혀진 상체」
인사이드 패스 follow_through 「짧게 이어지는 마무리」 등 「방향을 남긴 마무리 / 흘린 마무리 / 끊긴 마무리」

이 이름들은 커밋된 적이 없는 작업 트리 판이고 2026.09.10 에 되돌렸습니다. 그런데 검토의 논증은 하나도 이름에 기대지 않습니다 — 기준 id·구간 숫자(140~165 / 165~175)·impact_event(5종 extension_peak, 레이업만 distal_apex)는 전부 정확했습니다. 🔴 그래서 이름 불일치를 이유로 검토를 물리지 않습니다. 다만 인용된 문구를 그대로 문서에 옮기면 안 됩니다 — 없는 판의 문자열입니다.

🔴 「impact_event 를 먼저 하라」는 권고 — 맞지만 지금 못 합니다

검토의 우선순위 논증(이벤트가 틀리면 임계값이 정확해도 무의미하다)은 원리적으로 맞고 저장소도 같은 판단입니다. 그런데 그 일은 미결 5번 이고 보류입니다.

왜 못 하나  
정답 재개 조건이 팔 종목 릴리스 프레임 정답 25~60클립인데 외부 경로가 전부 닫혔습니다(PitcherMotion·Statcast·UCF101·공개 데이터셋). 남은 길은 자체 촬영뿐입니다
비용 impact_event 를 바꾸면 features 가 바뀌어 B-6 전 구간 재실행을 부릅니다
🔴 말할 수 없는 것 정답이 없으면 새 이벤트가 낫다고 말할 수 없습니다 — 「target 30 전환」과 같은 형태(동작점 이동을 정확도로 오독)가 됩니다

그래서 4축 표를 먼저 했습니다. 정답이 필요 없고, 무엇보다 검토가 말한 ①②분리를 실제로 작동시키는 장치입니다.

🔴 4축을 평면으로 적지 않았습니다 — 축마다 사는 층이 다릅니다
축 어디에 산다 왜
측정 타당성 지표(11개) hip_rotation_range_deg 문제는 그것을 쓰는 세 루브릭에 다 있습니다. 루브릭마다 복사하면 한쪽만 고쳐집니다(미결 10번의 형태)
이벤트 타당성 루브릭(6개) kinematics.impact_event 가 정합니다
생체역학 근거 세부기준(30개)  
임계값 타당성 세부기준(30개)  
판정 분포 — 지금 어디까지 와 있는가
축 분포
생체역학 근거 (30항목) cited_unverified 13 · unverified 17
측정 타당성 (11지표) measured_internal 1 · 🔴 refuted_internal 4 · unverified 6
이벤트 타당성 (6루브릭) measured_internal 1(레이업) · 🔴 refuted_internal 3 · unverified 2
임계값 타당성 (30항목) 🔴 unverified 30 — 예외가 하나도 없습니다

🔴 닫힌 어휘에 verified 가 없습니다. 그렇게 말할 수 있는 축이 아직 하나도 없어서입니다 — 어휘에 없으면 잘못 적을 수도 없고, 넣으려 하면 검사가 막습니다.

표를 채우다 새로 드러난 것 둘
  1. 🔴 trunk_forward_lean_deg_at_impact 를 두 방식으로 읽고 있습니다. 야구 타격만 「기울기의 크기만」 쓰고(부호가 카메라 방향에 의존해서), 투구·점프슛·축구는 부호를 그대로 씁니다. 어느 쪽이 옳은지 안 쟀습니다. 2차 검토가 이 지표(투구 trunk_tilt)를 콕 집어 「측방 기울기·어깨 외전과 함께 보지 않으면 한 각도로 좋다/나쁘다를 못 정한다」고 한 것과 같은 자리입니다
  2. 🔴 guide_hand 는 두 축이 한 자리에서 겹칩니다 — 검토가 「포즈만으로 판정하려면 별도 검증 필요」라 한 항목인데, 그것이 쓰는 support_elbow_angle_at_impact 가 측정 타당성 전 지표 중 최악(하한 2.2도)입니다
검사가 막는 것 (셋 다 실제로 무는지 확인했습니다)
검사 막는 것
test_every_criterion_has_both_criterion_axes 새 세부기준이 아무 데도 안 적힌 채 들어오는 것 — 그러면 「다 검증됐다」를 막을 근거가 그 항목에만 없어집니다
test_no_unverified_citation_reaches_the_document 🔴 미검증 인용이 정본에 박히는 것. citation 은 citation_verified: true 와 함께여야 통과하고, note 안에 URL 을 숨기는 것도 막습니다
test_thresholds_are_not_claimed_as_settled 임계값 타당성이 조용히 올라가는 것
test_the_vocabulary_has_no_verified_level 누군가 verified 등급을 만드는 것

🔴 인용을 하나도 넣지 않았습니다. 2차 검토가 PubMed 링크를 여럿 달아 왔지만 원문을 열어 확인하지 않았습니다. 확인한 사람이 citation 과 citation_verified 를 함께 넣으면 그때 들어갑니다.

   
판단해 주실 것 없습니다 — 제 구역입니다. 알리려고 올립니다
다음 34번의 「출처 붙이기」. 🔴 문헌을 실제로 열어 확인하는 사람이 필요합니다 — 그 전까지 cited_unverified 13건은 그대로입니다
확인 cd agent && uv run pytest tests/test_rubric_evidence.py -q (7 통과) · grep -c 'threshold_validity' contracts/rubric_evidence.yaml
하지 말 것 🔴 이 표를 근거로 임계값을 옮기지 마세요 — 검토는 AI 단독 판독이고 「분포에 맞춰 긋는 길」은 34번에서 기각됐습니다 · 🔴 cited_unverified 를 「근거 있음」으로 읽지 마세요

루브릭 6종에 대한 외부 검토를 받았습니다. 요지는 두 가지를 갈라 보라는 것입니다:

① 동작 선택의 생체역학적 방향성은 상당 부분 타당하지만, ② 현재 YAML 의 점수 구간·임계값·임팩트 이벤트까지 검증된 상태는 아니다.

🔴 먼저 성격 규정 — 이것은 AI 단독 판독입니다

프로젝트 규칙상 정답으로 승격하지 않습니다. 부정적 결론(「이건 검증됐다고 말하면 안 된다」)의 근거로는 씁니다 — 그리고 이 검토는 정확히 그 방향이라 쓸 수 있는 쪽입니다. 반대로 「이 각도가 옳다」 쪽으로는 쓰지 않습니다.

✅ 저장소와 대조했습니다 — 검토가 실물을 정확히 읽었습니다

기록 전에 제가 확인한 것입니다(그래야 틀린 것을 정본에 박지 않습니다).

대조한 것 결과
6종의 기준 id 전수 ✅ 일치 — 검토가 나열한 항목이 실제 YAML 과 같습니다
인스텝 swing_knee_extension 밴드 ✅ 일치 — 140~165 / 165~175 / <140·>175 가 실제 값입니다
임팩트 이벤트 ✅ 일치 — 5종이 extension_peak, 레이업만 distal_apex

🔴 그런데 ①②의 구분은 이미 저장소의 입장입니다 — 새 지적이 아닙니다

baseball_batting.yaml 의 validated_on 이 이미 이렇게 적고 있습니다:

🔴 두 실행 어디에도 자세 등급 정답이 없다 — 점수의 타당성은 검증되지 않았다. 확인된 것은 “파이프라인이 이 동작에서 끝까지 돈다”까지다.

공개 제안서도 과장하지 않고 있습니다(제가 전수 확인했습니다) — 5장 CON-005·ASM-004 와 부록 E 가 일관되게 「지도자 검수 전 임시값」·「검증할 방법이 없다」로 적혀 있습니다. 고칠 곳이 없었습니다. 🔴 다음 사람이 같은 감사를 반복하지 않도록 적어 둡니다.

임팩트 이벤트 문제도 이미 YAML 헤더에 있습니다 — 타격·투구·점프슛에 「extension_peak 이 릴리스가 아니라 신전 중간을 잡는다」가 적혀 있고, 레이업은 그래서 이미 distal_apex 로 바꿨습니다(실클립에서 6프레임 대 21프레임을 비교해 확인).

그래서 새로 얻은 것은 이 셋입니다

  새로 얻은 것
1. 근거 수준이 종목마다 다르다 인스텝 킥(proximal-to-distal 전달·디딤발)이 가장 강하고 인사이드 패스가 가장 약합니다 — 킥은 문헌이 두껍지만 패스는 상세 연구가 얇습니다. 지금 우리는 6종을 같은 무게로 말하고 있습니다
2. 4축으로 갈라야 한다 「동작 선택 / 측정 타당성 / 이벤트 타당성 / 임계값 타당성」. 지금은 이 넷을 뭉뚱그려 「검증」이라 부르고 있어 어디까지 됐는지가 안 보입니다
3. 특정 각도를 보편적 좋음/나쁨으로 쓰지 말 것 투구 trunk_tilt 가 지목됐습니다 — 전방 기울기만 보고 측방(contralateral) 기울기·어깨 외전과 함께 보지 않으면 한 각도로 좋다/나쁘다를 못 정합니다

🔴 하지 말 것 — 인용을 그대로 붙이지 마세요

검토에 PubMed 링크가 여럿 달려 왔습니다. 저는 그 링크를 열어 확인하지 않았습니다. 미검증 인용을 부록 C 에 넣으면 「출처 없음」보다 나쁩니다 — 없는 근거를 있다고 말하는 것이 됩니다. 34번이 정한 「검증을 포기하고 출처로 낮춘다」의 값어치가 거기서 무너집니다.

각 링크는 (가) 실재하는지 (나) 우리가 붙이려는 문장을 실제로 뒷받침하는지 확인한 뒤에만 인용합니다. 확인 못 한 것은 인용하지 않습니다.

⚠️ 이름이 오독을 부릅니다 — validated_on

이 필드가 담고 있는 것은 「이 동작에서 파이프라인이 끝까지 돌았다」이지 타당성 검증이 아닙니다. 이름만 보면 검증된 것으로 읽힙니다. 바꾸자는 제안이 아니라(바꾸면 6개 파일과 읽는 코드가 함께 움직입니다) 읽는 사람이 오해하지 않게 여기 적어 둡니다.

✅ 곁가지 하나 고쳤습니다 — 타격 헤더가 자기 파일과 어긋나 있었습니다

대조하다 찾았습니다. baseball_batting.yaml 머리말이 「실클립 미검증 — validated_on 이 비어 있다」라고 적고 있는데, 그 필드는 2026-09-04 에 두 실행으로 채워져 있습니다. 그대로 두면 다음 사람이 이미 한 실행을 다시 돌립니다. 주석 한 줄만 고쳤습니다 — features·밴드·점수 경로는 건드리지 않았으므로 B-6 재실행과 무관합니다.

   
판단해 주실 것 없습니다 — 제 구역 작업입니다. 알리려고 올립니다
다음 6종 × 세부기준을 4축 표로 채웁니다. 34번의 「출처 붙이기」와 같은 자리에서 하는 것이 쌉니다
확인 grep -n 'id:' agent/rubrics/*.yaml · grep -n 'validated_on' agent/rubrics/*.yaml
하지 말 것 🔴 이 검토를 근거로 임계값을 옮기지 마세요 — AI 판독이고, 「분포에 맞춰 긋는 길」은 34번에서 이미 기각됐습니다 · 🔴 미검증 인용 금지(위)
  • 관련: 같은 구역 34번(출처로 낮추기 — 정본) · 37번(4축 표가 찾아낸 것) · 2번(지도자 검수) · 7번(fps 불변성) · 22번(지표 타당성) · agent/rubrics/ · 부록 C
  • 담당: 정상호 · 제기: 정상호(외부 검토) · 기한: 34번과 함께 (스프린트 3) → 🔴 34번은 끝났고(결정 3, 2026.09.14) 검수는 안 받기로 했습니다 (2026.09.17, 2번). 4축 표가 낸 「근거 수준이 균일하지 않다」는 그대로 남습니다 — 균일하게 만드는 길이 검수였기 때문입니다. 표가 그 사실을 드러내고 있는 것이 지금의 답입니다

37. 상체 기울기가 촬영 방향을 함께 채점한다 — 이미 나고 있습니다 (2026-09-10 신설)

🔶 지금 상태 (2026.09.17): (다) 드러내기만 나가 있습니다(view_dependent). (가)는 임계값 이동이라 검수가 필요하고 검수 없이 가기로 했으며, (나)는 표본이 여전히 0편입니다. 🔴 닫지 않습니다 — 드러냈을 뿐 고친 것이 아닙니다. 재개 조건: 지도자 섭외 또는 다른 각도 표본 확보.

36번의 4축 표를 채우다 나왔습니다. trunk_forward_lean_deg_at_impact 하나를 여섯 루브릭이 두 방식으로 읽고 있었습니다 — 야구 타격만 「기울기의 크기」로 보고 나머지 다섯은 부호를 그대로 씁니다.

🔴 기전 — 코드가 바로 답합니다

trunk = shoulder_c - hip_c                            # 골반중심 → 어깨중심
trunk_lean = degrees(arctan2(trunk[0], -trunk[1]))    # trunk[0] = 이미지 x

normalize() 는 평행이동과 스케일만 합니다 — 방향을 정규화하지 않습니다. 그래서 좌우가 반전되면 각도가 정확히 -θ 입니다.

이 지표는 「앞으로 기울었나」가 아니라 「이미지 오른쪽으로 기울었나」를 잽니다. 피사체가 어느 쪽을 보고 서 있는지는 보지 않습니다.

크기를 쟀습니다 — 실측 위 반사실 (사전 등록 adff28f → 결과 c7a5293)

근거: agent/eval/pending37_trunk_mirror/.

루브릭 n(클립) 항목 등급 변동 최종 등급 변동 총점 평균/최악
⚽ 인스텝 active 18 🔴 18/18 (100%) 🔴 8/18 (44%) 12.7 / 21
🏀 레이업 (draft) 1 1/1 1/1 24 / 24
🏀 점프슛 active 1 0/1 0/1 0 / 0
⚾ 타격 (대칭 밴드) 46 ✅ 0/46 ✅ 0/46 0 / 0

🔴 총점 평균 12.7점은 제안서 3장 허용치(3점)의 4.2배, 최악 21점은 7배입니다.

🔴 가정이 아닙니다 — 이미 나고 있습니다

반전 없이 본 원 실측의 부호 분포입니다.

루브릭 음수 양수 |θ| < 5도
⚽ 인스텝 8 10 9/18
⚾ 타격 20 24 16/46

양쪽 부호가 다 나옵니다. 그리고 인스텝의 0등급 구간이 ≤ 0 이라, 음수인 8클립은 지금 「상체가 뒤로 젖혀졌다」로 0등급을 받고 있습니다. 그 음수가 자세인지 카메라 반대쪽을 향해 찼기 때문인지 지표가 구분하지 못합니다. 게다가 18클립 중 9클립이 |θ| < 5도 로 경계에 붙어 있어 측정 잡음만으로도 갈립니다.

✅ 자기 검사가 처방의 방향까지 보여 줬습니다

야구 타격 0/46 은 두 가지를 동시에 보입니다 — (1) 반사실 구현이 옳다 (2) 🔴 대칭 밴드가 실제로 막는다. 타격의 부호 분산이 인스텝보다 더 큰데도 등급 변동이 0입니다. 같은 지표, 밴드 하나 차이로 0% 대 44%.

🔴 가장 위험한 둘을 못 쟀습니다

야구 투구는 밴드가 6종 중 가장 비대칭(2등급 [15,40] · 0등급 ≤0)인데 Track 2 의 유일한 클립이 InsufficientQuality(상반신 키포인트)라 0클립 입니다. 축구 인사이드 패스도 표본이 0입니다. 🔴 「안 난다」가 아니라 「못 쟀다」로 읽어야 합니다.

처방은 둘이고 값이 다릅니다 — 아직 안 골랐습니다

  바꾸는 것 대가
(가) 밴드를 0 대칭으로 rubrics/*.yaml 다섯 임계값 이동이라 지도자 검수(2번)에 걸립니다. 🔴 다만 「분포에 맞춰 긋기」가 아니라 좌표계 결함 교정이라 34번의 기각 사유에 해당하는지가 판단 대상입니다
(나) 지표를 방향 인식으로 features.py 🔴 features 가 바뀌어 B-6 전 구간 재실행. 대신 밴드를 안 건드립니다

🔴 (나)가 원인에 가깝습니다. (가)는 「앞뒤 구분을 포기하는」 것이라 투구처럼 전후 기울기가 실제로 뜻을 갖는 동작에서 정보를 버립니다. 타격이 (가)를 고른 것은 두 손 스윙이라 전후 비대칭이 약해서였습니다.

🗂 2~4회차 기록 (2026.09.10) — 곁가지와 전제가 안 선 경위. 펼쳐 봅니다

곁가지 — selector 를 바꾸면 같은 클립에서 부호가 뒤집힙니다

10_penalty1.avi 이 comparison_selector 에 따라 trunk_lean -29.3 과 20.3 으로 갈립니다(다른 사람을 골라서). 이 회차 표본에는 안 들어갔지만 (baseline 행만 썼습니다) 실물이라 적어 둡니다.

2회차 — 🔴 「표본 메우기가 먼저」를 정정합니다. 지금 데이터로는 못 채웁니다 (2026.09.10)

재고를 세어 봤습니다. 촬영·수집 없이는 안 됩니다.

필요한 것 있는 것
야구 투구 클립 data/baseball_pitch_trim.mp4 하나뿐이고 그게 이미 InsufficientQuality 로 탈락한 그 클립입니다
축구 인사이드 패스 클립 0개. 축구 골든셋 19편은 전부 *_penalty*·*_freekick* — 인스텝입니다

그래서 질문을 둘로 갈랐습니다. 구조(밴드가 어떻게 생겼나)는 표본 없이 전수로 답할 수 있고, 분포(실측 θ 가 어디 떨어지나)는 그 둘에서 못 답합니다. 🔴 분포를 답한 척하지 않았습니다. 근거: agent/eval/pending37_band_map/ · 사전 등록 6a99669 → 결과 0fde28b.

밴드 반전 지도 — 정의역 [-90, 90] 전수 (PLAUSIBLE_RANGE 그대로)
루브릭 w 뒤집힘 Δ=2 0등급 하한 표본
⚾ 투구 active 0.15 🔴 61% 28% -0.1 🔴 0
⚽ 인사이드 패스 0.20 26% 15% -0.1 🔴 0
⚽ 인스텝 active 0.15 33% 17% -0.1 18클립
🏀 레이업 0.25 33% 6% -15.1 1클립
🏀 점프슛 active 0.15 10% 0% -20.1 1클립
⚾ 타격 0.15 ✅ 0% 0% -42.1 46클립

🔴 투구의 Δ=2 구간 [15, 40] 은 그 항목의 2등급 구간 그 자체입니다.

지금 trunk_tilt 에서 2등급을 받는 모든 투구는, 반대편에서 찍혔으면 0등급을 받습니다. 예외가 없습니다.

🔴 가장 중요한 것 — 정의역 비율은 발생률이 아닙니다
  정의역 뒤집힘 실측이 그 구간에 있는 비율
⚽ 인스텝 33% 🔴 18/18 = 100%
⚾ 타격 0% 0/46

θ 가 균등분포가 아니라 0 근처에 몰리는데 뒤집히는 구간이 정확히 거기라서 입니다. 🔴 그래서 투구 61%·인사이드 26% 는 발생률의 과소평가일 가능성이 큽니다 — 다만 이건 추론이지 측정이 아닙니다.

🔴 레이업이 숨어 있었습니다

뒤집힘은 인스텝과 같은 33%인데 가중치가 0.25로 가장 커서 점수 영향이 최대(Δ=2 에 25~29점)입니다. 1회차는 1클립뿐이라 못 봤습니다. 3장 허용치가 3점이니 전 루브릭이 그 5~10배입니다.

🔴 예측이 하나 틀렸습니다

「0등급 하한이 같으니 인사이드도 투구와 비슷할 것」이라 적었는데 26% 대 61%로 절반 이하입니다. 뒤집힘 비율을 정하는 것은 하한이 아니라 2등급 구간의 폭과 위치였습니다(투구 25도 대 인사이드 13도).

| | | |—|—|

3회차 — 🔴 (나)의 전제가 안 섭니다. 방향을 못 짚습니다 (2026.09.10)

(나) 방향 인식 지표를 고르려면 어느 쪽을 보는지 알아낼 수 있어야 합니다. 정답 없이 잴 수 있는 질문이라 먼저 쟀습니다. 근거: agent/eval/pending37_facing/ · 사전 등록 b1fba98 → 결과 e5d0930.

🔴 코 오프셋은 후보에서 미리 뺐습니다 — 부호를 정하려는 값이 shoulder_c − hip_c 인데 앞으로 기울면 코도 같은 방향으로 갑니다. 그 큐를 쓰면 trunk_lean 이 항상 양수가 되어 지표가 통째로 죽습니다. 구조적 순환이라 결과를 보고 판단할 것이 아니라 사전에 배제했습니다.

  기준 결과
A 커버리지 70%+ ✅ 97%(귀) · 74%(눈·이동)
B 독립 큐와 일치 70%+ 🔴 46% — 우연이 50%입니다
C 시간 안정 80%+ 🔴 66% — 클립 안에서 부호가 뒤집힙니다
D 구조적 비순환 ✅ 두 큐 다 trunk 벡터를 안 씁니다

커버리지는 넉넉한데 서로 안 맞습니다. 사람은 한 스윙 안에서 돌아서지 않는데 귀 큐는 3분의 1의 클립에서 부호가 뒤집힙니다 — 방향이 아니라 잡음을 따라간다는 증거입니다.

🔴 분석하다 찾은 것 — 이동 큐도 기준이 못 됩니다

46%를 「귀가 틀렸다」로 읽으면 안 됩니다. 이동 큐는 카메라 팬과 피사체 이동을 구분하지 못하는데 표본이 Kinetics 방송 영상입니다. 정확한 결론은 「둘 다 방향을 짚는다고 말할 근거가 없다」입니다.

🔴 정정 — 판정 문장이 기준 A 만 보고 있었습니다

첫 실행이 A 만 보고 「전제가 선다」를 찍었습니다. 사전 등록은 A·B·C·D 를 다 걸었습니다. 기준이 아니라 출력의 구현 오류라 고쳤습니다 — 안 잡았으면 정반대 결론으로 기록될 뻔했습니다.

🔴 커버리지를 성과로 읽는 실수가 같은 날 두 번째입니다
  커버리지 내용
미결 18번 7회차 잃은 프레임의 93% 를 채웠다 맞은 것 1%
여기 97% 에서 값이 나온다 독립 큐와 일치 46%

「값이 나온다」와 「맞다」는 다릅니다. 두 번 다 짝 기준이 막았습니다 — 커버리지 단독 기준을 세우지 않습니다.

선택지가 셋으로 늘었습니다
  처방 지금
(가) 밴드 0 대칭화 임계값 이동(2번·34번) · 앞뒤 구분 포기. 투구는 손실이 큽니다
(나) 방향 인식 지표 🔴 전제가 안 섰습니다. 되살리려면 투구·킥 표본이 필요하고, 그건 데이터가 생긴 뒤입니다
(다) 🆕 드러내기만 점수를 그대로 두고 「이 값은 촬영 방향에 의존한다」를 결과 봉투에 싣습니다. 🔴 B-6 재실행 없음 · 임계값 이동 없음 — 20번 out_of_band · E-3 timebase 와 같은 형태입니다

(다)가 지금 유일하게 대가 없이 되는 길입니다. 고치는 것이 아니라 안 고친 것을 감추지 않는 것입니다.

   
판단해 주실 것 없습니다 — 제 구역입니다. 알리려고 올립니다. 다만 (가)를 고르면 임계값 이동이라 2번·34번과 함께 봐야 합니다
다음 (다) 드러내기 구현이 대가 없이 됩니다. (나)는 투구·킥 표본이 생긴 뒤 다시 봅니다 — 🔴 「이 둘로는 안 됐다」이지 「방향은 못 짚는다」가 아닙니다
확인 처방 (다): cd agent && uv run pytest tests/ -q -k "mirror_declaration or view_dependent or symmetric_band" (7건 통과) · 회차 재현: uv run python eval/pending37_trunk_mirror/measure_mirror.py · eval/pending37_band_map/measure_band_map.py · eval/pending37_facing/measure_facing.py · eval/dataset_3dsp/probe_3dsp.py(4회차, /mnt/d 데이터 필요) — 전부 GPU 불필요
하지 말 것 🔴 밴드를 먼저 고치지 마세요 — 크기를 두 루브릭에서 모르는 채로 옮기는 것이 됩니다 · 🔴 「그래서 지금 점수가 틀렸다」로 읽지 마세요 — 정답이 없어 어느 부호가 옳은지 모릅니다. 말할 수 있는 것은 「같은 자세가 촬영 방향에 따라 다른 등급을 받는다」까지입니다

4회차 — 축구 슛 200클립이 생겼습니다. 분포는 채워졌고 (나)는 여전히 안 섭니다 (2026.09.10)

사용자가 3DSP(AutoSoccerPose, CVPRW 2024)를 받아 왔습니다. 축구 슛 200클립 × 20프레임, 2D는 사람이 붙였고 3D는 리프팅한 것입니다. 성격 파악 기록: agent/eval/dataset_3dsp/.

🔴 이번 것은 판정 회차가 아닙니다. 사전 등록이 없고 production 을 import 하지 않고 식만 옮겨 왔습니다(features.py:544). 처방을 고르는 근거로 쓰려면 사전 등록을 세우고 scripts/analyze_keypoints.py 경로로 다시 재야 합니다. 여기 적는 것은 데이터가 무엇을 열어 주는가까지입니다.

2회차가 「못 쟀다」고 적은 분포가 채워졌습니다 — 인스텝 18 → 200클립
  1회차 (18클립) 4회차 (200클립)
음수 / 양수 8 / 10 100 / 100
\|θ\| < 5도 9/18 (50%) 73/200 (36%)
중앙값 — -0.1도
좌우 반전 시 등급 변동 18/18 (100%) 192/200 (96%)

임팩트 프레임을 9~16 어디로 옮겨도 78~98% 입니다 — 임팩트 정의에 기대지 않는 결론입니다.

🔴 부호가 정확히 반반이고 중앙값이 0.1도입니다. 이 지표가 자세를 재고 있었다면 전방 기울기 쪽으로 쏠려야 하는데 0을 중심으로 대칭입니다. 2회차가 「정의역 33%는 발생률의 과소평가일 것」이라고 추론으로 적어 둔 것이, 이제 200클립 실측으로 같은 방향을 가리킵니다.

(나)의 전제 — 3D 깊이가 생겼는데도 안 섭니다

3회차의 사전 등록 기준 A·B·C·D 를 그대로 대 봤습니다.

  기준 귀·이동 큐 (3회차) 3D 깊이 큐 (여기)
A 커버리지 70%+ ✅ 97% ✅ 100%
B 독립 큐와 일치 70%+ 🔴 46% ✅ 83%
C 시간 안정 80%+ 🔴 66% 🔴 66% / 70%
D 구조적 비순환·독립 ✅ 🔴 안 섭니다

🔴 B의 83%를 성과로 읽으면 안 됩니다. 어깨 큐와 골반 큐가 같은 리프팅 모델 하나에서 나옵니다 — 서로를 검증하지 못합니다. 3회차의 귀·이동은 적어도 출처가 달랐습니다. 그리고 C는 그대로입니다 — 사람은 한 슛 안에서 돌아서지 않는데 3분의 1의 클립에서 부호가 뒤집힙니다.

「3D로 올리면 방향이 잡힐 것」이 틀렸습니다. 커버리지와 일치도만 보고 (나)를 되살렸으면 3회차가 정정한 그 실수를 세 번째로 반복할 뻔했습니다.

🔴 못 쟀던 둘은 여전히 0입니다

3DSP는 전부 슛입니다(on target 110 · off target 90). 야구 투구 0 · 축구 인사이드 패스 0 — 2회차의 재고표가 그대로입니다. 🔴 가장 위험한 투구(뒤집힘 61%)는 아직 한 클립도 없습니다.

곁가지 — 이 데이터로 무릎각은 못 봅니다

선수 높이 중앙값 63.5px(5퍼센타일 45.6px)에 무릎각 지터가 13~15도 입니다. 인스텝 무릎 2등급 구간이 20도 폭(150~170)이라 지터가 구간에 견줍니다. 골든셋에서 vru_basketball(70px)을 뺀 것과 같은 영역입니다. 몸통 지터는 4.0도라 trunk_lean 만 다룰 만합니다.

밟을 뻔한 함정 셋 (상세는 eval/dataset_3dsp/README.md)
   
깊이 축이 z가 아니라 y 이름만 믿으면 어깨 높이차를 방향 큐로 착각합니다. 2D ≈ A·3D 를 맞춰 영공간으로 찾았습니다(투영 잔차 6.8%)
뼈 길이 CV로 등방성을 판정하면 오독 CV 0.11~0.16이 나오는데 이건 정규화가 아니라 리프팅 오차입니다. CV만 보면 「축별 정규화 → 각도 재설계」라는 정반대 결론이 나옵니다. 실제로는 z만 [0,1]이고 x·y는 0.58·0.385 — 전역 스칼라 하나라 각도가 보존됩니다
라이선스 저장소는 Apache-2.0이지만 이미지는 SoccerNet 방송 화면입니다(연구용 한정). 15번·19번과 같은 축이라 내부 검증까지만 쓰고, 🔴 크롭·어노테이션을 이 공개 저장소에 커밋하지 않습니다 — 어노테이션 JSON에 만료된 AWS presigned URL이 들어 있습니다
그래서 처방은 — (다)가 그대로 유일합니다

선택지 셋 중 바뀐 것이 없습니다. (나)는 표본이 늘어도 전제가 안 서서 막혀 있고, 늘어난 표본은 오히려 (다)로 드러낼 근거를 굳혔습니다 — 200클립에서 96%가 촬영 방향에 따라 등급이 갈립니다.

✅ (다) 드러내기를 넣었습니다 (2026.09.10) — 점수는 한 비트도 안 바뀝니다

breakdown[] 에 view_dependent 한 필드가 늘었습니다.

값 뜻
"" 이 항목의 판정 지표는 좌우 반전에 안 변합니다
"metric" 지표는 방향에 의존하지만 이 값에서는 등급이 같습니다
🔴 "grade" 반대편에서 찍혔으면 등급이 달랐습니다

🔴 features 를 바꾸지 않았습니다. out_of_band(20번)·stat(jin 25번)과 같은 표시 전용 필드라 features 를 안 주면 빈 문자열일 뿐이고 나머지는 한 비트도 같습니다. B-6 재실행도, 임계값 이동도 없습니다.

🔴 선언이 실제와 어긋나지 못하게 묶었습니다

「어느 지표가 방향에 의존하는가」를 features.MIRROR_ANTISYMMETRIC_METRICS 로 선언하는데, 선언만 두면 실제와 갈라집니다. 그래서 검사를 하나 세웠습니다 (test_features.py::test_the_mirror_declaration_matches_what_the_code_actually_does) — 좌우를 뒤집은 입력에서 실제로 부호가 뒤집히는 지표의 집합이 선언과 같아야 합니다. 두 방향을 다 막습니다:

  • 빠뜨리기 — 새 지표가 방향에 의존하는데 선언이 없으면 view_dependent 가 조용히 "" 를 내고 결함이 다시 안 보이게 됩니다
  • 낡기 — 나중에 (나)로 trunk_lean 을 고치면 이 지표는 더 이상 안 뒤집힙니다. 선언을 안 지우면 고쳐진 결함을 계속 경고합니다
넣지 않은 것

🔴 골반 회전(22번)은 안 넣었습니다. 축 기준(mod 180)이라 반전에 부호가 안 바뀝니다 — 다른 결함입니다. 한 이름에 둘을 담으면 어느 쪽인지 못 읽어서 MIRROR_ANTISYMMETRIC_METRICS 는 「부호가 뒤집힌다」만 담습니다.

🔴 이 필드를 「점수가 틀렸다」로 읽지 마세요

정답이 없어 어느 부호가 옳은지 모릅니다. 말할 수 있는 것은 「같은 자세가 촬영 방향에 따라 다른 등급을 받는다」까지이고, 이 필드는 그것을 숨기지 않게 할 뿐입니다. 계약 문서(agent/report-contract.md)에도 같은 경고를 달았습니다.

  • 관련: 36번(여기서 나왔습니다) · 34번(임계값을 출처로) · 2번(지도자 검수) · 7번(fps 불변성 — 같은 계열의 불변성 결함) · 22번(골반 회전도 카메라 축을 함께 채점합니다)
  • 담당: 정상호 · 제기: 정상호 · 기한: 표본 메우기 → 처방 선택 → ✅ (다) 드러내기는 넣었습니다 (2026.09.10). 남은 것은 (가)/(나) 중 무엇으로 고칠지이고 둘 다 지금은 막혀 있습니다 — (가)는 임계값 이동이라 지도자 검수(2번·34번) 뒤, (나)는 전제가 안 서서 투구·킥 표본이 생긴 뒤입니다. 🔴 항목을 닫지 않습니다 — 드러냈을 뿐 고친 것이 아닙니다 → 🔴 둘 다 이번 기간에는 안 합니다 (2026.09.17). (가)는 검수 없이 가기로 해서(2번 「검수 없이 갑니다」), (나)는 표본이 여전히 0편이라 그대로입니다. 남는 것은 (다) 드러내기 하나이고 — 축구 18편에서 항목 등급 18/18, 최종 등급 8/18 이 촬영 방향으로 흔들린다는 사실이 view_dependent 로 나가 있습니다. 🔴 여전히 닫지 않습니다
  • 후속: 계약에 필드가 하나 늘어 jin 적재가 받아야 합니다 → 아래 38번

38. breakdown[] 에 필드가 하나 늘었습니다 — view_dependent (2026-09-10 신설) ✅ 해소 (2026.09.16 확인 — 실제로는 2026.09.10에 이미 됨)

  • 위치: pending-archive.markdown의 ## ho (정상호) 구역으로 이동됨

39. 에이전트를 축구 단일 종목으로 정리했습니다 — 종목 코드가 남은 곳을 봐 주세요 (2026-09-11 신설)

팀 방향이 축구로 모여서, 에이전트 쪽 야구·농구를 지웠습니다. 미결 3번의 「축구·야구·농구 3종」 결정을 덮습니다 (그 항목에도 한 줄 달아 두었습니다).

에이전트에서 지운 것 — 루브릭 4개(baseball_batting·baseball_pitching· basketball_jump_shot·basketball_layup), 근거 정본 (contracts/rubric_evidence.yaml)의 해당 루브릭·세부기준·팔 전용 지표 4개, 종목 이름표(judge.SPORT_NAMES), 수집 어댑터(Picklebot-130K·PL-NBA), dataset_pipeline의 SPORTS. 남은 루브릭은 football_instep_shot(active)과 football_inside_pass(draft) 둘입니다.

안 지운 것과 그 이유 — 아래는 종목 자산이 아니라 측정 근거라서 지우면 지금 축구 판정이 서 있는 바닥까지 빠집니다.

남긴 것 이유
eval/ 전체 (35MB) 사전등록 실험(7·20·34·37번)이 그 클립 측정치 위에 서 있습니다. 아래 「정정」 참고
features.py 의 팔 지표 산출 고치면 B-6 재실행을 부릅니다(판정 입력이 달라집니다). 쓰는 루브릭이 없으면 그냥 안 불립니다
contracts/metric_definitions.yaml 의 야구·농구 코드 백엔드 시드 정본이고 이미 적재된 행이 있습니다. 지우는 것은 정어진 님 판단입니다(아래)
소스 주석의 「야구 투구 실클립에서 …」 임계값·알고리즘이 지금 모양인 이유입니다. 지우면 다음 사람이 같은 벽에 다시 부딪힙니다

🔴 정정 (2026.09.11) — 「관절 검증의 근거」라고 쓴 것을 바로잡습니다

처음에 eval/jhmdb_batting/ 을 test_keypoint_source 의 근거라고 적었는데 틀렸습니다. 그 검사는 scripts/analyze_keypoints.py 의 매핑과 features.py 만 읽고 eval/ 을 보지 않습니다 — 관절 정답 원본도 저장소가 아니라 /mnt/d/..._jhmdb/ 에 있습니다. eval/jhmdb_batting/ 은 그 관절 정답으로 파이프라인을 돌려 본 기록(54건 중 46건 산출, 밴드 분포)입니다. 값은 있지만 제가 말한 종류의 값은 아니었습니다.

eval 을 「축구 + 관절 검증」으로 가를 수 있는지 — 갈리지 않습니다

물어봐 주셔서 폴더 34개를 전부 봤습니다. 종목으로 자를 수 있는 구조가 아닙니다.

   
한 회차가 6종을 같이 쟀다 pending20_band_floor/RESULTS.md 는 축구 18편·농구 각 1편의 하한을 같은 문서에서 비교합니다. 「축구만 남기기」는 사전등록된 측정 기록을 사후에 쪼개 다시 쓰는 일이 됩니다
순수 팔 종목 폴더가 사실상 없다 pending6_side/(11MB, 전체의 31%)가 유일한 후보인데, 거기 담긴 결론이 「다리 판별이 4/11 = 36.4% 맞는다」(사람 라벨 12건)입니다 — 축구 결과입니다
phaseA(22MB, 63%)는 종목 자산이 아니다 분석 대상 선택(selector) 자산이고 골든셋 39클립이 야구 33 + 축구 5 혼합입니다. 축구 5건만 남기면 B-2~B-6 결론이 표본을 잃습니다
🔴 테스트가 깨진다 tests/test_observability.py::test_offline_evaluation_opts_out_explicitly 가 eval/phaseA/extract.py 파일을 직접 읽습니다(오프라인 추출이 서비스 관측을 오염시키지 않는지 확인). 지우면 FileNotFoundError 입니다
다른 폴더도 phaseA 를 import 한다 pending18_*·pending37_*·pending2_bands 등이 phaseA 모듈을 씁니다

그래서 eval/ 은 그대로 뒀습니다. 용량을 줄이는 길이라면 종목이 아니라 eval/phaseA/README.md 의 「이 사본에 넣지 않은 것」 표처럼 재생성 가능한 벌크 기준으로 보는 것이 맞습니다.

🔴 루브릭을 지우면서 깨진 것 — 재실행 스크립트 11개

load_rubric 으로 야구·농구 루브릭을 읽던 조사 스크립트 11개가 지금 FileNotFoundError 입니다(pytest 에는 안 걸립니다 — 수집 대상이 아닙니다). 로직은 고치지 않았습니다 — 사전 등록된 측정이라 사후에 고치면 그 RESULTS.md 가 무엇을 잰 기록인지 알 수 없게 됩니다. 대신 파일 머리에 「이 회차는 그대로 재실행되지 않는다 + 루브릭을 꺼내는 명령」을 달았습니다:

git show bf21391:agent/rubrics/baseball_batting.yaml > agent/rubrics/baseball_batting.yaml

🔴 결정 — 평가도 축구로 통합합니다 (2026.09.16, 사용자 지시)

「앞으로 모든 종목은 축구로 통합시켜줘.」 위 2026.09.11 전환은 코드·루브릭 이었고, 이번 결정은 평가 표본까지입니다. 무엇이 달라지는지 적어 둡니다.

   
앞으로의 회차 축구 표본으로 엽니다. 8번 3회차(2026.09.16)가 첫 적용입니다 — 야구로 돌리다 축구(SoccerNet 100편)로 옮겼습니다
기존 B-1~B-6 지우지 않습니다. 야구 39편 위에 선 기록이고, 판독 라벨은 다시 못 받습니다. 「그때 그 표본으로 이랬다」로 남습니다
🔴 인용할 때 종목을 밝힙니다. 로드맵 2절의 「종목을 넘어 유효하다를 뭉뚱그리지 않는다」가 그대로 적용됩니다
23번 문장 검사 같은 날 축구 루브릭 기준선을 새로 세웠습니다(64문장). 야구 19문장은 기록으로 남습니다

🔴 이 결정이 드러내는 구멍: 지금 축구 표본은 둘 다 반쪽입니다.

표본 할 수 있는 질문 못 하는 질문
축구 1인 19편 관절·등급 여러 명 중 고르기 (다인이 없습니다)
SoccerNet 방송 100편 박스 선택 관절·등급 (사람 키 중앙 28px)

둘 다 되는 표본 — 다인 + 근접 촬영 축구 — 이 아직 없습니다. 그래서 「제품 성능(등급)을 축구로 말한다」는 아직 못 합니다(46번과 같은 자리이고, 실사용자 영상이 쌓이면 풀립니다).

봐 주셨으면 하는 것 — 남의 영역이라 손대지 않았습니다

종목 코드는 에이전트 밖에도 있습니다. 지금 고장 난 곳은 없습니다 — 화면에 야구·농구가 떠도 그 작업은 워커에서 「루브릭 없음」으로 거부됩니다(아래 검사가 그것을 고정합니다). 다만 사용자에게는 고를 수 있어 보입니다.

어디 무엇 담당
www/src/lib/sports.ts · rubricFocus.ts 종목 선택지에 야구·농구가 있고 baseball_pitching 루브릭을 가리킵니다 백성검
fastapi/alembic/.../20260901_sport_and_position.py · 20260902_match_tables.py sport·포지션 참조 행(야구 P/C/IF/OF, 농구 G/F/C) 정어진
fastapi/alembic/.../20260910_seed_metric_definition.py grade.baseball.*·grade.basketball.* 시드 행 정어진
flutter/lib/core/mock/mock_db.dart 목 데이터의 종목 백성검
부록 D(sport 3행)·부록 E(2026-08-26 결정)·1부 사업개요·4부 시장 제안서 서술이 3종목입니다 박민호

🔴 마이그레이션은 되돌리지 마세요. 이미 적재된 행을 지우면 그 종목으로 올라간 과거 데이터가 참조를 잃습니다. 새 행을 안 만드는 쪽이나 화면에서 빼는 쪽이 안전합니다 — 어느 쪽이든 정어진 님·백성검 님 판단입니다.

  • 확인: cd agent && uv run pytest tests/ -q (352건 통과) · ls agent/rubrics/ (축구 2개만) · uv run python scripts/worker.py --dry-run → football 한 줄
  • 새로 생긴 검사: test_worker.py::test_a_sport_we_do_not_support_is_refused_not_guessed — 축구 아닌 sport_code(백엔드 참조 테이블에 남아 있습니다)가 축구 루브릭으로 조용히 채점되는 것을 막습니다. soccer(데이터셋 쪽 이름)도 거부합니다 — 루브릭의 종목 코드는 football 입니다
  • 하지 말 것: 🔴 eval/ 을 「야구 자료」로 보고 지우지 마세요. 포즈 검증 근거입니다 · 🔴 종목을 되살릴 때 루브릭 없이 이름표만 추가하지 마세요 — 멈추는 이유가 「지원하지 않는 종목」이 아니라 설정 실수처럼 읽힙니다
  • 관련: 3번(덮었습니다) · 19번(데이터셋 조사 — 닫힌 경로로 남김) · jin 17번(종목 참조 테이블) · jin 23번(지표 시드)
  • 담당: 백성검(화면 종목 선택지) ✅ 해소 (2026.09.14) · 정어진(참조 테이블·시드 판단) ✅ 해소 (2026.09.16, sport.active) · 박민호(제안서 서술) · 제기: 정상호 · 기한: 스프린트 3 (급하지 않습니다 — 지금 깨지는 것은 없습니다)

✅ 정어진 몫 해소 (2026.09.16) — 행은 남기고 active 를 내렸습니다

판단: 지우지 않습니다. 말씀하신 경고가 추측이 아니라 실물이었습니다 — 지금 DB에 야구 영상이 165건 올라와 있습니다(video 기준, 팀은 전부 축구). sport 행을 지우면 그 165건이 참조를 잃습니다.

그런데 그냥 두기만 하면 API가 계속 받습니다. 백성검 님이 화면에서는 빼 주셨지만(09-14), 다른 클라이언트(flutter 목업에 아직 세 종목이 있습니다)나 직접 호출은 등록이 통과한 뒤 워커에서 거부됩니다 — 말씀하신 「사용자에게는 고를 수 있어 보입니다」가 그 자리입니다.

그래서 지우는 것과 두는 것 사이로 갔습니다.

   
한 것 sport.active 컬럼 추가(마이그레이션 c5a81d0e6b73). 루브릭이 남은 축구만 true, 야구·농구는 false
막는 자리 새로 만드는 것만 — 팀 만들기·영상 등록. 422 SPORT_NOT_AVAILABLE 로 UNKNOWN_SPORT(오타)와 가릅니다
안 막는 자리 GET /positions·경기 목록·코치 목록의 종목 거르기는 그대로 — 올라가 있는 과거 데이터를 계속 볼 수 있어야 합니다
포지션 행 야구 4·농구 3행 그대로 둡니다. 위와 같은 이유(참조)이고, 안 불리면 안 쓰입니다
metric_definition 시드 grade.baseball.*·grade.basketball.* 10행 그대로 둡니다 — 「지우는 것은 정어진 판단」이라고 주신 자리인데, 지울 이유가 참조 위험보다 작습니다

🔴 되살릴 때는 active 만 올리면 됩니다 — 행을 다시 넣을 필요가 없습니다. 다만 주신 「하지 말 것」대로 루브릭이 agent/rubrics/ 에 실제로 있을 때 올리겠습니다. 이름표만 올리면 멈추는 이유가 설정 실수처럼 읽힌다는 그 지적 그대로입니다.

계약 변경은 docs/client-contract-changes.md 50번으로 백성검 님께 전달했습니다(flutter 목업 쪽 확인 요청 포함). 계약·DB 양쪽 테스트 추가, 전체 pytest -q 통과.

확인 — www 쪽에서 실제로 깨진 테스트를 찾았습니다 (2026.09.11, 박민호)

ho·jin·paik을 main에 합친 뒤 www 전체 vitest를 돌려 봤습니다 (agent의 pytest만으로는 안 걸리는 자리라 위 서술과 안 겹칩니다). src/lib/rubricFocus.test.ts가 지워진 agent/rubrics/baseball_pitching.yaml· basketball_jump_shot.yaml을 직접 파일로 읽으려다 ENOENT로 3건 실패합니다 (baseball 의 항목이 루브릭 그대로다 · basketball 의 항목이 루브릭 그대로다 · 종목마다 열린 루브릭이 정확히 하나다).

🔴 CI는 안 막습니다 — www vitest를 돌리는 워크플로가 없습니다 (backend-tests.yml만 있음). 그래서 그대로 병합·push했습니다. 다만 백성검 님이 이 항목을 집으실 때 rubricFocus.ts뿐 아니라 이 테스트 파일도 같이 고치셔야 npx vitest run이 다시 깨끗해집니다.

✅ 백성검 몫 해소 (2026.09.14) — 화면 종목 선택지를 축구 하나로

  • www/src/lib/sports.ts·rubricFocus.ts에서 야구·농구를 뺐고, 위 테스트 3건도 축구 하나로 맞췄습니다
  • /me 업로드가 아직 종목을 묻고 있었습니다(야구·농구를 고르면 워커가 거부하는 자리). 종목 단추를 「올리기」 하나로 바꾸고 축구로 보냅니다. 분석 화면과 같이 「종목을 고르는 자리가 없다」를 시험이 붙듭니다
  • 안 건드린 것: 백엔드 참조 테이블을 흉내 내는 mock(포지션·경기 목록)· 공공 구장 데이터·마켓 목업, flutter/lib/core/mock/mock_db.dart — 백엔드 sport 행이 그대로 남아 있어서 그쪽 판단(정어진)을 따릅니다. /matches 종목 거르기는 SPORTS를 따라가므로 「전체 · 축구」만 남습니다
  • 확인: cd www && npx vitest run → 602 통과(실패 0) · npx tsc --noEmit 깨끗 · eslint 28건(오류 7) 변경 전과 같음

40. breakdown[] 에 필드가 또 하나 늘었습니다 — title_earned (2026-09-11 신설) ✅ 해소 (2026.09.16)

  • 위치: pending-archive.markdown의 ## ho (정상호) 구역으로 이동됨

41. 같은 영상을 아홉 번 다시 올려 아홉 번 같은 이유로 떨어졌습니다 (2026-09-11 신설)

min 11번(워커 토큰)을 고치고 밀린 큐가 비는 것을 보다 나온 것입니다. 추측이 아니라 실서버 로그입니다.

2026-09-11 03:01~04:29 에 워커가 18건을 처리했는데 실패가 10건이고, 그중 9건이 같은 클립(mixkit-goal-...-4348)입니다. 사유도 아홉 번 똑같습니다:

품질 게이트 미달: 하반신 스윙 측 키포인트(3개 관절) 유효 프레임 비율 53% < 기준 70%.
재촬영이 필요하다.

무엇이 문제인가 — 게이트가 아니라 되풀이입니다

🔴 게이트는 제대로 일했습니다. 못 재는 영상으로 점수를 지어내는 것보다 거절하는 것이 맞고, 사유도 정확합니다. 고장이 아닙니다.

문제는 판정이 결정론적이라는 것입니다. 같은 파일은 몇 번을 올려도 같은 53% 가 나옵니다 — 그런데 화면 문구가 「재촬영이 필요하다」이니 사용자는 다시 올려 보는 것이 자연스럽습니다. 그래서 아홉 번이 났습니다.

무엇이 새는가  
사용자 아홉 번 기다리고 아홉 번 같은 말을 들었습니다. 될 리 없는 것을 기다린 것입니다
GPU 24초 × 9 ≈ 3.6분. 지금은 작지만 사용자가 늘면 그대로 곱해집니다
S3 업로드마다 새 키가 생깁니다(18건이 전부 다른 키였습니다)

만족해야 할 성질 (자리는 예시입니다)

  • 같은 영상을 다시 올리면 앞의 결과를 알려 줄 것. 「이 영상은 앞서 같은 이유로 분석되지 않았습니다」 정도면 됩니다. 🔴 막을 필요는 없습니다 — 촬영을 다시 해서 올린 것을 거절하면 그게 더 나쁩니다
  • 문구가 「무엇을 바꿔야 하는지」를 말할 것. 지금 「재촬영이 필요하다」는 같은 파일을 다시 올리는 것을 막지 못합니다. 「하반신이 화면에 절반쯤만 잡혔습니다 — 다리가 다 나오게 찍어 주세요」 쪽이면 같은 파일을 다시 올릴 이유가 없어집니다. 🔴 에이전트 쪽 사유 문자열이라 이건 제 몫입니다

🔴 하지 말 것

  • 문턱 70% 를 낮추지 마세요. 이 게이트가 막는 것은 「못 쟀는데 점수가 나오는 것」입니다. 낮추면 53% 짜리 영상에 등급이 붙고, 그건 ho 20·21번이 고친 결함과 같은 계열입니다
  • 같은 해시의 업로드를 자동으로 거절하지 마세요 — 위 첫 항목의 이유입니다

✅ 정어진 조각 — 새 작업 없이 재사용합니다 (2026.09.16)

판단: 합니다. 성공·실패 둘 다 — 같은 파일이면 재분석해도 같은 결과가 나오는 건 실패든 성공이든 같은 논리라, 성공한 영상을 또 올리는 것도 GPU 낭비이긴 마찬가지입니다.

어떻게: 등록(POST /videos)이 이미 하던 S3 HeadObject(size_of)에서 ETag를 같이 얻습니다 — 이 저장소는 사전 서명 단일 PUT만 쓰므로(상한 200MB, 멀티파트 없음) ETag가 곧 MD5라, 클라이언트 해싱도 새 다운로드도 필요 없습니다. 같은 사용자가 올린 같은 내용(video.content_hash)의 영상 중 분석까지 끝난 것(성공·실패 무관)이 있으면 새 analysis_job을 아예 안 만들고 그 결과를 등록 응답에 바로 싣습니다(duplicate_of_video_id· duplicate_status·duplicate_failure_reason).

🔴 가짜 analysis_job을 만드는 방식은 안 썼습니다 — queued를 건너뛰고 바로 succeeded/failed로 만들면 job_rules.py가 명시적으로 금지하는 것과 같은 모양이 됩니다(“워커가 안 집은 작업이 끝난 것처럼 보이면 PER-001이 보려는 started_at/finished_at 차이가 거짓이 된다”). 그래서 이 영상 자신은 작업을 아예 안 만들고, 빌려온 결과는 등록 응답 한 번에만 싣습니다 — 나중에 GET /videos로 다시 읽으면 이 영상의 analysis_status는 정직하게 null입니다.

「이 사람으로 분석」·「집중해서 볼 항목」을 지정한 업로드는 대상에서 뺐습니다 — 같은 영상이어도 누구를 보라고 골랐는지가 다르면 측정 결과가 다를 수 있습니다. 같은 사용자로만 좁혔습니다(남의 결과는 안 빌려옵니다).

계약 변경은 docs/client-contract-changes.md 48번으로 백성검에게 전달했습니다. 유닛·계약·DB 3층 테스트 추가, 전체 pytest -q 통과, alembic heads/check 클린. 마이그레이션 8c1f4a6e9d02.

남은 것: 백성검(재업로드 안내 UI · 프리픽스 숨기기 — 계약은 client-contract-changes.md 48번에 있습니다). 정상호 님 사유 문구는 아래 블록대로 끝났습니다.

🔴 두 조각이 겹치지 않고 서로를 보완합니다 (2026.09.16 병합 때 확인). 제 쪽은 같은 파일이면 아예 다시 안 돌립니다(새 작업을 안 만듦), 정상호 님 쪽은 처음 떨어질 때 무엇을 바꿔야 하는지 말합니다. 앞의 것이 GPU·S3 낭비를 막고, 뒤의 것이 애초에 같은 파일을 다시 올릴 이유를 없앱니다.

✅ 사유 문구 — 고쳤습니다 (2026.09.16, 정상호)

   
전 하반신 스윙 측 키포인트(3개 관절) 유효 프레임 비율 53% < 기준 70%. 재촬영이 필요하다.
후 다리가 영상에 충분히 잡히지 않았습니다 — 동작하는 쪽 골반·무릎·발목이 모두 보이는 프레임이 53%뿐입니다(기준 70%). 다리 전체가 화면 안에 들어오고 가려지지 않게 다시 찍어 주세요. 같은 영상을 다시 올리면 결과가 같습니다.

팔 쪽도 같은 형태입니다(어깨·팔꿈치 · 상체가). 셋을 담았습니다: 어느 부위가 안 잡혔나 · 어떻게 찍어야 하나 · 🔴 같은 파일을 다시 올려도 같다. 마지막 줄이 아홉 번을 막는 자리입니다.

  • 문턱은 안 건드렸습니다(70% 그대로) — 위 「하지 말 것」입니다
  • 🔴 길이를 셌습니다: 프리픽스 둘(품질 게이트 미달:·분석 중단:)까지 포함해 148자입니다. failure_reason 상한이 255자이고 워커가 뒤를 자르는데, 고쳐야 할 내용과 「같은 파일은 소용없다」가 문장 끝에 있어서 잘리면 정작 필요한 말부터 사라집니다 — 검사로 못박았습니다
  • 🔴 평가 기준선이 흔들리는지 재 봤습니다. 이 문구는 B-6 CSV 의 fail_reason 열에 그대로 들어갑니다. Track 2 를 다시 돌려 09-08 기준선과 대조했더니 불일치 0 입니다 — 옛 문구가 들어 있던 행은 baseball_pitch_trim 하나뿐이고 그것은 39번 종목 정리로 이미 빠진 클립이었습니다. (47번을 겪은 뒤라 가정하지 않고 쟀습니다.)
  • 검사 13건 추가(470 통과): 「무엇을 바꿔야 하는지 말한다」·「같은 파일은 안 된다고 말한다」·「내부 용어(스윙 측·키포인트)를 안 쓴다」·「255자에 안 잘린다」·조사(「팔가」·「어깨·팔꿈치이」가 한 번 나갔습니다)
  • 확인: cd agent && uv run pytest tests/test_features.py -q

🔴 백성검 님께 — 화면 쪽에 남은 것: 이제 사유 문장 자체가 무엇을 해야 하는지 말합니다. 그대로 보여 주시면 되고, 다만 앞에 붙는 품질 게이트 미달: 분석 중단: 두 프리픽스는 내부 표시라 화면에서는 빼는 편이 낫습니다. 🔴 워커 쪽에서 못 뺍니다 — 그 접두사가 「품질 미달(다시 찍어야 함)」과 「종목 불일치(다른 영상을 올려야 함)」를 가르는 검사에 걸려 있습니다(test_sport_gate.py). 「같은 영상 재업로드 안내」는 그대로 남아 있습니다.

  • 확인: sudo journalctl -u supersub-worker --since '2026-09-11 03:01' (인스턴스 supersub-ai)
  • 관련: min 11번(여기서 나왔습니다) · jin 24번(업로드마다 새 키가 생기는 것) · ho 20·21번(못 잰 것을 점수로 만들지 않기)
  • 담당: 정상호(사유 문구) ✅ 끝 (2026.09.16) · 백성검(같은 영상 재업로드 안내 · 프리픽스 숨기기) · 정어진(중복 업로드 판단 — 하실지 여부부터) ✅ 새 작업 없이 재사용 완료 (2026.09.16) · 제기: 정상호 · 기한: 스프린트 3 (급하지 않습니다 — 지금 깨지는 것은 없습니다)

42. S3 버킷 이름이 규칙과 실제가 어긋납니다 — 판단 부탁드립니다 (2026-09-11 신설)

루트 CLAUDE.md 의 자리표시자 표는 「S3 버킷 이름 → <S3 버킷>」 인데, 실제로는 문서에 값 그대로 19곳이 있습니다(s3://supersub-ai/…).

🔴 고치지 않았습니다. 대부분 남의 영역 문서이고, 19곳이나 되는 것은 실수가 아니라 판단이었을 가능성이 큽니다(jin 22번 식별자 정리 때 계정 ID·VPC· 인스턴스 ID 는 걷어내면서 버킷은 남겼습니다). 제가 임의로 바꾸면 그때 정한 이유를 모른 채 되돌리는 것이 됩니다.

어느 쪽이든 한쪽으로 맞춰야 합니다 — 지금은 규칙과 실물이 다르니 커밋 전 검사를 도는 사람마다 다르게 판단하게 됩니다. 저도 이번에 3곳을 같은 방식으로 더했습니다(기존 관례를 따랐습니다).

안  
(가) 규칙을 실물에 맞춘다 CLAUDE.md 표에서 버킷 행을 빼거나 「공개해도 되는 것」 쪽으로 옮깁니다. 근거: 버킷은 비공개이고 이름만으로는 못 읽습니다
(나) 실물을 규칙에 맞춘다 19곳을 자리표시자로. 🔴 재실행 명령이 많아 그대로 못 돌게 됩니다 — aws s3 ls s3://<S3 버킷>/reports/ 는 붙여넣어도 안 돕니다

저는 (가)를 권합니다 — 계정 ID·리소스 ID 와 달리 버킷 이름은 그것만으로 할 수 있는 것이 없고, 명령이 그대로 도는 값이 문서의 값이기 때문입니다. 다만 커밋 전 grep 에 버킷 패턴이 없는 것도 함께 정해 주시면 좋겠습니다.

🔴 갱신 (2026.09.18) — 그 사이 숫자가 흔들렸습니다. 이 항목의 논거입니다

항목에 적은 19곳이 지금 20곳입니다. 되짚어 보니 19 → 21 → 20 으로 오갔습니다(09-11 의 제 43번 커밋이 둘 늘렸고, 그 뒤 압축·아카이브가 하나 줄였습니다).

🔴 이것이 이 항목이 말하려던 바로 그것입니다 — 규칙과 실물이 다르면 아무도 틀리지 않으면서 숫자가 움직입니다. 커밋 전 검사에 버킷 패턴이 없으니 늘어도 안 걸리고, 줄어도 아무도 모릅니다.

지금 분포(2026.09.18 실측, 20곳):

파일  
jekyll/pages/pending.markdown 6
fastapi/docs/worker-interface.md 4
jekyll/pages/pending-archive.markdown · _posts/2026-09-03-… 각 3
fastapi/docs/deployment.md · agent/scripts/dataset_pipeline/README.md · agent/deploy/README.md · agent/deploy/README-console.md 각 1

🔴 여전히 안 고쳤습니다 — (가)/(나) 판단이 나기 전에 제가 건드리면 같은 일이 반복됩니다. 판단만 주시면 제 구역(agent/·jekyll/) 몫은 제가 합니다.

  • 확인: grep -rn 's3://supersub-ai' --include='*.md' --include='*.markdown' . | wc -l → 19 20 (2026.09.18 실측). 🔴 이 숫자 자체가 고정값이 아닙니다 — 판단이 나기 전까지는 흔들립니다
  • 관련: 루트 CLAUDE.md 「공개 사이트에 인프라 식별자를 쓰지 않습니다」 · jin 21·22번(식별자 정리)
  • 담당: 박민호(규칙 소유) · 정어진(jin 22번에서 남긴 판단이 있으면) · 제기: 정상호 · 기한: 급하지 않음 (지금 새는 것은 없습니다 — 규칙과 실물이 다른 것이 문제입니다)

43. 연결이 끝난 뒤 기능 여섯 건 — 순서와 그 근거 (2026-09-11 신설)

에이전트가 프론트·백엔드와 이어진 뒤 사용자가 여섯 가지를 요청했습니다. 여기가 그 목록의 정본이고, 착수 순서는 제가 정했습니다.

사용자가 말한 것 제 순서
㉮ 축구 외 종목 업로드 막기 (공 인식 · 영상이 축구인지) 1
㉯ 여러 명 인식 → 고른 사람만 분석 3
㉰ 고른 사람이 가려져도 따라가기 6
㉱ 리포트를 일반인 말로 2
㉲ 한 영상에 슛·패스가 같이 나오면 리포트 둘 5
㉳ 루브릭 더 (드리블 등) 4

🔴 순서를 이렇게 잡은 이유 — 셋만 보시면 됩니다

⑴ ㉮ 가 1번인 것은 「새 모델이 필요 없어서」입니다. 검출기(RT-DETR)가 이미 COCO 80클래스를 내는데 우리는 3개만 씁니다(sports_ball·baseball_bat· tennis_racket). 같은 forward 결과를 재사용하므로 추론 비용이 0 입니다. 그리고 🔴 부정 신호가 더 싸고 정확합니다 — 「축구인가」를 맞히는 것보다 「배트·라켓이 보이니 축구가 아니다」가 훨씬 잘 섭니다.

지금 이게 없어서 나는 일이 가장 나쁜 실패 모드입니다: 야구 영상을 올려도 sport_code 만 축구면 축구 루브릭으로 점수가 나옵니다. 워커의 test_a_sport_we_do_not_support_is_refused_not_guessed 는 메타데이터만 보지 내용은 안 봅니다.

⑵ ㉰(가림 추적)를 마지막에 둔 것은 미루려는 게 아닙니다. 그건 미결 18번 이고 사전 등록 10회차까지 돌았습니다. 닫힌 경로가 9개입니다(로드맵 4절). 남은 길은 (A) 사람 판독 70장 또는 (B) 더 강한 외양 모델뿐입니다.

🔴 그런데 1번이 새 단서를 줍니다 — 공 위치입니다. 열 회차가 전부 IoU와 외양이었고 공은 한 번도 안 썼습니다. 축구에서 분석 대상은 공 근처에 있고, 이건 닫힌 경로 목록에 없는 신호입니다. 그래서 순서가 뒤인 것이지 포기가 아니고, 1번을 할 때 공 궤적을 봉투에 남겨 6번이 쓸 수 있게 합니다.

⑶ ㉲(리포트 둘)가 5번인 것은 계약을 깨서입니다. 지금 계약은 영상 1 : 리포트 1 을 전제합니다(reports/<user_id>/<video_id>/report.json 하나, analysis_job 도 1:1). 여러 리포트는 schema_version 2.0(뜻이 바뀌는 변경) 이라 적재·읽기·화면이 함께 움직여야 합니다. 게다가 jin 17번(클립의 「동작」을 담을 자리가 없다)이 선행이고, ㉳로 동작이 셋 이상 되어야 「연달아」가 뜻이 생깁니다.

착수 순서와 각각의 첫 걸음

# 무엇 첫 걸음 · 걸리는 것
1 ㉮ 축구 아닌 영상 거르기 ✅ 1회차 끝 (2026.09.11) — 아래 「㉮ 진행」
2 ㉱ 리포트 문구 ✅ 1회차 끝 (2026.09.11) · ✅ 2회차 — 단위 실측·수정 (2026.09.16) — 아래 「㉱ 진행」
3 ㉯ 사람 목록 ✅ 끝 (2026.09.18, 44번 종결) — 서버 경로(계약 38)는 워커 배선·GPU 실물 확인까지 됐고(후보 2명 10.2초), 화면은 안 붙이기로 했습니다(2026.09.17). 🔴 기능 자체는 됩니다 — 브라우저가 직접 찾고 고르는 규칙이 서버와 같습니다. 서버 경로는 브라우저 검출이 모자란 것이 드러나면 되살립니다
4 ㉳ 드리블 루브릭 🔴 순서를 정정합니다 (2026.09.11) — 아래 「㉳ 정정」. 드리블의 선행은 ㉲ 입니다
5 ㉲ 리포트 둘 jin 17번 → 동작 분할 → 계약 2.0. 미리 알립니다
6 ㉰ 가림 추적 🔴 11·12회차 돌렸습니다 — 둘 다 판별불가 (2026.09.14). 11회차는 표본에 현상이 없었고(축구 19편이 전부 1인 영상), 46번으로 다인 표본을 확보한 뒤 12회차가 성립했습니다(주 층 1프레임 → 198프레임). 🔴 M 17.7% 로 (나) 쪽인데 제가 박아 둔 기전(원근)이 틀려 판별불가로 내렸습니다 — 진짜 기전은 「면적이 폭에 끌린다」입니다. ✅ 13회차가 그 이유를 갈랐습니다 — 겹침이 아니라 「자세」(S 12.3%, 사각지대를 최악으로 쳐도 34.4%)라 뿌리는 선택 규칙입니다. 🔴 14회차까지 돌렸고 처방을 못 골랐습니다 — R1(높이)은 오히려 더 나쁘고(두 층에서 −8.6·−3.8%p) R2(누운 박스 제외)는 5%만 건드려 아무것도 안 바꿉니다. 기하로 고치는 길을 닫습니다. 남은 것은 사람 판독 70장과 그 위에 서는 공 근접 선택뿐입니다. 정본은 미결 18번

🔴 ㉳ 정정 (2026.09.11) — 드리블은 ㉲ 뒤입니다. 제가 순서를 틀렸습니다

「루브릭 하나 더 쓰는 일」로 보고 4번에 뒀는데, 착수하려고 보니 YAML 문제가 아니었습니다.

파이프라인 전체가 「클립당 임팩트 한 번」을 전제합니다.

IMPACT_EVENTS = ("extension_peak", "distal_apex")   ← 둘 다 단일 순간
segment_phases(...) -> Phases(takeback, impact, follow_through)   ← impact 는 int 하나
지표 이름: swing_knee_angle_at_impact · plant_knee_angle_at_impact · …

드리블은 반복 동작이라 임팩트가 여러 번입니다. 지금 구조로 드리블 루브릭을 쓰면 「신전 각속도가 최대인 아무 터치 하나」를 골라 그 순간의 무릎각을 채점하게 됩니다 — 드리블을 재는 것이 아닙니다. 숫자는 나오지만 뜻이 없고, 그건 이 저장소가 가장 경계하는 형태입니다.

🔴 그리고 이건 ㉲(한 영상에 리포트 둘)와 같은 빈틈입니다. 둘 다 「한 클립에 이벤트가 여럿」이 필요합니다. 그래서 ㉳ 의 진짜 선행은 ㉲ 이고, 제가 4·5로 갈라 둔 것이 틀렸습니다.

드리블에 더 필요한 것 (㉲ 뒤에도 남습니다): 지금 지표 12개는 전부 단일 순간의 관절각입니다. 드리블의 질은 터치 빈도·공과의 거리 유지·제어 반경인데 하나도 없습니다. 공 궤적(objects["sports_ball"])이 있으니 만들 수는 있지만, 새 지표는 features 를 바꾸고 근거 4축 선언과 검수 대상 임계값을 함께 늘립니다.

그럼 지금 넣을 수 있는 루브릭은

단일 임팩트 · 다리 · 새 지표 0개면 오늘 됩니다. 후보:

동작 지금 지표로 되나
롱패스 · 크로스 ✅ 됩니다. 인스텝과 폼이 다릅니다(상체 젖힘·디딤발 간격·마무리 길이)
트래핑 · 퍼스트터치 🔶 절반. 쿠션(무릎·상체)은 되는데 핵심인 「공을 어디에 뒀는가」(제어 반경)가 없습니다 — 새 지표 하나 필요
헤딩 ❌ impact_limb 이 leg·arm 뿐입니다
드리블 ❌ 위

🔴 어느 것을 넣든 임계값은 unverified 로 늘어납니다 (지금 30/30, 미결 36번). 지도자 검수(2·34번) 대상이 그만큼 늘어나므로 무엇을 넣을지는 제품 판단이라 여쭙습니다.

✅ ㉳ 1회차 (2026.09.14) — 병목의 크기를 쟀습니다. 구간을 쪼개는 길은 닫힙니다

㉳ 의 선행이던 ㉲ 칸이 같은 날 열렸는데(45번 4회차), 그 회차가 다음 벽을 스스로 드러냈습니다 — 클립을 반으로만 잘랐는데 창 22개가 경계 검사 (impact - first < 2)에 걸렸습니다. 드리블은 터치 간격이 반 클립일 리 없습니다. 그래서 루브릭도 지표도 아닌 「간격이 짧을 때 구간 분할이 성립하는가」를 쟀습니다.

사전 등록 agent/eval/pending43_dribble_spacing/PREREGISTRATION.md(e2f079e, 재기 전 커밋) · 결과 RESULTS.md. 🔴 조사 회차라 src/ 무변경(0줄) · GPU 안 씀.

기준 내용 결과  
A 🔴 계기 검사 — L ≤ 4 성립 0건(구조적으로 불가능) 0건 ✅
B L 11개 전부에 값 11/11 ✅
C 성립률이 L 에 단조 비감소 위반 0 ✅
D 90% 에 필요한 L 보고 🔴 없음 ✅
E src/·rubrics/ 무변경 0줄 ✅
F pytest -q 413 통과 ✅
G 🔴 짝 기준 77.5 < 97.7 예측대로 ✅
L (프레임) 5 10 15 30 60
성립률 13% 42% 53% 70% 83%
임팩트 각속도 백분위 77.5 86.9 91.9 95.7 97.7

🔴 L=10·15 를 굵게 둔 것은 그것이 드리블 간격이라고 「가정」했기 때문입니다 (초당 두세 번 × 30fps). 재 보지 않았습니다 — 드리블 클립이 없습니다.

🔴 D 가 뜻밖입니다 — 90% 에 닿는 L 이 없습니다. L=60(2초)에서도 83% 입니다. 「구간을 넉넉히 잡으면 된다」가 닫혔습니다: 타일 안에 가운데쯤 봉우리가 없으면 어떤 L 이어도 걸리고, L 을 키우면 실패 비율만 낮아집니다.

🔴 정정 — 제가 어제 적은 「2 를 낮추면 안 되는 이유」가 틀렸습니다

45번 4회차에 “2 를 낮추는 것은 새 상수라 답이 아니다” 라고 적으면서 속으로 「낮추면 집는 프레임이 나빠질 것」을 깔고 있었습니다. 재 봤습니다 (🔴 src/ 를 안 고치고 규칙을 평가 스크립트에 복제했습니다).

경계 여유 m L=10 성립률 백분위
0 89% 87.0
1 59% 87.0
2 (production) 42% 86.9

성립률이 배 넘게 오르는데 백분위는 안 움직입니다. 제 예상이 틀렸습니다.

🔴 그런데 계기가 그 대가를 볼 자리가 아니었습니다. 각속도 백분위는 임팩트 프레임 하나만 보는데, m 이 지키는 것은 봉우리의 질이 아니라 「준비·마무리 구간이 비지 않는 것」입니다. m=0 이 추가로 살리는 891개 구간(L=10)을 세어 보니 —

   
takeback 길이 0 35%
follow_through 길이 0 33%
한쪽이 1프레임뿐 39%

사실상 전부 퇴화했습니다. 그 위에서 follow_through_duration_frames 와 swing_hip_flexion_after_impact_deg 가 계산됩니다 — 구간이 비면 그 둘은 값이 아니라 소음입니다.

2 를 낮추면 안 되는 이유는 새 상수라서 → 🔴 낮추면 지표가 뜻을 잃어서입니다. 「새 상수」는 형식적으로 맞지만 왜 나쁜지를 말해 주지 않습니다.

✅ 짝 기준을 안 박았으면 「낮춰도 백분위가 안 떨어지니 낮추자」로 적을 뻔했습니다. 8·10회차에서 배운 것과 같은 형태입니다 — 사전 등록은 「결과를 보고 기준을 바꾸는 것」을 막지만 계기가 재려던 것을 재는지는 막지 못합니다.

그래서 ㉳ 에 남는 것
   
✅ 칸은 열렸다 45번 4회차의 window 로 구간마다 한 번씩 돌릴 수 있습니다
🔴 간격이 짧으면 절반이 안 선다 가정한 10~15프레임에서 42~53%
🔴 경계 여유를 푸는 길은 닫혔다 빈 구간이 들어오고 마무리 지표 둘이 무효
🔴 「성립」이 「터치」인지는 못 말한다 각속도 백분위는 「봉우리다」까지입니다. 「터치다」는 정답이 필요합니다

남은 길은 이 항목이 이미 적어 둔 그것입니다 — 터치 빈도·공과의 거리 유지·제어 반경. 구간을 쪼개는 쪽으로는 더 갈 곳이 없다는 것을 이 회차가 보였습니다. 🔴 그 지표들은 features 를 바꿔 B-6 재실행을 부르고 임계값을 unverified 로 더 늘립니다(지금 11/11) — 무엇을 넣을지는 제품 판단이라 위에서 여쭈어 둔 그대로이고, 기다리는 동안 할 수 있는 것은 이 회차로 다 했습니다.

🔴 한계: 표본이 야구 39클립이라 제품 표본이 아닙니다(재는 성질이 구조적이라 회차는 서지만 드리블 성능을 말하지 않습니다).

✅ ㉳ 2회차 (2026.09.15) — 창 안의 값은 분할을 함께 잽니다

🔴 정정 — 1회차 끝에 적은 「기다리는 동안 할 수 있는 것은 이 회차로 다 했습니다」가 틀렸습니다. 하나 남아 있었습니다. 1·4회차가 물은 것은 전부 「구간이 서는가」까지였고, 선 다음에 나온 값이 쓸 수 있는 값인지는 아무도 안 물었습니다.

사전 등록 agent/eval/pending43_dribble_metrics/PREREGISTRATION.md(9e04ecf, 재기 전 커밋) · 결과 RESULTS.md(5b02af6). 🔴 조사 회차라 src/· rubrics/ 무변경(0줄) · GPU 안 씀 · pytest 413 통과.

계기: 같은 임팩트 프레임을 같은 정의로 잡은 짝(창 없음 vs 창 안)을 대 봅니다. 임팩트가 같으므로 차이는 전부 분할이 만든 것입니다.

기준 내용 결과  
A 계기 검사 — window=None 이 45번 4회차 스냅샷과 비트 동일 불일치 0 ✅
B 분모 — L=30 짝 ≥ 20 34쌍 ✅
C-1 🔴 짝 예측 — 누수지표는 창에 안 묶인다 불일치 0 ✅ 예측대로
C-2 🔴 짝 예측 — 창 지표는 달라지고 전부 작아진다 바뀜 161 · 커진 것 0 ✅ 예측대로
D 크기 보고 아래 ✅
E·F src/ 무변경 · 테스트 0줄 · 413 ✅
G 누수를 창 끝에서 끊으면 바뀌는 산출 🔴 2개 보고

찾은 것 둘은 성격이 다릅니다. 섞으면 잘못 고칩니다.

⑴ 누수 — 결함입니다. follow_through_duration_frames 가 ankle_speed[t:] 라 창이 아니라 클립 끝까지 봅니다. 창을 걸어도 값이 한 건도 안 바뀌고, L=10 에서 35%가 창 끝을 넘겨 감속을 찾습니다. 드리블이면 다음 터치의 발 속도를 이번 마무리로 셉니다. 🔴 extract_features docstring 이 「마무리 구간도 창 안에서 끊긴다」고 적어 둔 것과 어긋납니다 — 문서가 코드보다 앞서 있습니다. 🔴 그리고 이 값은 밴드가 아니라 근거 문장으로 나가서 점수에는 안 보입니다.

⑵ 축소 — 결함이 아닙니다. 창에 묶인 지표 둘이 작아집니다(중앙 L=10 에서 골반 회전 −87% · 임팩트 후 굴곡 −57%). 커진 것이 0 인 이유는 대수 입니다(부분집합의 ptp는 커질 수 없음) — 그래서 이 열은 결론이 아니라 계기 검사입니다. 값은 정의 그대로이고, 준비 구간이 짧으면 골반이 돌 시간이 실제로 없습니다.

L 등급 판정 짝 등급 바뀜 내려감 올라감
10 41 44% 18 0
15 49 41% 20 0
30 59 29% 17 0
60 69 17% 12 0

🔴 한 방향으로만 움직입니다. L=60(2초)에서도 17%가 내려갑니다 — 창을 넉넉히 잡아도 안 없어집니다. 강등은 주로 hip_rotation 이 만듭니다 (L=10 에서 67%).

그래서 틀린 것은 값이 아니라 밴드입니다. hip_rotation(25/15도)· follow_through(30/15도)는 단일 동작 한 번을 통째로 본 클립에서 매긴 눈금입니다. 반복 동작의 한 구간에 그대로 대면 잘 한 터치도 내려갑니다. 44%는 선수가 못한 것이 아니라 자가 안 맞는 것입니다. 로드맵이 적어 둔 「루브릭 앵커는 다른 동작점에서 매긴 값이다」와 같은 형태이고, 그때는 fps 였고 이번엔 구간 길이입니다.

🔴 그래서 ㉳ 에서 「새 지표를 만들자」보다 먼저 서는 것이 있습니다 — 반복 동작용 밴드입니다. 새 지표를 넣어도 기존 항목이 그대로 채점에 들어가면 강등은 남습니다.

남은 길 셋 (고르지 않았습니다):

   
(가) 누수 수정 🔴 공짜가 아닙니다 — window=None 에서도 산출 2/78이 바뀝니다(arm 설정 둘, 109→104 · 56→47). 즉 오늘도 유효 구간 밖의 발목 속도로 감속을 판정하고 있습니다. features 변경이라 B-6 재실행을 부르고, 별도 사전 등록이 필요합니다. docstring 도 함께 고칩니다
(나) 반복 동작용 밴드 🔴 제품·검수 판단입니다 — 임계값 11/11 이 이미 unverified 인데(34·36번) 여기 더합니다
(다) 드리블 표본 1·2회차가 같은 가정(간격 10~15프레임) 위에 서 있습니다. 46번이 다인 표본에 한 것과 같은 형태의 회차

🔴 (가)와 (나)는 순서가 있습니다 — (나)를 먼저 하면 누수가 낀 값 위에 눈금을 매기게 됩니다.

🔴 한계는 1회차와 같습니다: 표본이 야구 39편(1·4회차와 같은 표본 — 안 쓴 표본이 없습니다)이고, 드리블 간격 10~15프레임은 여전히 가정입니다. 넘는 것은 계기의 성질이고 「축구 드리블에서 몇 %」는 안 넘습니다.

✅ ㉳ 3회차 (2026.09.15) — 누수를 고쳤습니다. 그리고 B-6 기준선이 안 맞는 것을 봤습니다

2회차가 잰 (가) 누수를 고쳤습니다. 사전 등록 agent/eval/pending43_leak_fix/PREREGISTRATION.md(50c5d7f, 구현 전 커밋) · 결과 RESULTS.md · 구현 99f12b9.

고친 것은 한 줄입니다 — post = ankle_speed[t:] → ankle_speed[t:ft_end].

기준 내용 결과  
A 변경 범위 1줄 1줄 + 주석 ✅
B 🔴 값까지 박은 예측 — 78산출 중 정확히 둘 2개, 109→104 · 56→47 ✅
C 🔴 짝 기준 — 누수 소멸 + 값이 실제로 잘림 창 밖 0% · 짝 8건 갈라짐 ✅
D 🔴 2회차 결론의 시험 — 등급·점수 불변 불일치 0 (290행) ✅
E-1 기존 B-6 CSV 와 대조 🔴 깨졌습니다 (아래) 🔴
E-2 고치기 전 → 후 격리 마무리 길이 두 열 말고 전부 비트 동일 ✅
F 회귀 검사 · 테스트 415 (사전 등록은 414로 적었습니다) 🔶

무엇이 달라졌나: L=10 창에서 「마무리 86프레임」이 나오던 것이 6이 됐습니다. 창 밖 읽기가 35/31/15/15% → 전부 0%.

🔴 D 가 2회차 결론을 실물에서 확인했습니다 — 이 값이 바뀌어도 등급·점수는 290행에서 한 칸도 안 움직입니다. 즉 이 결함은 점수로는 절대 안 드러나고 근거 문장에만 틀리게 실립니다.

🔴 F 는 사전 등록과 어긋났습니다 — 「검사 하나·414」로 적었는데 둘을 넣어 415입니다. 창 없는 경우(유효 구간 밖)와 창 있는 경우(창 밖)는 한 검사로 둘 다 못 잡습니다. 숫자를 맞추려고 검사를 지우지 않았고, 어긋난 것을 적습니다. (둘 다 헛돌지 않는지 옛 규칙과 대 봤습니다 — 7 대 6 · 45 대 24.)

🔴 멈춤 규칙을 제가 잘못 썼습니다. 사전 등록에 「E-1 이 어긋나면 B·C·D 를 멈춘다」고 적었는데, 격리는 E-2 가 혼자 세웁니다(같은 코드·환경에서 한 줄만 다른 두 실행). B·C 는 B-6 을 아예 안 씁니다. 기준을 결과에 맞춰 고친 것이 아니라 의존 관계를 처음에 잘못 적은 것이고, 다음 사전 등록부터는 멈춤 규칙을 걸기 전에 「어느 판정이 어느 기준에 실제로 의존하는가」를 먼저 적습니다.

E-1 이 드러낸 것은 이 회차보다 큽니다 → 아래 47번으로 올립니다.

🔴 「드리블 채점이 고쳐졌다」고 적지 않습니다. 2회차가 찾은 밴드 문제 (축소·강등 44%)는 그대로 남습니다 — 2회차 재측정에서 그 숫자가 한 개도 안 바뀌었습니다. 남은 길은 (나) 반복 동작용 밴드(제품·검수 판단)와 (다) 드리블 표본입니다.

🔴 ㉳ 를 뒤로 미룹니다 — 비교 화면(paik 30번)을 먼저 합니다 (2026.09.15)

순서를 바꾼 것이 아니라 막힌 것을 인정한 것에 가깝습니다. 남은 셋이 전부 제 손 밖입니다: (가) 누수 수정은 ✅ 끝났고(3회차), (나)는 지도자 검수 (2번, 층 4 라벨 0건), (다)는 드리블 클립이 없습니다(간격 10~15프레임은 아직 가정). 게다가 47번이 (나)·(다)보다 먼저라고 이미 적어 뒀습니다.

그 사이에 paik 30번(비교 화면의 관절·세 순간)이 급해졌습니다 — 사용자가 「비교 기능을 켜니 분석 시간이 2배」라고 했고 원인이 그 항목입니다. 정답도 라벨도 필요 없고 features 를 안 건드려 47번과도 안 엉킵니다. 2026.09.15 에 에이전트 몫을 끝냈습니다(paik 30번 참고).

🔴 ㉳ 를 닫는 것이 아닙니다 — 위 (나)·(다)가 열리면 그대로 이어집니다.

🗂 ㉮·㉱ 진행 기록 (2026.09.11) — 끝난 두 건의 회차 상세. 펼쳐 봅니다

✅ ㉮ 진행 — 축구 아닌 영상을 거릅니다 (2026.09.11, 1회차 합격)

사전 등록 agent/eval/pending43_sport_gate/PREREGISTRATION.md(커밋 8969ce3, 구현 전에 굳혔습니다) → 실측 58편 전수 → 합격 → 구현 df7ef61. 상세는 같은 폴더 RESULTS.md.

🔴 정답이 있는 드문 회차였습니다 — AI 판독이 아니라 데이터셋 라벨입니다.

사전 등록 기준 결과  
A 축구 19편 오거절 0건 0건 ✅
B 야구 39편 거절 ≥50% 69% (27/39) ✅
C 점수 비트 동일 게이트가 features 를 안 건드립니다 ✅

A 가 0건인 것은 운이 아닙니다 — 축구 19편이 예외 없이 sports_ball 하나뿐 이고 배트·라켓이 한 번도 안 나왔습니다. 분포가 겹치지 않습니다.

🔴 31%는 그냥 통과합니다 — 벽이 아니라 걸름망입니다

통과한 야구 12편은 배트 궤적이 안 남은 것들입니다(도구 없음 8 · 공만 4). 0% → 69% 는 큰 개선이지만 「이제 다른 종목은 안 들어온다」고 읽으면 안 됩니다. 검사로도 못박아 두었습니다(test_nothing_detected_is_not_a_rejection).

설계에서 지킨 것 넷
   
거절은 「있음」으로만 「공이 없으니 축구가 아니다」로 두면 정상 업로드를 대량으로 막습니다
🔴 sports_ball 은 근거로 못 씁니다 COCO 가 종목별 공을 안 나눕니다. 실측으로 야구 39편 중 12편이 공을 갖고 있어, 공을 양성 신호로 쓰면 그 12편이 그대로 통과합니다. 목록 수준에서도 검사로 막았습니다
🔴 종료 코드를 품질 게이트와 나눴습니다 품질(2)은 「다시 찍으세요」, 종목(3)은 「다른 영상을 올리세요」 — 사용자가 할 일이 정반대입니다. 뭉뚱그리면 같은 파일을 다시 올립니다(41번, 실서버에서 아홉 번)
사용자에게 baseball_bat 을 안 보입니다 「야구 배트가 보입니다」 — ㉱ 와 같은 취지
🔴 제 검사가 처음엔 이빨이 없었습니다 — 적어 둡니다

워커의 code 3 분기를 통째로 지워도 검사가 통과했습니다. 일반 분기가 내는 「종료 코드 3: 종목 불일치: …」가 제 단언 셋(「종목」 포함 · 「품질 게이트」 없음 · 「재촬영」 없음)을 전부 만족하기 때문입니다 — last_line 에 이미 그 말이 들어 있어서입니다. 계기가 재려던 것을 안 재고 있었습니다.

분기의 존재와 analyze_s3 의 종료 코드를 각각 못박아 고쳤고, 결함 셋(공을 근거로 · 분기 삭제 · 2로 내보내기)을 일부러 되살려 전부 빨개지는 것을 확인했습니다. 399 통과 (앞: 387).

남긴 한계 — 다음 회차의 재료
  • 🔴 농구는 원리적으로 못 거릅니다. COCO 에 구별되는 도구가 없습니다. 야구도 투구·수비는 배트가 화면에 없어 못 잡습니다
  • 🔴 라켓 오검출이 야구 39편 중 2편에서 났습니다. 이번엔 결과적으로 맞는 거절이었지만 축구에서 나면 그대로 오거절입니다. 라켓이 유일하게 잡아낸 것은 1편뿐이라 뺄지 다음 회차에서 잽니다 — 🔴 결과를 보고 이번 규칙을 고치지는 않았습니다(그러면 사전 등록이 무의미해집니다)
  • 표본이 방송·데이터셋 영상입니다. 🔴 정정 (2026.09.14, 18번 11회차) — 축구 19편(양성 층)은 방송이 아니라 1인 훈련 영상이고 OpenPose 스켈레톤이 픽셀에 구워져 있습니다. 🔴 이 회차의 판정은 그대로 섭니다(게이트가 보는 것은 배트·라켓이고 스켈레톤은 그것을 만들지 않습니다). 다만 「오거절 0건」을 스켈레톤이 그려진 영상에서 쟀다는 사실이 한계에 들어가야 합니다. 실사용자 업로드(휴대폰)로 재측정이 남은 것은 그대로입니다
  • ㉰(가림 추적)가 쓸 공 궤적은 pose.objects 에 이미 있습니다 — 봉투에 따로 싣지 않았습니다(평가 회차는 in-process 로 다시 계산합니다)

✅ ㉱ 진행 — 근거 문장에서 코드 심벌을 걷어냈습니다 (2026.09.11, 1회차)

사전 등록 agent/eval/pending43_evidence_plain/PREREGISTRATION.md(커밋 58f108e, 구현 전에 굳혔습니다) · 구현 34cc561.

🔴 결함이 둘인데 하나만 고쳤습니다. 실서버 문장을 갈라 보면:

측정값 swing_knee_angle_at_impact=151.6로 임팩트 직전 무릎각이 140~165도 범위 내에 …
        └─ 코드 심벌: 고쳤습니다 ─┘          └─ 구간 숫자: 못 고칩니다 ─┘
   
뿌리 build_prompt 가 지표 코드를 JSON 그대로 프롬프트에 넣었고 모델이 베꼈습니다. 샜던 자리 넷: criterion.id · 앵커 measured · metrics · 마무리 줄의 band_metric=값
고친 방법 그 함수 docstring 이 이미 쓰는 논리 그대로입니다 — 「프롬프트에 없는 숫자는 베껴 쓸 수 없다」. 심벌도 같습니다. 출력이 아니라 입력을 고쳤습니다. 라벨 정본은 contracts/metric_definitions.yaml(적재 시드와 같은 파일이라 화면·백엔드·문장이 같은 이름을 씁니다)
🔴 안 붙인 것 단위. follow_through_duration_frames 의 선언 단위가 s 인데 features 값은 프레임입니다(초 환산은 봉투의 frame_metrics_seconds). 붙였으면 12프레임을 「12초」로 지어냈을 것입니다
🔴 폴백 라벨을 못 찾아도 코드로 떨어지지 않습니다 — 그러면 유출이 되살아납니다. 항목명으로 갑니다
남은 결함 구간 숫자(「140~165도」)는 루브릭 grades[g] 문구에서 옵니다 — 미결 23번이고 처방(숫자 없는 수준 설명)이 스키마를 늘려 지도자 검수(2·34번)에 묶여 있습니다. 섞으면 검수를 기다리느라 오늘 고칠 수 있는 것까지 멈춥니다
검사 tests/test_judge_plain_language.py 25건 — 전 루브릭 × 전 항목 × 전 등급 전수 + 실서버 사례 회귀 하나. 🔴 일부러 되돌려 12건이 빨개지는 것을 확인했습니다
확인 cd agent && uv run pytest tests/ -q → 387 통과 (앞: 362)
점수 🔴 한 비트도 안 바뀝니다 — 근거 문장만 만드는 자리이고 등급은 grade_for 가 정합니다. B-6 재실행 없음

백성검 님께: 화면이 evidence 를 그대로 그리시면 됩니다. report-contract.md 의 evidence 행에 「지표 코드 없음 / 구간 숫자는 아직 나올 수 있음」을 적어 두었습니다. 🔴 여전히 정규식으로 등급을 뽑지 마세요 (23·24번).

🔴 계기 검사 — 재는 것은 프롬프트에 심벌이 있는가이지 모델 출력에 있는가가 아닙니다. 관측된 유출은 전부 프롬프트를 베낀 것이라 이 자리가 맞지만, 모델이 지어내는 경우는 이 검사가 못 봅니다. 출력 쪽 확인은 2회차(LLM 필요)로 남깁니다.

✅ ㉱ 2회차 — 단위를 빼면 지어냅니다. 주면 베낍니다 (2026.09.16)

사전 등록 PREREGISTRATION_2.md(c3035c2, 돌리기 전 커밋) · 결과 RESULTS_2.md. 위 1회차가 「출력 쪽 확인은 2회차로 남긴다」고 적어 둔 그 회차이고, EXAONE 1.2B 를 로컬에 올려 120문장씩 앞뒤로 쟀습니다.

  문장 값을 언급 「초·분」을 붙임 「N프레임」
전 120 21 🔴 15 (71%) 0
후 120 23 0 ✅ 23
전: … 팔로스루 지속 시간은 7초로 짧습니다.      ← 실제 값은 7프레임
후: … 팔로스루 지속 시간은 7프레임으로 짧습니다.

🔴 위 1회차의 「단위를 붙이면 지어낸다」를 반만 정정합니다. 계약의 unit(s)을 그대로 붙였으면 정말 「12초」가 됐을 것이라 그 판단은 맞았습니다. 놓친 것은 「빼면 안 지어낸다」 쪽입니다 — 빼도 지어냅니다. 「지속 시간: 7」이 단위를 부르는 문장이라, 가장 그럴듯한 「초」가 따라옵니다.

  • 새 선언을 하나도 안 더했습니다 — 계약의 emitted_from: frame_metrics_seconds 가 이미 「환산을 거쳐야 초가 된다」를 말하므로, 그것이 있으면 프레임입니다
  • 🔴 초로 환산하지는 않았습니다. 프롬프트에는 앵커가 프레임으로 들어가서, 측정값만 초로 바꾸면 모델이 두 다른 자를 나란히 봅니다. 앵커를 환산하려면 그 앵커의 fps 격자를 알아야 하는데 미결 7번이 아직 안 닫았습니다
  • 🔴 계기를 네 번 고쳤습니다 — 반복 5회가 결정론이라 표본이 30이 아니라 6이었고 · 밴드가 루브릭마다 달라 한 세트를 두 곳에 못 쓰고 · 정규식이 조사(「3초로」)에 걸려 분자를 0으로 세고 · 「7.5도」의 7을 분모로 셌습니다. 첫 집계가 분모 7 · 분자 0 이라 그대로 믿었으면 「재현 안 됨」으로 닫을 뻔했습니다. 자가검사를 스크립트에 박아 뒀습니다
  • ✅ 사전 등록의 「분모 10 미만이면 판정하지 않고 표본을 늘린다」가 실제로 일했습니다 (첫 분모 3 → 표본 24 → 120문장)
  • 점수: 🔴 한 비트도 안 바뀝니다 — 같은 입력에 옛 문장·새 문장을 넣어 aggregate 를 실측했습니다(92 · A · 등급 여섯 동일). B-6 재실행 없음
  • 검사 34건 추가 (387 → 504 통과) · 확인: cd agent && uv run pytest tests/test_judge_plain_language.py -q
  • 🔴 EC2 는 같은 모델을 vLLM 으로 서빙합니다. 프롬프트가 같으므로 원인은 같지만 비율까지 같다고는 말하지 않습니다

🔴 하지 말 것 (여기서 이미 닫힌 것들)

  • PERSON_ELIGIBLE_THRESHOLD(0.5)를 낮추지 마세요 — selector 동작 기준이라 B-1~B-6 이 전부 무효가 됩니다
  • 품질 게이트 70% 를 낮추지 마세요 (41번) — 못 잰 것에 점수가 붙습니다
  • 미결 18번의 닫힌 경로 9개를 다시 열지 마세요 — 로드맵 4절에 이유가 있습니다. 특히 외양 「재가중」과 거부 시 자동 박스로 떨어뜨리기
  • ㉱ 를 「코칭 문장」으로 확대하지 마세요 — 「더 나은 설명」은 정답이 없어 판정이 안 됩니다. 이번 회차는 코드 심벌·날숫자 유출이라는 판정 가능한 결함만 봅니다
  • 🔴 등급·구간을 문장에 되돌리지 마세요 (23·24번). 등급 맥락은 breakdown[] 의 title·title_earned·band 가 싣습니다

  • 관련: 미결 18번(㉰의 정본) · jin 17번(㉲ 선행) · 36번(루브릭 근거 수준) · 2·34번(임계값 검수) · 23·24번(문장에서 등급 빼기) · 41번(품질 게이트 되풀이) · 로드맵 4절(닫힌 경로)
  • 담당: 정상호(1·2·4·5·6 에이전트 몫) · 백성검(3의 화면 · 2의 표시) · 정어진(3의 내주는 경로 · 5의 적재) · 제기: 정상호(사용자 요청) · 기한: 순서대로, 1·2 는 스프린트 3 안

44. 검출된 사람 목록을 화면에 주는 경로가 없습니다 — 자리를 정해 주세요 (2026-09-11 신설) ✅ 정어진 몫(결정+배선) 끝 (2026.09.15) · ✅ 워커 배선 끝 (2026.09.16) · ✅ 화면은 안 붙이기로 했습니다 (2026.09.17) · ✅ 실물 확인 끝 (2026.09.18) — 종결

  • 위치: pending-archive.markdown의 ## ho (정상호) 구역으로 이동됨

45. 한 영상에 동작이 여럿일 때 — 계약이 「영상 1 : 리포트 1」입니다 (2026-09-11 신설)

43번 ㉲(슛·패스가 같이 나오면 리포트 둘)의 계약 조각입니다. 착수하려고 파이프라인을 열어 보니 막는 것이 셋인데, 그중 둘이 남의 영역입니다.

🔴 막는 것 셋

  지금  
⑴ 임팩트가 하나 _peak_frame 이 argmax 하나를 돌려줍니다. segment_phases 의 impact 도 int 하나 제 몫
⑵ 분석 창이 10초 DEFAULT_MAX_SECONDS = 10.0. 🔴 슛이 2초, 패스가 30초에 있으면 패스는 아예 안 잡힙니다 — 업로드 상한은 60초인데 분석 창은 10초입니다(ho 30번, 박민호 판단 대기) 결정 필요
⑶ 리포트가 1:1 reports/<user_id>/<video_id>/report.json 하나, analysis_job 도 영상당 하나, 완료 보고의 report_key 도 하나 정어진

🔴 그리고 더 어려운 것 — 「무엇인지」는 정답이 필요합니다

㉲ 를 둘로 갈라야 합니다. 난이도가 전혀 다릅니다.

  무엇 정답이 필요한가
㉲-a 같은 동작이 여러 번 (슛 3번 → 리포트 3개) ❌ 불필요. 어느 루브릭인지 이미 압니다
㉲-b 다른 동작이 섞임 (슛 + 패스) 🔴 필요. 「이 이벤트가 슛인가 패스인가」는 분류이고, 정답 라벨 없이 「맞다」고 말할 수 없습니다

㉲-a 부터 합니다. 그리고 🔴 ㉲-a 가 드리블(㉳)도 엽니다 — 드리블이 막힌 이유가 정확히 「반복 동작인데 임팩트가 하나」였습니다.

㉲-b 의 길은 둘입니다: (가) 정답 라벨을 모아 분류기를 검증한다(지도자 검수·라벨 병목, 미결 2·34번) · (나) 🔴 사용자가 표시한다 — 화면에서 「여기가 슛, 여기가 패스」를 찍으면 분류가 아예 필요 없습니다. (나)를 권합니다 — 정답 없이 분류하는 길은 이 저장소에서 여러 번 닫혔습니다.

정해 주셔야 할 것

정어진 님 — 리포트를 어떻게 담습니까

안  
(A) 봉투 안에 배열 report.json 하나에 results: [...]. 🔴 schema_version 2.0(뜻이 바뀝니다) — 적재·읽기·화면이 함께 움직여야 합니다
(B) 파일을 여럿 reports/<user>/<video>/report-1.json … 완료 보고의 report_key 도 여러 개가 됩니다
(C) 작업을 여럿 이벤트마다 analysis_job. 화면 폴링은 그대로인데 한 업로드가 작업 N개가 됩니다

저는 (A) 를 권합니다 — 이벤트들이 같은 영상·같은 대상의 결과라 한 봉투에 있는 것이 뜻에 맞고, report_key 규약(paik 11번)을 안 건드립니다. 🔴 다만 2.0 이라 미리 알려야 하고, 그게 이 항목입니다.

박민호 님 — 분석 창(⑵)이 이것 때문에 급해집니다

ho 30번(업로드 60초 대 분석 10초)이 지금까지는 「길게 올려도 앞 10초만 본다」였는데, ㉲ 가 붙으면 「두 번째 동작이 통째로 사라진다」가 됩니다. 증상이 달라집니다 — 사용자는 슛만 리포트가 나오고 패스는 왜 없는지 모릅니다.

🔴 실측은 이미 있습니다 (eval/pending11_window/): 창을 60초까지 넓혀도 평가셋 39클립 장수가 안 변해 B-6 재실행을 안 부릅니다. 720p 는 천장이 115초, 1080p 는 51초, 4K 는 13초입니다.

🗂 1~4회차 기록 (2026.09.11~09.14) — 이벤트 찾기 4회차와 정정. 펼쳐 봅니다

🔴 1회차 결과 (2026.09.11) — 불합격이고, 그 이유가 값입니다

사전 등록 agent/eval/pending45_touch_events/PREREGISTRATION.md(커밋 1940afb) · 상세 RESULTS.md.

기준 결과  
A 터치 수 중앙값 1~3 0 🔴
B 기존 임팩트와 ±5프레임 일치 ≥50% 25% 🔴

18편 중 4편만 터치를 찾았습니다. 그런데 실패 원인이 제가 만진 손잡이가 아닙니다.

⑴ 문턱은 자연스러운 틈에 정확히 앉았습니다
  클립별 최근접 거리 (어깨너비)
잡힘 4편 0.151 · 0.175 · 0.375 · 0.662
못 잡음 14편 0.862 · 0.904 · … · 2.613 · 4.819

0.662 와 0.862 사이가 비어 있고 사전에 박은 0.8 이 그 안에 있습니다. 🔴 문턱을 돌리는 회차는 열지 않습니다 — 0.9 로 올려도 3편이 더 들어올 뿐이고 1.2 를 넘는 8편은 어떤 값으로도 못 잡습니다.

⑵ 공 검출 탓도 아닙니다

못 잡은 쪽의 평균 공 커버리지가 0.76 으로 잡은 쪽(0.69)보다 높습니다. 공은 잘 추적되는데 추적 대상의 발에서 멉니다.

⑶ 🔴 남는 설명 — 추적 대상이 공 가진 사람이 아닙니다 → 틀렸습니다 (2026.09.14)

최근접이 4.82 어깨너비(≈2m) 인 클립까지 있습니다. 세트피스에서 키커의 발과 공이 2m 떨어질 수 없습니다. 중계 영상이라 「가장 큰 사람」이 골키퍼·수비벽일 수 있습니다.

즉 이 회차는 ㉲-a 가 아니라 미결 18번을 쟀습니다.

🔴 정정 (2026.09.14, 미결 18번 11회차) — 위 문단의 설명이 틀렸습니다. 그 표본을 프레임으로 열어 보니 19/19 가 1인 훈련 영상이고 OpenPose 스켈레톤이 픽셀에 구워져 있습니다. 검출 바닥(0.3)까지 풀어도 사람 후보 수가 중앙 1 · 평균 1.44 입니다.

  • 중계 영상이 아닙니다. 골키퍼·수비벽을 골랐을 수 없습니다 — 다른 사람이 화면에 없습니다. 큰 공-발 거리는 한 사람뿐인데 그 사람이 공에서 떨어져 있는 것이고, 세트피스 준비 자세가 그렇습니다
  • 🔴 「4.82 어깨너비 ≈ 2m」라는 크기도 믿을 값이 아니었습니다. 같은 클립들을 키높이로 다시 재니 순위는 삽니다(ρ 0.835). 그런데 배율이 0.8~7.6(중앙 2.2) 으로 흩어집니다 — 단위 변환이면 상수여야 합니다. 어깨너비는 촬영 방향에 따라 투영 폭이 달라지고(이 표본은 측면), 미결 37번과 같은 뿌리입니다
  • ✅ 이 회차의 판정(A·B 불합격)은 그대로입니다. 흔들리는 것은 왜 그랬는가이고, 그것이 「우리가 딴 사람을 본다」에서 「표본에 사람이 한 명뿐이다」로 바뀝니다

근거: agent/eval/pending18_ball_proximity/RESULTS.md

🔴 계기 검사가 예측과 다른 방식으로 틀렸습니다

사전 등록에 「단일 동작 표본이라 여럿 찾기는 증명 못 한다」고 적었고 그건 맞았습니다. 그런데 실제 실패는 한 칸 앞에서 났습니다 — 터치 검출을 시험해 보지도 못했습니다. 표본은 축구가 맞았고 공도 잘 잡혔는데, 어긋난 것은 파이프라인 앞단이 고른 사람이었습니다. 계기 검사에 「내 앞 단계가 맞게 돌고 있는가」를 한 줄 더 넣습니다.

🔴 미결 18번에 주는 것 — 라벨 없는 새 진단

18번은 10회차 끝에 「대상이 맞는지 말해 줄 독립 근거가 없다」에 닿았고 남은 길이 사람 판독 70장뿐이었습니다. 공-발 거리가 그 근거가 될 수 있습니다 — 「공을 다루는 사람의 발과 공은 가깝다」는 라벨이 아니라 물리이고, 위 분포가 실제로 갈립니다. 열 회차가 전부 IoU·외양이었고 공은 닫힌 경로 목록에 없습니다.

🔴 다만 증명된 것은 아닙니다 — 「멀다」와 「딴 사람이다」는 아직 같은 말이 아닙니다(공이 날아가는 중일 수 있습니다). 18번 11회차에서 사전 등록하고 제대로 잽니다.

🔴 11회차 결과 (2026.09.14) — 판별 불가입니다. 계기는 섰습니다(「박스 하단 중앙↔공」이 「발목↔공」의 대리로 서고 ρ 0.835, ViTPose 없이 후보 전부에 잽니다). 그런데 주 층이 19편에서 1프레임이라 분모가 0편입니다 — 위 정정대로 표본에 사람이 한 명뿐이어서입니다. 공 근접이라는 축 자체는 안 닫혔고, 다음은 처방이 아니라 다인 축구 표본 확보입니다. 정본은 미결 18번.

사후 관찰 (n=4, 판정에 안 씀)

터치가 잡힌 4편에서 기존 임팩트가 언제나 뒤입니다 (+15 · +15 · +2 · +6, 4/4 같은 방향). 🔴 rubric_evidence.yaml 이 event_validity 를 blocked_by: 접촉 프레임 정답 없음 으로 막아 두었는데, 공 최근접은 접촉 프레임의 대리 지표이고 라벨이 필요 없습니다 — 그 막힌 것을 열 실마리일 수 있습니다. n=4 이고 크기가 +2~+15 로 흩어집니다. 이 표로 impact_event 를 바꾸지 않습니다.

🔴 2회차 (2026.09.11) — 공만으로 찾아 보았습니다. A 가 한 클립 차이로 불합격

1회차가 「대상이 딴 사람」에 넘어졌으므로 사람을 아예 안 썼습니다. 터치는 물리적으로 공의 속도가 갑자기 바뀌는 것이라, 공 궤적만으로 잽니다. 사전 등록 agent/eval/pending45_ball_events/PREREGISTRATION.md(커밋 ef6b528).

기준 결과  
A 1개 이상 찾음 ≥80% 78% (14/18) 🔴
B 2개 이하 ≥80% 89% ✅

🔴 78% 를 80% 로 반올림하지 않습니다.

🔴 그런데 B 의 통과가 절반은 공허합니다
공 커버리지 최대가속 (어깨너비/프레임²)
42~77% 0.15 ~ 3.43
89~100% 3.37 · 4.86 · 4.98 · 6.27 · 18.5 · 21.7 · 33.6

어깨너비 0.4m · 24fps 에서 세게 찬 공 20m/s = 2.1 어깨너비/프레임입니다. 즉 가속 2.1 이 「정지 → 풀파워」인데 33.6 이 나왔습니다 — 그 16배, 한 프레임에 13m 어치입니다. 공이 아니라 검출기가 딴 물체로 건너뛴 것입니다.

절대적인 값 셋이 전부 커버리지 100% 클립에서 나왔습니다. 100% 는 「매 프레임 공을 봤다」가 아니라 「매 프레임 무언가를 공이라고 불렀다」입니다.

🔴 커버리지를 성과로 읽고 데인 것이 이번이 세 번째라 로드맵 함정 절에 올렸습니다 (18번 7회차 93%→1% · 37번 3회차 97%→46% · 여기 100%→가장 엉터리).

사후 관찰도 같은 곳을 가리킵니다 — 이벤트 직전 공이 거의 정지한 것이 6/21 뿐입니다. 세트피스는 정지한 공을 찹니다.

🔴 계기 검사 — 이번엔 ①이 틀렸습니다 (1회차는 ②·③)

오염원으로 카메라 팬을 지목해 뒀는데 빗나갔습니다. 실제 오염원은 검출기의 신원 바뀜입니다. 「공」이라 부른 것이 프레임마다 다른 물체일 수 있다는 것을 계기 정의에 안 넣었습니다. 🔴 미결 18번이 사람에 대해 열 회차를 쓴 그 질문인데 공에는 안 물었습니다.

🔴 문턱을 지금 옮기지 않습니다 — 옮기면 통과합니다

0 이벤트 클립 넷의 최대가속이 0.151 · 0.266 · 0.561 · 0.899 라, MIN_ACCEL 을 1.0 → 0.85 로만 낮춰도 A 가 83% 로 통과합니다. 결과를 보고 문턱을 옮기는 것이 정확히 사전 등록이 막으려는 일이라 적어 두고 넘어갑니다.

다음은 (가) 공 궤적에 연속성 걸기 — 한 프레임에 텔레포트하면 미검출로 봅니다(물리 상한이 근거, 새 모델 없음). 🔴 규칙 변경이라 다음 회차에 사전 등록하고 합니다. 공 궤적을 ball_tracks.npz 로 남겨 다음 회차는 GPU 없이 돕니다.

🔴 3회차 (2026.09.11) — 게이트는 섰고, 2회차 숫자가 부풀려졌던 것이 드러났습니다

공 궤적에 속도 상한을 걸었습니다(물리적으로 못 닿는 위치는 미검출로 봄). 사전 등록 agent/eval/pending45_ball_continuity/PREREGISTRATION.md(818179b). 🔴 GPU 를 안 썼습니다 — 2회차 궤적에 규칙만 바꿔 댔습니다.

기준 결과  
G 게이트 OFF 재현 불일치 0/19 ✅
A 1개 이상 ≥80% 67% (2회차 78%) 🔴
B 2개 이하 ≥80% 94% (2회차 89%) ✅
E 남은 가속 <8.0 위반 0건 ✅
F 절반 이상 버린 클립 0편 ✅
게이트는 정확히 제 일을 했습니다

33.631 → 3.229 · 21.719 → 0.047 · 18.487 → 0.011 · 6.272 → 1.008. 평균 커버리지 75% → 73% — 🔴 내려가는 것이 정상입니다(가짜를 버렸으니).

🔴 A 가 내려간 것은 게이트가 망친 것이 아닙니다

두 클립이 이벤트를 잃었는데, 버린 프레임은 8·16% 뿐이고 게이트 후 최대가속이 0.011·0.047 입니다. 즉 그 몇 프레임이 이벤트 전부를 만들고 있었고 진짜 공의 움직임은 없었습니다.

🔴 그래서 2회차의 A 78% 가 부풀려진 값이었습니다. 14편 중 2편이 가짜 였고 진짜는 처음부터 12/18 = 67% 였습니다. 이 회차는 성능을 낮춘 것이 아니라 측정을 정직하게 만들었습니다.

🔴 A·E 짝이 없었으면 「좋아졌다」고 적을 뻔했습니다

사전 등록에 「E 만 서고 A 가 떨어지면 공을 통째로 버린 것」이라고 미리 적어 두었고, 실제로 그 경우가 나왔습니다. F(버린 비율 0편)로 확인해 보니 버려서 잃은 게 아니라 원래 없었습니다. 짝 기준이 없었다면 E 0건 위반과 B 94% 만 보고 성공으로 적었을 것입니다.

지금 서 있는 자리

공만으로는 18편 중 12편(67%) 입니다. 못 찾는 6편은 최대가속이 0.011 · 0.047 · 0.151 · 0.266 · 0.561 · 0.899 — 🔴 문턱을 낮춰도 안 풀립니다(0.899 하나만 아깝고 나머지는 어떤 값으로도 못 잡습니다). 공의 움직임이 실제로 안 보입니다.

🔴 그리고 이 표본은 제품과 다릅니다. 중계 영상이라 공이 작고 멉니다. 사용자는 휴대폰으로 가까이 찍습니다 — 67% 로 제품 판단을 하지 마세요.

🔴 여기서 제 판단만으로는 못 가는 것 둘 — 정해 주세요

⑴ 박민호 님 — 67% 로 ㉲-a 를 열 것인가 (제품 판단)

세 번 중 두 번 찾으면 쓸 만한가입니다. 🔴 제 숫자로 정하지 마세요 — 위에 적었듯 이 67% 는 중계 영상의 값이고 사용자는 휴대폰으로 가까이 찍습니다. 다만 못 찾았을 때 화면이 무엇을 보일지는 지금 정해 두는 것이 좋겠습니다(「동작을 못 찾았습니다」인지, 지금처럼 전체를 한 번 채점인지). 저는 후자를 권합니다 — 못 찾은 것이 곧 실패는 아니고, 41번처럼 재업로드를 부르는 문구는 피하는 게 맞습니다.

⑵ 전체 — 실서버에 쌓인 사용자 영상을 평가에 써도 됩니까

(가)가 가장 값이 큰데 사용자 데이터를 건드립니다. 🔴 제가 임의로 시작하지 않았습니다.

   
쓰려는 것 S3 videos/ 의 실제 업로드로 이벤트 검출 정확도를 재는 것
🔴 저장소에 안 들어갈 것 영상·프레임·파일명(닉네임이 들어 있습니다)·user_id. 남길 것은 집계 숫자뿐
걸리는 것 팀원 업로드만인가 · 외부 사용자가 섞였나 · 약관에 「품질 개선에 쓴다」가 있나

팀원이 올린 것만이라면 지금 해도 된다고 봅니다. 외부 사용자가 섞였으면 약관 확인이 먼저입니다(1번 항목의 상업 오픈 전 확인 셋과 같은 계열).

나머지 후보: (나) 게이트를 production 으로 — 🔴 objects 가 바뀌면 plant_foot_to_ball_offset 입력이 달라져 B-6 재실행입니다. 근거(E·F·G)는 섰으니 옮길 수는 있지만 별도 사전 등록이 필요합니다.

✅ 4회차 (2026.09.14) — 막는 것 ⑴ 을 치웠습니다. 이벤트를 여럿 실어 나릅니다

🔴 앞 셋과 다른 층입니다. 1~3회차는 전부 이벤트를 찾는 쪽이었고, 거기서 더 가는 길 둘은 아래 ⑴·⑵ 결정에 막혀 있습니다. 그런데 이 항목이 적어 둔 「막는 것 셋」 중 ⑴(임팩트가 하나)은 제 몫인데 그대로였습니다 — 찾는 것이 완벽해져도 두 번째 이벤트를 실어 나를 곳이 없습니다.

사전 등록 agent/eval/pending45_multi_event/PREREGISTRATION.md(cf4d241, 구현 전 커밋) · 결과 RESULTS.md. 🔴 GPU 안 씀(캐시 사본을 읽습니다).

기준 내용 결과  
A window=None 에서 extract_features 비트 동일 불일치 0/117 ✅
B window=None 에서 Phases 동일 불일치 0/117 ✅
C 구간을 주면 impact 가 그 안에 위반 0 ✅
D 좁은 구간은 같은 예외 22건 전부 InsufficientQuality ✅
E uv run pytest -q 413 통과 (410 + 3) ✅
F 🔴 새 상수 0개 · rubrics/ 무변경 ✅ ✅
G 🔴 짝 기준 — 반 나누기 예측 92/92 = 100% ✅
무엇을 고쳤나 — 로직 다섯 줄

segment_phases · extract_features 가 구간(window=[a, b))을 받습니다. 구간 밖을 「검출 실패」와 같게 다루는 것이 전부입니다.

🔴 임팩트 탐색·경계 검사·구간 분할을 한 줄도 안 고쳤습니다 — 그것들이 이미 usable 위에서만 돌기 때문입니다. 그래서 window=None 의 비트 동일이 검사로 확인되는 성질이 아니라 구조적으로 보장됩니다(A 가 0/117 인 이유이고, B-6 재실행을 안 부릅니다).

🔴 argmax 를 「상위 N개」로 바꾸지 않았습니다. 그러면 「몇 개인가」와 「어디인가」가 한 함수에 섞이고 개수·최소 간격 같은 새 상수가 붙습니다. 지금은 바깥이 구간을 정하고 안은 하나씩 받습니다.

🔴 G(짝 기준)가 이 회차의 값입니다 — A 는 아무것도 안 해도 만점입니다

A·B 는 「안 바뀌었다」를 재므로 코드를 한 줄도 안 고치면 100% 입니다. 그래서 결과를 보기 전에 예측을 적어 두었습니다 — 유효 구간을 반으로 자르면 전체 argmax 는 둘 중 정확히 한 구간에 있고, 다른 구간은 다른 프레임을 낸다. 92/92 로 예측대로입니다. 하나라도 「두 구간이 같은 프레임」이면 창이 실제로 안 걸린 것이고, A 가 만점이어도 불합격이었습니다.

🔴 D 의 22건이 다음 자리를 가리킵니다

경계 검사에 걸린 창이 22개인데, 두 창이 다 성립한 것이 92쌍입니다. 클립을 반으로만 잘랐는데도 그렇습니다 — 🔴 이벤트가 촘촘하면 구간이 짧아지고, 짧아지면 구간 분할 자체가 성립하지 않습니다.

㉳(드리블)이 정확히 그런 동작입니다. 반복 터치 사이가 반 클립일 리 없습니다. 이 회차가 ㉳ 를 연 것은 맞지만 「열렸다」가 「된다」는 아닙니다. 간격이 짧을 때 무엇을 할지는 다음 회차의 질문이고 지금 추측으로 답하지 않습니다(2 를 낮추면 그 순간 새 상수라 F 불합격입니다).

🔴 그래서 「㉲-a 가 됐다」고 적지 않습니다

된 것은 실어 나르는 칸입니다. 남은 둘이 둘 다 남의 결정입니다.

남은 것 어디
칸에 넣을 것을 찾기 공만으로 67%. 아래 ⑴ 박민호 님 판단
칸을 밖으로 내보내기 schema_version 2.0. 정어진 님 A/B/C
검사 셋을 코드에 붙들어 두었습니다 (410 → 413)

test_no_window_is_the_whole_clip(깨지면 B-6 재실행) · test_a_window_cannot_move_the_impact_outside_itself(창이 안 걸리면 리포트 N개가 전부 같은 프레임인데 실패가 조용합니다) · test_an_empty_window_is_refused_not_guessed(빈 구간에 아무 프레임이나 내는 것 — 미결 21번과 같은 형태).

🔴 한계 — 표본이 야구입니다

39클립은 Kinetics hitting baseball 전수라 제품 표본이 아닙니다(축구 클립은 캐시가 없어 GPU 가 필요합니다). 재는 성질이 「같은 입력에 같은 출력」 과 「창이 걸리는가」라 종목과 무관해서 회차는 성립하지만, 🔴 이 표본으로 검출 성능이나 등급을 말하지 않습니다 — 결과 문서에 그런 숫자는 없습니다.

✅ 5회차 (2026.09.18) — 판별불가. 4회차가 적은 「짧아서 안 된다」는 안 섰습니다

4회차가 이름을 붙여 둔 질문(「간격이 짧을 때 무엇을 할지」)을 열었습니다. 사전 등록 agent/eval/pending45_window_floor/PREREGISTRATION.md(b28e076c, 돌리기 전 커밋) · 결과 RESULTS.md. 🔴 조사 회차라 src/·rubrics/ 무변경(0줄) · 테스트 578 통과 · GPU 불필요(캐시 사본, 39초).

🔶 지금 상태 (2026.09.18): 창이 짧아서 실패하는 것도, 창에 이벤트가 없어서 실패하는 것도 둘 다 확증 못 했습니다. 다만 후자는 이 표본에서 지지받지 못했습니다 — 경계 검사는 「이 창에 이벤트가 있는가」를 거의 안 봅니다. 🔴 처방을 안 골랐고 2 를 한 번도 안 건드렸습니다.

무엇을 물었나: 4회차의 22건은 반 클립(50~150프레임)에서 났는데 경계 검사가 요구하는 것은 5프레임입니다 — 길이일 수가 없습니다. 그래서 길이와 자리를 갈랐습니다. 클립의 유효 구간을 길이 L 로 타일링하고(L = 60·40·30·20·15·12·10·8·6·5), 알려진 이벤트를 담은 타일(층 E) 과 나머지 (층 N) 를 견줬습니다. 타일 15,710개(분모는 117 중 68 — 알려진 이벤트가 없으면 층을 못 나눠 49건을 사전 등록대로 뺐습니다).

  결과  
층 E 대 층 N 차이가 L 전체에서 −1.8%p ~ +7.5%p 🔴 겹칩니다
층 N 실패 중 boundary 93.8% (9,024/9,621) ✅
층 N 이 산수 (L-4)/L 보다 얼마나 낮은가 최대 18.7%p (문턱 20%p) 🔴

🔴 문턱을 안 옮겼습니다. 1.3%p 모자란다고 20 을 18 로 내리면 그 순간 이 표본이 훈련셋이 되고 합격이 뜻을 잃습니다(52번 1회차의 「하지 말 것」과 같은 형태). 판별불가로 적는 것이 사전 등록이 정한 답입니다.

🔴 이 회차의 알맹이 — 제가 「산수」라고 적은 것이 사실은 가설이었습니다

사전 등록에 “층 E 성립률 ≈ (L-4)/L … 이것은 예측이 아니라 산수다. 여기서 벗어나면 계기가 고장 난 것이지 발견이 아니다” 라고 못 박았습니다. 틀렸습니다. 그 식은 「창 안 argmax 의 자리가 균등하다」를 몰래 전제합니다. 균등성은 산수가 아니라 신호의 성질이고, 재 보니 균등하지 않습니다.

🔴 8·10회차와 같은 자리에서 또 미끄러졌고, 형태가 세 번째입니다 — 8회차는 계기가 무엇을 재는지(길이 오염), 10회차는 누구를 재는지(현상의 2.6%), 이번엔 계기 검사의 기준선 자체가 검증 안 된 모형이었습니다. 앞으로 「이건 산수다」라고 적을 때 전제가 몇 개 들어갔는지 셉니다.

사후 관찰 (판정 밖 — 위 판정을 고쳐 쓰지 않습니다)
  • 가장자리 쏠림은 실재합니다 — argmax 가 창 가장자리 2프레임에 앉는 비율이 산수 4/L 의 최대 2.4배(z 최대 +12.9). 🔴 그런데 층 E 와 층 N 의 배율이 같습니다 — 창에 진짜 이벤트가 들어 있어도 똑같이 쏠립니다
  • 🔴 ㉳(드리블)에 뜻하는 것: ㉳ 가 가정한 터치 간격 10~15프레임에서 층 E 성립률이 50.0%·58.2% 입니다 — 진짜 터치를 담은 창의 절반 가까이가 버려지고, 이유가 「이벤트가 없어서」가 아니라 창 경계가 이벤트 옆에 우연히 그어져서입니다
  • 🔴 그래서 처방의 방향이 바뀔 수 있습니다 — 2 를 낮추는 것이 아니라 바깥이 창을 넓게 잡는 것(여백)이고, 그러면 파이프라인에 새 상수가 안 붙습니다. 사후 읽기이고 다음 회차의 질문입니다 — 지금 처방으로 적지 않습니다
🔴 이 회차가 말하지 않는 것

드리블이 된다/안 된다(표본에 반복 터치가 0편입니다 — 층 N 은 「이벤트가 없는 구간」이지 「두 번째 이벤트가 있는 구간」이 아닙니다) · 축구 성능(표본이 야구 39편입니다) · 2 가 옳은 값인가(값을 바꿔 보지 않았습니다).

다음 회차의 질문: 바깥이 이벤트를 정확히 모를 때 — 검출한 이벤트가 δ 프레임 어긋나 있으면 얼마나 버티는가. δ 의 실제 분포는 1~3회차의 이벤트 검출(공-발 거리)이 쥐고 있고, 그 둘을 붙이는 것이 다음 회차입니다.

✅ 6회차 (2026.09.18) — 기준은 전부 통과, 그런데 값은 사후에 있습니다

5회차가 남긴 질문(「바깥이 δ 만큼 어긋났을 때 얼마나 버티는가」)을 열었습니다. 사전 등록 agent/eval/pending45_margin/PREREGISTRATION.md(1446d5c3, 돌리기 전 커밋) · 결과 RESULTS.md. 🔴 조사 회차라 src/·rubrics/ 무변경(0줄) · 새 상수 0개(바깥 규칙은 1회차에서 import) · 테스트 578 통과 · GPU 불필요(1.6초).

🔶 지금 상태 (2026.09.18): 🔴 이 회차의 C(지시성 통과)는 아래 7회차가 뒤집었습니다. 인용하실 때 7회차를 함께 봐 주십시오 — 여기 ✅ 로 남아 있는 것은 사전 등록 판정을 고쳐 쓰지 않는다는 규칙 때문입니다. 여백의 크기를 처방으로 못 냅니다.

🔴 표본을 축구로 바꿨습니다 — 52번 2회차가 뜬 포즈 캐시(instep_shot 23 + inside_pass 15, 키포인트와 공 궤적이 함께)를 읽었습니다. 🔴 52번을 다시 여는 것이 아니라(그 항목은 보류입니다) 이미 뜬 자산만 읽은 것입니다.

  결과  
관문 통과 17 / 38 — 🔴 ②다리 가시성에서 14편(52번 1회차의 10편과 같은 현상) · ③1 · ④2 · ⑤4  
\|δ\| 중앙 10.0 · 평균 82.6 · 범위 0~906  
무작위 대조 중앙 14.0 ✅ C 통과
실효 여백 손실(P3) 5.9% (예측 ≥20%) 🔴 예측이 틀렸고 방향이 좋습니다
🔴 사후가 합격 기준의 통계를 무너뜨립니다 — 계기가 미끄러진 네 번째 형태

클립 단위로 「실제가 무작위보다 가까운가」를 세면 9/17 입니다. 집계 중앙값이 이긴 것은 δ 가 작아서가 아니라 무작위 대조군 중앙값이 클립마다 5~322 로 흩어지기 때문입니다.

층 n \|δ\| 중앙 무작위 중앙 클립 단위로 이김
후보 ≤5 6 13.5 95.0 4/6
후보 ≥10 9 7.0 11.0 4/9

후보가 적으면 δ 가 오히려 큰데 무작위가 훨씬 커서 이깁니다. 즉 C 의 승리는 「바깥이 잘 가리킨다」가 아니라 「후보가 적으면 무작위가 불리하다」 에서 옵니다.

🔴 8회차(무엇을 재는가) · 10회차(누구를 재는가) · 5회차(계기 검사의 기준선이 가설이었다)에 이어 네 번째입니다 — 이번은 요약 통계가 비교 불가능한 것을 묶었습니다. 배운 것: 🔴 대조군을 클립마다 따로 만들었으면 비교도 클립마다 해야 합니다. 🔴 사전 등록 판정은 고쳐 쓰지 않고 그대로 뒀습니다.

사후 — 그 밖에
  • 파국 두 클립: wnpIwUpws7g δ=+906(30.2초) · QbbsTcPS2to δ=−325(10.8초). 🔴 바깥과 안쪽이 다른 사건을 보고 있고, 여백으로 덮는 길이 없습니다
  • 🔴 e 는 정답이 아닙니다 — 안쪽(신전 각속도 argmax)의 자기 답이라 δ 가 크면 바깥이 틀린 건지 안쪽이 틀린 건지 이 회차는 못 가릅니다. 그 자리가 52번 2회차의 diagnose_impact.py 인데 보류입니다
  • production 의 check_quality 가 유효 프레임 0인 클립에서 numpy Mean of empty slice 를 뿜고 그 뒤에 예외를 올립니다. 결과는 안 바뀌고, 조사 회차라 안 고쳤습니다 — 적어만 둡니다
🔴 이 회차가 말하지 않는 것

「여백을 이만큼 주면 된다」(사후 ⑴ 때문입니다) · 드리블(반복 터치 0편) · 제품 성능(캐시가 40초로 떠 있고 제품 창은 10초) · 바깥과 안쪽 중 누가 틀렸나.

🔴 7회차 (2026.09.18) — 불합격. 그리고 6회차의 지시성을 뒤집습니다

6회차의 계기를 고치는 회차였습니다. 🔴 새 측정이 없습니다 — 6회차 원자료를 올바른 단위(클립별 분위)로 다시 요약한 것이 전부입니다(0.8초). 사전 등록 agent/eval/pending45_pointing/PREREGISTRATION.md(dd0b043a, 돌리기 전 커밋) · 결과 RESULTS.md. src/·rubrics/ 0줄 · 새 판정 상수 0개 · 테스트 578 통과.

🔶 지금 상태 (2026.09.18): 🔴 「공-발 거리 극소점이 신전 각속도 임팩트를 가리킨다」가 이 표본에서 안 섭니다. 그래서 여백 처방을 닫습니다 — 가리키는 것 자체가 안 서면 「여백을 얼마나 줄까」는 답할 수 없는 질문입니다. 🔴 「안 섰다」는 「없다」가 아닙니다(n=17 에 바가 2.1 표준오차).

  결과  
A 가짜 바깥의 평균 분위 (교정) 0.499 (합격 0.5±0.10) ✅
B 6회차 원자료 그대로 불일치 0 (sha256:c8f7691255db9386) ✅
C 실제 바깥의 평균 분위 0.422 (합격선 ≤0.35) 🔴
D 분위 ≤0.10 인 클립 4편 (귀무 기댓값 1.7) 보고

🔴 6회차는 C 를 통과했었습니다(|δ| 중앙 10.0 < 무작위 14.0). 그 통계가 클립마다 다른 분포를 묶은 것이었고, 같은 자료를 클립 단위로 보니 안 섭니다. 🔴 6회차의 사전 등록 판정은 고쳐 쓰지 않았습니다 — 규칙대로 ✅ 로 두고, 여기가 그것을 뒤집은 기록입니다.

✅ 계기가 이번엔 제 일을 했습니다

가짜 바깥(터치 후보를 같은 개수의 무작위 프레임으로 갈아 끼운 것)의 평균 분위가 0.499 입니다 — 이 통계가 귀무 아래에서 정확히 0.5 로 교정돼 있습니다. 🔴 이 확인이 없었으면 0.422 를 「0.5보다 작으니 가리킨다」로 읽을 수 있었습니다. 분위를 P(null ≤ |δ|) 로 잡았으면 이산 동률 때문에 귀무 평균이 0.5에서 밀렸을 것이고, 그 밀림과 신호를 못 갈랐습니다. mid-p 보정과 그 교정 확인을 사전 등록에 미리 적어 둔 것이 값을 했습니다.

사후 (판정 밖)
  • 후보가 적은 클립은 양극단입니다 — 후보 ≤5 는 6편 중 3편이 분위 0.1 미만, 2편이 0.9 초과로 가운데가 비었습니다. 맞히거나 완전히 딴 데를 봅니다. 🔴 6회차 사후를 일부 정정합니다 — 거기서 「C 의 승리는 후보가 적으면 무작위가 불리해서」라고 적었는데, 교정된 통계로도 후보 ≤5 가 더 낮습니다(0.386 대 0.437). 순전한 착시는 아니었고, 다만 그 층이 양극단이라 평균으로 말할 것이 아니었습니다(분모 6편이라 비율도 안 냅니다)
  • 파국 두 클립은 자기 귀무보다도 나쁩니다 — 분위 0.979 · 0.961. 우연히 빗나간 것이 아니라 체계적으로 다른 자리를 봅니다
  • 잘 맞힌 4편은 δ 가 0~8프레임입니다. 🔴 문제는 정밀도가 아니라 「맞히느냐 못 맞히느냐」이고, 여백으로 덮는 길이 이 그림과 안 맞습니다
🔴 남는 갈래 둘 — 둘 다 정답이 있어야 갈립니다
  갈래 지금
(가) 바깥이 틀렸다 — 공-발 극소점이 킥 아닌 것을 집는다 1회차가 「공만으로 67%」에서 멈춰 있고, 규칙을 고치려면 정답이 필요합니다
(나) 안쪽이 틀렸다 — e(신전 각속도 argmax)가 진짜 킥이 아니다 🔴 52번 2회차의 diagnose_impact.py 가 정확히 그 자리인데 52번이 보류입니다

이 회차는 둘을 못 가릅니다 — e 를 정답으로 쓸 수 없어서입니다. 가르려면 사람이 「이 프레임이 킥인가」를 봐야 하고, 그게 미결 2번(라벨링 주체)입니다.

(경과) 제가 하는 것 — 정답이 필요 없는 조각부터

「한 클립에서 이벤트를 여럿 찾을 수 있는가」를 먼저 잽니다. 🔴 각속도 피크가 아니라 공-발 거리의 극소점으로 찾을 생각입니다 — 터치는 「공이 발에 닿는 것」이라 물리적 정의가 있고, 각속도 피크는 그 대리 지표일 뿐입니다. 공 궤적은 ㉮ 에서 살아 있는 것을 확인했습니다. 사전 등록하고 시작합니다.

  • 하지 말 것: 🔴 정답 없이 「슛인지 패스인지」를 분류하고 「맞다」고 적기 · 🔴 분석 창을 결정 전에 바꾸기(사용자에게 보이는 값입니다)
  • 관련: 같은 구역 43번(여섯 기능 · ㉲·㉳) · 30번(분석 창 — 이것 때문에 급해집니다) · jin 17번(클립의 동작을 담을 자리) · jin 27번(봉투 계약) · paik 11번(report_key)
  • 담당: 정어진(리포트 담는 방식 A/B/C) · 박민호(분석 창 — 30번) · 정상호(이벤트 찾기 · 봉투) · 제기: 정상호(사용자 요청) · 기한: 스프린트 3

46. 다인 축구 영상이 없습니다 — 「여러 명 중 고르기」를 축구에서 잰 적이 없습니다 (2026-09-14 신설)

제품은 2026.09.11에 축구 단일 종목이 됐습니다(39번). 그런데 분석 대상 선택(누구를 채점할 것인가)을 축구에서 잰 적이 한 번도 없습니다.

우리가 가진 것 무엇
phaseA 골든셋 39편 전부 야구(Kinetics hitting baseball 전수). 다인 10건이 있지만 야구입니다
축구 19편 🔴 19/19 가 1인 훈련 영상이고 OpenPose 스켈레톤이 픽셀에 구워져 있습니다. 검출 바닥(0.3)까지 풀어도 사람 후보 수가 중앙 1 · 평균 1.44, 2명 이상인 프레임이 24% 입니다

그래서 미결 18번 11회차(2026.09.14)가 분모 0편으로 끝났습니다 — 계기는 섰는데(ρ 0.835) 표본에 현상이 없었습니다. 상세는 그 항목과 agent/eval/pending18_ball_proximity/RESULTS.md.

필요한 것 — 성질로 씁니다 (특정 데이터셋을 지목하는 것이 아닙니다)

  1. 한 프레임에 사람이 2명 이상 잡히는 프레임이 클립당 5프레임 이상
  2. 공이 누군가의 발치에 오는 구간이 있을 것 (드리블·패스 주고받기)
  3. 🔴 스켈레톤·박스·자막이 안 그려진 원본일 것 — 주석이 픽셀에 들어간 자료는 검출·포즈를 오염시키고, 무엇보다 제품 입력이 그렇지 않습니다
  4. 휴대폰 촬영이면 더 좋습니다 — 실사용자 업로드가 그것입니다
  5. 분량은 20편이면 회차가 섭니다(11회차 계기가 클립당 5초로 돕니다)

🔴 정답(라벨)은 필요 없습니다. 「공을 다루는 사람의 발과 공은 가깝다」는 물리라서, 영상만 있으면 라벨 없이 잽니다.

🔴 갱신 (2026.09.14 같은 날) — 디스크부터 뒤져 봤습니다. 길이 하나 나왔습니다

항목을 올리고 나서 가진 자료를 먼저 확인 안 한 것이 걸려 훑었습니다. 🔴 「없다」고 적기 전에 했어야 할 일입니다.

후보 결과
3DSP (AutoSoccerPose, 축구 슛 200클립) 🔴 안 됩니다 — 영상이 없고 100×100 크롭 .jpg 뿐입니다. 이미 한 사람으로 잘려 있어 원리적으로 다인이 될 수 없습니다. 재조사하지 마세요
/mnt/d/sports_dataset/ 의 baseball·basketball 클립 0편(수집 어댑터는 39번에서 지웠습니다). 축구는 지금 쓰는 19편뿐
✅ SoccerNet 방송 클립 어댑터가 이미 있습니다 — scripts/dataset_pipeline/sources.py 의 SoccerNet10s. SushantGautam/SoccerNet-10s-5Class, 10초 클립 34,050건(그중 Shots 5,456건). 목록 조회는 익명으로 됩니다(실제로 받아 세어 봤습니다)

방송 클립은 여러 선수가 한 화면에 있습니다 — 파이프라인 README 가 그것을 단점으로 적어 두었는데(“이벤트 클립이다 — 방송 화면, 여러 선수, 카메라 전환”), 이 항목에는 그것이 바로 요구 조건입니다.

🔴 막힌 것은 딱 하나 — 허깅페이스 로그인입니다
huggingface_hub.errors.GatedRepoError: 401 — Access to dataset
SushantGautam/SoccerNet-10s-5Class is restricted. You must ... be authenticated.

목록은 열려 있고 파일만 게이트입니다. 저장소 페이지에서 약관에 동의하고 huggingface-cli login 을 하면 열립니다 — 사람이 해야 하는 2분짜리 일이고 제가 대신 못 합니다.

🔴 「받으면 된다」가 아닙니다 — 받아도 관문을 통과해야 합니다

224p 입니다. 지금 19편은 사람 박스 높이가 중앙 335px(144~587)인데, 방송 224p 에서 선수는 그보다 훨씬 작습니다. 검출기가 score ≥ 0.5 로 2명을 실제로 집는지는 받아서 재 봐야 압니다.

그래서 관문을 도구로 만들어 두었습니다 — agent/eval/sample_gate/. 받는 즉시 한 줄로 5분이면 답이 납니다.

cd agent && uv run python eval/sample_gate/sample_gate.py --sample soccernet

🔴 바(다인률 10%)는 SoccerNet 숫자를 보기 전에 정했습니다. 결과를 보고 내리면 관문이 아무것도 안 막습니다.

관문을 두 표본에 실제로 돌렸습니다 (2026.09.14)
표본 프레임 다인률 공 검출률 발치+다인 관문
축구 19편 (1인 훈련 영상) 1,297 8% 69% 0.1% (1f · 0클립) ❌
야구 39편 (phaseA, 양성 대조군) 10,705 66% 23% 0.4% (44f · 1클립) ✅

🔴 대조군이 세 가지를 한꺼번에 확인해 줍니다.

  1. 관문이 고장 난 게 아닙니다 — 66% 대 8% 로 여덟 배 갈립니다. 대조 없이 「8% 라 표본이 나쁘다」고 적으면 10회차와 같은 형태의 결함입니다
  2. 축구 8% 는 18번 11회차 값을 그대로 재현합니다(101/1297 · 주 층 1프레임)
  3. 공 검출의 종목 비대칭도 재현됩니다 — 야구 23% 대 축구 69% (gate_raw.json 의 「야구 39편 중 12편(31%)」과 같은 방향)

✅ 그리고 논증이던 것이 측정이 됐습니다. 45번 1회차가 “야구는 공이 발치에 없다” 고 원리로 적고 재지는 않았는데, 발치+다인 층이 야구에서 0.4%(39편 중 1편) 입니다. 🔴 공을 23%나 보면서도 그렇습니다 — 공이 안 잡혀서가 아니라 발치에 없어서입니다. 「야구로 대신하자」가 숫자로 닫혔습니다.

✅ 받았습니다 — 관문 통과 (2026.09.14 저녁, 정상호)

정상호 님(jsangho) 계정으로 약관 동의 + hf auth login 을 하셔서 열렸습니다. Shots 앞에서 40편을 받아 관문을 돌렸습니다(23MB).

표본 프레임 다인률 공 검출률 발치+다인 사람 키 중앙 관문
축구 19편 (1인 훈련) 1,297 8% 69% 0.1% (1f · 0클립) 335px ❌
야구 39편 (대조군) 10,705 66% 23% 0.4% (44f · 1클립) 187px ✅
SoccerNet 방송 40편 7,982 98% 15% 2.5% (198f · 9클립) 🔴 28px ✅

주 층이 1프레임 · 0클립에서 198프레임 · 9클립이 됐습니다. 18번 12회차를 열 수 있습니다.

🔴 읽는 법을 좁혀 적습니다 — 「통과」가 「다 된다」가 아닙니다
   
✅ 관중은 안 잡힙니다 프레임에 박스를 그려 눈으로 확인했습니다. 검출된 것은 전부 필드 위 선수이고 관중석은 하나도 0.5 를 못 넘습니다. 「다인 98%가 관중 아니냐」는 의심은 배제됐습니다
🔴 사람이 28px 입니다 다른 두 표본과 자릿수가 다릅니다. 물을 수 있는 것은 「어느 박스를 고르는가」까지이고, 키포인트가 필요한 것(관절각·등급)은 못 묻습니다. 11회차 계기는 박스만 쓰므로 그 질문에는 섭니다
🔴 방송 중계입니다 제품 입력(휴대폰)이 아닙니다. 선택 규칙이 깨지는 것을 보이는 데는 쓰되 제품 성능을 말하지 않습니다
공 검출률 15% 224p 에서 공이 몇 픽셀입니다. 그래도 주 층은 9클립에서 섭니다
🔴 눈으로 본 것 하나 — 실패가 보입니다

한 프레임에서 공은 골문 앞인데 우리가 고른 사람(_largest_person_box)은 화면 반대쪽 아래였습니다. 원근이 강한 방송 구도라 「가장 큰 사람」이 곧 「카메라에 가장 가까운 사람」이 됩니다. 11회차 사후 관찰의 「불일치 시 고른 사람 키 ÷ 최근접 후보 키 = 6.77」과 같은 기전입니다.

🔴 이건 관찰이지 판정이 아닙니다. 12회차에서 사전 등록하고 셉니다.

저작권 — 제가 한 것과 안 한 것
  • 한 것: 계정 약관에 동의하고 로컬로 받아 재기만 했습니다(23MB, /mnt/d)
  • 🔴 안 한 것: 저장소에 안 넣습니다. 영상도 프레임 그림도 넣지 않고 측정 결과(JSON·수치)만 커밋합니다 — 3DSP 와 같은 취급입니다
  • 남은 판단: 이 성격의 자료를 평가 근거로 계속 쓰는 것이 괜찮은지는 여전히 박민호 님 몫입니다. 위 「왜 여쭙는가」 그대로입니다

왜 여쭙는가 — 제 선에서 못 정하는 것이 있습니다

   
저작권·이용 조건 🔴 구체화됐습니다 (2026.09.14) — 이제 「어디서 구하나」가 아니라 「SoccerNet 방송 클립의 약관에 동의하고 평가에 써도 되는가」입니다. 방송 화면이고 연구용 배포라 저장소에 커밋은 안 합니다(3DSP 때와 같은 취급 — 측정 결과만 남깁니다). 판단은 박민호 님, paik 28번과 같은 축입니다
허깅페이스 로그인 🔴 사람이 해야 합니다. 계정으로 약관 동의 + huggingface-cli login. 누구 계정으로 할지도 함께 정해 주시면 좋겠습니다
실사용자 영상을 쓸 수 있는가 업로드가 쌓이면 가장 좋은 표본입니다. 다만 동의·보관 범위가 제품 결정이고, 18번이 적어 둔 「드래그 한 번이 subject 라벨 하나」와 한 문제입니다
촬영으로 만들 수 있는가 팀이 공 주고받는 20편이면 충분합니다. 그게 가장 빠를 수 있습니다

지금 막히는 것 / 안 막히는 것

  • 🔴 막힙니다: ㉰(가림 추적 = 18번) 12회차 · ㉯(사람 목록)의 정확도를 축구에서 말하는 것
  • ✅ 안 막힙니다: 지금 서비스가 도는 것 · ㉮ 종목 게이트 · ㉱ 문구 · ㉲ 4회차의 window. 오늘 고장 나는 것은 없습니다

  • 하지 말 것: 🔴 표본이 없다고 PERSON_ELIGIBLE_THRESHOLD(0.5)를 만지기 (43번 「하지 말 것」) · 🔴 야구로 대신하기 — 다인률은 66% 로 충분한데 발치+다인이 0.4%(39편 중 1편) 입니다. 공을 23%나 보면서도 그렇습니다 — 공이 발치에 없어서이고, 그건 축구의 물리입니다. 측정으로 닫혔습니다
  • 관련: 같은 구역 18번(정본 · 11회차) · 43번 ㉰·㉯ · 19번(공개 데이터셋이 안 맞는다) · 2번(라벨링 주체) · paik 28번(영상 기준)
  • 담당: 박민호(저작권·실사용자 영상 판단) · 정상호(표본 조건·측정) ✅ 표본 확보·관문 통과 (2026.09.14) · 제기: 정상호 · 기한: 스프린트 3 → 18번 12회차의 막힘은 풀렸습니다. 남은 것은 박민호 님의 저작권 판단이고 그것이 12회차를 막지는 않습니다(로컬 측정이고 저장소에 안 넣습니다)

47. 🔴 B-6 기준선이 지금 코드로 재현되지 않습니다 (2026-09-15 신설) ✅ 원인 규명 (2026.09.16) — torchvision 이 빠져 전처리기가 갈렸습니다

43번 ㉳ 3회차에서 B-6 을 재실행하다 발견했습니다. 고치기 전 코드로 돌린 결과가 저장소에 든 B-6 산출(2026-09-08 판)과 다릅니다.

   
행 수 305 → 290. 3클립 × 5 selector = 15행. 이건 설명됩니다 — 39번 축구 단일 종목 전환으로 야구·농구 루브릭을 지웠고 Track 2 는 루브릭이 없으면 건너뜁니다
🔴 겹친 290행 85행이 다릅니다. 전부 Track 2(17클립 × 5)
Track 1 ✅ 195행 전부 일치
어긋난 열 17개 — impact_frame(10행) · grade(10행, B→D 포함) · swing_knee_angle_at_impact(30행, 151.0→47.1 같은 큰 차이) · 🔴 임팩트 정의와 무관해야 할 detected_frames·usable_ratio_arm·usable_ratio_leg

🔴 eval_b6/RERUN.md 가 그 여섯 열에 대해 「여기서 어긋나면 임팩트 변경이 아니라 환경이 변한 것」이라고 적어 두었습니다. 같은 문서의 「불일치가 나오면 — 캐시가 아니라 기준선 감사다」가 이 상황입니다: B-2~B-6 이 서로 다른 포즈 위에서 산출됐을 수 있고, 그 결론들이 함께 흔들립니다.

🗂 1~3회차 기록 (2026.09.15) — 코드·입력·GPU 를 차례로 배제한 경위. 펼쳐 봅니다

원인이 아닌 것은 좁혀 뒀습니다 (측정으로)

배제 근거
패키지·GPU 환경 torch·cuDNN·transformers·opencv·numpy·TF32 설정까지 RERUN.md 의 2026-09-01 표와 같습니다
모델 가중치 (N-1) HF 캐시 스냅숏이 각 하나뿐이고 8월 25일자입니다 — 갈아탈 것이 없었습니다
비결정성 (N-2·N-3) 🔴 같은 코드로 두 번 돌린 290행이 의도한 두 열 말고 전부 비트 동일입니다. 이 환경에서 B-6 은 실행 간 재현됩니다
읽은 프레임 수 frames 열이 한 행도 안 바뀌었습니다
루브릭 정의 09-08 이후 축구 루브릭 변경은 항목 이름뿐입니다
품질 게이트 check_quality 는 추출 리팩터만 됐고 규칙이 같습니다

남은 것은 2026-09-10~14 의 코드 변경이고, 검출 경로가 가장 유력합니다.

🔴 정정 · ✅ 1회차 (2026.09.15) — 코드가 아닙니다. 갈림점이 없습니다

사전 등록 agent/eval/pending47_baseline_audit/PREREGISTRATION.md(259007c, 돌리기 전 커밋) · 결과 RESULTS.md(a52c018). 🔴 조사 회차라 src/· rubrics/·보존 자산 무변경(0줄) · 테스트 415.

이분 탐색을 시작하기도 전에 끝났습니다 — 양 끝점을 재 보니 good 쪽이 없었습니다.

실행한 커밋 기준선과
4626870 (09-02 · 그 CSV 를 낸 커밋 자체) 🔴 85/95행 다름
426de4d (09-08) 85/95행
HEAD (09-15) 85/95행

🔴 그리고 재실행끼리는 불일치 0 입니다 (40열 × 95행, 두 쌍 모두). 2026-09-02 이후 어떤 커밋도 Track 2 값을 바꾸지 않았습니다.

제가 위에 적은 「남은 것은 코드 변경」과 사전 등록한 예측 둘(P-1 read_frames 쪽 · P-2 3b05436)이 전부 틀렸습니다.

기준선의 출처도 틀렸습니다 — md5 가 답했습니다. Track 2 CSV 는 09-08 갱신에서 한 바이트도 안 바뀌었고 실제로는 2026-09-02 산출입니다 (7e41a201… 이 4626870·426de4d 두 커밋에서 같음). 그래서 탐색 구간을 4626870..426de4d 로 옮겼는데 그 good 끝도 bad 였습니다.

추가로 배제한 것: 의존성(uv.lock 09-02 이후 무변경) · 드라이버(560.94, 기록과 동일) · N-2 OOM 배치 폴백(🔴 Track 2 에서는 구조적으로 불가능 — _pose_batch 에 박스가 1~3개인데 MAX_BATCH 는 24 라 쪼갤 일이 없습니다).

남은 후보는 둘이고 전부 버전 관리 밖입니다: ⑴ 입력 클립이 그때와 다르다 (agent/data/ 는 .gitignore 이고 체크섬 기록이 없습니다. mtime 은 2021년이라 아무것도 안 알려 줍니다) ⑵ 그 CSV 가 이 기계·이 상태의 산출이 아니다(실행 메타를 파일로 안 남기기로 되어 있어 확인 불가).

🔴 이것이 「왜 Track 2 만인가」에 답합니다 — Track 1 의 입력은 /mnt/d 클립

  • 저장소에 든 후보 npz 라 고정돼 있고, Track 2 의 입력만 버전 관리 밖입니다.

✅ 남긴 것: input_fingerprints.csv — 평가 입력 61개(Track 2 22 · Track 1 39)의 md5·크기. 🔴 이게 없어서 오늘 ⑴ 을 못 갈랐습니다. 과거를 복원해 주지는 않고 오늘부터의 기준선입니다.

남은 길 셋 (고르지 않았습니다): (가) 09-02 실행이 쓴 클립을 복원할 길이 있는지 · (나) 기준선을 오늘 산출로 다시 세우되 지문·환경·커밋을 함께 박기 · (다) 실행 메타를 파일로 남기도록 규칙을 바꾸기(지금 규칙이 ⑵ 를 못 가르게 만들었습니다). 🔴 (나)를 (가)보다 먼저 하지 않습니다 — 덮으면 영영 못 가릅니다.

✅ (다) 했습니다 (2026.09.15) — 규칙을 바꿨습니다

selector_downstream.py 에 적혀 있던 「실행 메타는 파일로 남기지 않는다 (승인된 산출물 목록에 없음). 보고서에 적는다」를 바꿨습니다. 🔴 「보고서에 적는다」는 사람이 적어야 남는다는 뜻이고, 그날 아무도 안 적었습니다.

이제 매 실행이 eval_b6/run_meta.json 을 함께 냅니다 (커밋 17345c3).

담는 것 답해 주는 질문
inputs — 읽은 영상 61개의 md5·크기 🔴 「그때와 같은 파일로 돌렸나」 — ⑴ 을 못 가른 이유가 이것이었습니다
git — 커밋·브랜치·dirty 🔴 dirty 가 참이면 그 산출은 어느 커밋의 것도 아닙니다 — ⑵ 가 이것이었습니다
env · models 환경(N-3) · 가중치 리비전(N-1)
batching — oom_events·min_batch 🔴 「배치 폴백이 일어났나」(N-2) — RERUN.md 가 「확인할 방법이 현재 없다」고 적어 둔 것을 닫았습니다
constants · outputs 동작점(미결 10번) · 이 메타가 이 산출의 것인지

확인: 기능을 넣고 전체 실행을 한 번 돌렸고(424초) CSV 두 개가 오늘 run#2 와 비트 동일입니다 — 계측이 산출을 안 건드렸습니다. 덤으로 이 환경에서 B-6 이 세 번 연속 재현됐습니다. 첫 메타: dirty=false · oom_events 0 · min_batch 24.

🔴 과거는 복원해 주지 않습니다. 옛 산출에는 이 파일이 없고, 2026-09-15 이전 입력 지문은 기록이 없습니다(47번 1회차가 남긴 input_fingerprints.csv 가 그날 시점의 스냅숏일 뿐입니다).

RERUN.md 에 대조 절차 0단계 — 메타부터 본다를 넣었고, N-2 항목의 「확인할 방법이 없다」를 정정했습니다. 남은 것은 (가)·(나) 이고 순서는 그대로입니다.

✅ (가) 했습니다 (2026.09.15, 2회차) — 입력이 아닙니다. 창이 옮겨졌습니다

사전 등록 PREREGISTRATION_2.md(2052a7c, 찾기 전 커밋) · 결과 RESULTS_2.md(463b666). 🔴 조사 회차 — src/·rubrics/ 무변경 · GPU 안 씀.

찾던 것(09-02 에 쓰던 클립)은 없었습니다.

   
A 사본 /mnt/d/sports_dataset/soccer/clips/batch_0000 에 2026-09-04 바이트 복사본 19개. 오늘 것과 md5 19/19 일치. 🔴 /mnt/d·/home/ho 를 크기로 전수 검색해도 사본은 이 둘뿐
B 재취득 🔴 길이 없습니다 — video_source.csv 가 보여 주듯 제3자가 유튜브를 온라인 커터로 잘라 만든 트림본입니다. 같은 바이트를 다시 만들 수 없습니다
C 간접 09-02~09-04 창에 축구 클립을 담은 산출물이 하나도 없습니다(전수 검색)

🔴 그런데 다른 기록이 답을 줬습니다 — 미결 21번 회차(2026-09-08)가 B-6 전 구간을 다시 돌려 Track 2 완전 일치를 확인해 뒀습니다(Track1 330초 / Track2 142초 · before_rubric_clips.csv md5 가 커밋된 CSV 와 같은 파일). 그날은 재현됐습니다.

창  
09-02 ~ 09-04 닫혔습니다 — 그 뒤 09-08 에 재현됐으므로
09-08 ~ 09-15 🔴 여기서 깨졌습니다. 이 창에서 입력은 md5 로 동일(09-04부터)하고 uv.lock·드라이버 무변경, 코드는 세 시점이 서로 같은 값을 냅니다

→ ⑴ 입력 가설은 사실상 배제됩니다.

🔴 사전 등록 규칙에 결함이 있었습니다 — 「A 또는 B 로 md5 일치 → ⑴ 배제」에 증거의 시점을 안 붙였습니다. 찾은 사본은 09-04 이고 문제의 실행은 09-02 라 문자 그대로 적용하면 틀린 말이 됩니다. ✅ 같은 문서의 「예상」에는 그 한계를 적어 뒀고(예상이 규칙보다 정확했습니다), ⑴ 을 실제로 배제한 것은 사본 대조가 아니라 09-08 재현 기록입니다. 그대로 적습니다 — 지난 회차의 멈춤 규칙과 같은 종류의 실수(무엇이 무엇을 보장하는지 안 쓰고 규칙만 썼습니다)라 다음부터 증거에 시점을 붙입니다.

남은 자리는 「실행 시점의 기계 상태」입니다. 가장 그럴듯한 후보는 다른 프로세스의 GPU 점유(이 기계는 vLLM 도 띄웁니다. 여유 VRAM 이 다르면 커널 선택이 달라질 수 있고 검출 문턱 0.5 의 절벽이 미세차를 박스 하나로 증폭합니다) — 🔴 아직 가설입니다(cudnn.benchmark=False 라 자동탐색은 꺼져 있습니다).

✅ run_meta.json 에 gpu_free_bytes_at_start 를 더했습니다 — 이 회차가 그 칸이 없다는 것을 알려 줬습니다(과거는 여전히 기록 없음).

다음: (가-2) VRAM 을 일부러 점유한 채 Track 2 만 돌려(116초) 값이 움직이는지 — 움직이면 원인 규명, 안 움직이면 후보가 닫힙니다. 🔴 (나) 기준선 재선언보다 먼저입니다 — 원인을 모른 채 덮으면 같은 일이 또 나도 못 알아봅니다.

✅ (가-2) 했습니다 (2026.09.15, 3회차) — GPU 점유도 아니고, 검출도 아닙니다

사전 등록 PREREGISTRATION_3.md(cc104b2, 돌리기 전 커밋) · 결과 RESULTS_3.md(5383a7f). 🔴 조사 회차 — src/·rubrics/ 무변경.

수준 시작 시 GPU 사용(nvidia-smi) Track 2 산출
L0 (깨끗) 약 900 MiB / 8,192 기준
L1 (4 GiB 점유) 5,699 MiB 불일치 0
L2 (6 GiB 점유) 7,751 MiB (여유 441 MiB) 불일치 0

예측대로 안 바뀝니다 → 이 후보는 닫힙니다. 여유 441 MiB 에서도 배치 폴백조차 없었습니다(oom_events 0) — WSL2 는 GPU 메모리를 초과 배정해서 메모리 압력이 커널 선택을 흔들 자리가 애초에 좁습니다.

🔴 계기 결함이 또 하나 있었습니다 — 사전 등록이 고른 자 (torch.cuda.mem_get_info)가 WSL2 에서 남의 점유를 못 봅니다(4 GiB 가 잡혀 있는데 「여유 6.5 GiB」). B·C 를 보기 전에 nvidia-smi 로 고치고 다시 돌렸습니다. ✅ 덤으로 어제 (다) 에 넣은 gpu_free_bytes_at_start 가 이 환경에서 무용지물이라는 것을 잡아 gpu_used_at_start_smi 를 함께 싣도록 고쳤습니다.

🔴 사후 관찰이 더 컸습니다 — 09-08 로그가 CSV 와 독립된 기록이었습니다

pending21_hip_rotation/rerun_stdout.log 에 그날 실행의 클립별 한 줄이 남아 있었습니다.

대조 (B_pose 기준, 축구 19클립)  
09-08 로그 == 09-02 CSV 19/19
09-08 로그 == 오늘 17/19

✅ 2회차 결론(09-08 에는 재현됐다)이 CSV 하나가 아니라 독립 기록으로 굳었습니다.

🔴 그리고 자리를 좁혔습니다 — 같은 로그의 multi_candidate_frames 와 selected_target_difference 가 세 시점 전부 같습니다. 검출도 selector 도 안 움직였습니다. 움직인 것은 포즈 이후입니다. 「검출 경로가 가장 유력하다」던 1회차 추정을 정정합니다.

남은 가설은 .avi 디코딩입니다 — Track 1 은 .mp4(Phase A), 갈린 Track 2 19편은 전부 .avi 입니다. 픽셀이 미세하게 달라지면 검출 박스는 튼튼해서 그대로인데 포즈는 흔들립니다 — 관찰된 모양 그대로입니다. 🔴 다만 그날 디코딩한 픽셀의 기록이 없습니다. 잡으려면 run_meta.json 에 디코딩 지문(클립별 첫·중간 프레임 md5)을 넣어야 하고, 그러면 다음엔 한 번에 갈립니다.

지금까지 배제된 것: 입력 파일 · 코드 · 의존성·드라이버·가중치 · 실행 간 비결정성 · 여유 VRAM · 검출·selector. 🔴 「원인을 모른다」가 오늘의 정직한 답이고, 모르는 범위가 세 번 연속 좁아졌습니다.

🔴 B-2~B-5 무효 여부는 이 회차로 단정하지 않습니다. 다만 Track 1 은 195행 전부 일치하고 selector 결론은 그쪽 자산 위에 서 있습니다. 흔들리는 것은 Track 2(축구 19클립) 이므로, 다음 질문은 「무효인가」가 아니라 「Track 2 결론 중 무엇이 이 85행에 기대고 있는가」입니다.

   
만족해야 할 성질 어느 커밋이 Track 2 를 갈랐는지 특정되고, 그것이 ⑴ 의도한 개선인지 ⑵ 모르고 낸 회귀인지 판정될 것. ⑵ 면 고치고, ⑴ 이면 B-2~B-5 중 어느 결론을 다시 봐야 하는지를 적을 것
확인 후보 커밋에서 Track 2 만 한 번씩 돌려 대조(116초/회). 갈라지는 지점이 바로 나옵니다
🔴 하지 말 것 CSV 를 다시 덮어쓰고 「맞췄다」고 하지 않기 — 값이 왜 달라졌는지가 답이지 최신화가 답이 아닙니다 · 🔴 eval_b2/eval_b2.py 의 selector 가중치를 건드리지 않기(B-2~B-5 전체 무효)

✅ 원인 규명 (2026.09.16, 4·5회차) — 커밋이 아니라 torchvision 이었습니다

🔴 위 「만족해야 할 성질」의 전제가 틀렸습니다 — 갈린 것은 커밋이 아닙니다. 세 시점의 코드가 서로 같은 값을 냈다는 1회차 결과가 이미 그렇게 말하고 있었는데, 저는 계속 버전 관리 안에서 찾고 있었습니다.

4회차(사전 등록 PREREGISTRATION_4.md aed3f45 · 결과 RESULTS_4.md): 「기록되지 않은 무엇」이 site-packages 디렉터리 mtime 으로 남아 있었습니다. 마지막 재현(09-08 11:16)과 깨짐(09-15) 사이, 09-08 17:48 에 extra 없이 uv sync 가 한 번 돌아 16개가 제거됐고 그중 torchvision 이 있었습니다.

5회차(사전 등록 PREREGISTRATION_5.md cae501a · 결과 RESULTS_5.md): 되살려서 Track 2 를 다시 돌렸습니다.

  Track 2 산출 09-08 기준선과
S0 — 지금 그대로(torchvision 없음) 127.3초 🔴 85행 다름 (= 09-15 산출과 비트 동일, 네 번째 재현)
S1 — torchvision 복원 117.8초 ✅ 95행 × 40열 불일치 0

기전: transformers 5.x 는 torchvision 이 있느냐로 이미지 전처리기를 다르게 고릅니다(RTDetrImageProcessorPil ↔ RTDetrImageProcessor). 리사이즈가 갈리면 픽셀이 달라지고, selector 가 고르는 사람은 안 바뀌는데(짝 기준 확인: multi_candidate_frames·selected_target_difference 불일치 0) 관절각이 흔들립니다. 등급까지 갈렸던 두 클립도 기준선 값으로 돌아왔습니다(D→B·D→C).

🔴 torchvision 은 pyproject.toml 에 적혀 있지도 않습니다 — AGPL 이라 서비스에서 뺀 ultralytics(tracking extra)가 끌고 오던 것이라, 아무도 의존성으로 인식하지 않은 채 판정 결과가 거기 매달려 있었습니다.

  • 🔴 uv.lock 무변경은 「같은 환경」이 아닙니다. 잠금 파일은 깔 수 있는 것이고 결과를 정하는 것은 깔려 있는 것입니다 — 1회차가 「의존성 무변경」을 배제 근거로 쓴 것이 이것을 못 잡은 이유입니다
  • ✅ 계기를 심었습니다: run_meta.json 에 env.torchvision · env.transformers_sees_torchvision · packages(설치된 것 전부, 105개·3KB). 🔴 3회차가 예고한 「디코딩 지문」은 안 넣었습니다 — 이번 답이 디코딩이 아니었고(frames 불일치 0), 무엇이 중요한지는 사고 뒤에 알게 된다가 이 항목의 교훈이라 고르지 않고 전부 적는 쪽을 택했습니다. track2() 는 한 줄도 안 바뀌어 산출 불변이 구조로 보장됩니다
  • (나) 기준선 재선언은 아직 안 합니다 — 아래 49번(둘 중 무엇으로 통일할지)이 정해진 뒤입니다. 덮어쓰는 것은 그 다음입니다
  • B-2~B-5 무효 여부: 🔴 무효가 아닙니다. 그 자산들은 같은 전처리 경로(torchvision) 에서 산출됐고, 오늘 그 경로가 비트 단위로 재현됩니다
  • 확인: cd agent && uv run python eval/pending47_baseline_audit/verdict.py eval/pending47_baseline_audit/rerun_S1_torchvision_track2.csv → 불일치 0 · good

✅ 적는 대신 막습니다 (2026-09-18) — 그리고 이 기계가 또 S0 였습니다

5회차는 환경을 run_meta.json 에 적게 만들었습니다. 🔴 적는 것은 사고를 막지 못합니다 — 틀린 환경에서도 그대로 돌아 숫자가 나오고, 그 숫자는 맞는 숫자와 생김새가 같습니다.

  • 🔴 오늘 보니 torchvision 이 다시 빠져 있었습니다 — 5회차에서 되살린 것이 그 사이 또 없어졌습니다. 오늘 평가를 돌렸다면 비교할 수 없는 숫자가 나왔을 것이고, 알아챌 방법은 없었습니다. uv sync --all-extras 로 복구
  • 가드: eval/phaseA/env_guard.py — 기준선 환경(torchvision 있는 쪽, 근거는 5회차 S1 의 95행×40열 불일치 0)과 다르면 B-6 이 돌기 전에 멈추고 고치는 법을 찍습니다. 일부러 다른 환경에서 재는 비교 회차는 SUPERSUB_ALLOW_ENV_DRIFT=1 로 진행합니다
  • 🔴 track2() 는 한 줄도 안 바꿨습니다 — 산출 불변이 구조로 보장됩니다
  • 검사: tests/test_eval_env_guard.py 6건(578 통과). 이 기계에 torchvision 이 있는지는 묻지 않습니다 — 그러면 제품 설치에서 늘 깨집니다. 무엇이 맞는 환경인가는 49번이 정합니다
  • 🔴 49번 쪽에 실물 확인을 올렸습니다 (2026-09-18): EC2 워커 venv 는 torchvision 없음 · RTDetrImageProcessorPil · transformers 5.16.1 (평가 기계는 5.15.1). 버전이 갈린 이유는 agent/uv.lock 이 .gitignore 에 있어서입니다 — 잠금 파일이 공유되지 않아 기계마다 따로 굳었습니다

  • 측정·배제 근거: agent/eval/pending43_leak_fix/RESULTS.md 5절 · compare_e1.out(대조 출력 전체) · 정정한 eval_b6/RERUN.md
  • 관련: 같은 구역 43번 ㉳(발견한 자리) · 11번(재실행 자산 의존) · 10번(동작점이 이름에 없어 섞여 쓰이던 것) · 49번(어느 경로로 통일할지)
  • 🔴 지금 새는 것은 없습니다 — 서비스 경로가 아니라 평가 기준선의 문제입니다. 다만 제안서 3장의 검증 수치가 여기서 나옵니다(34번).
  • 담당: 정상호 · 제기: 정상호 · 기한: 스프린트 3 (43번 ㉳ (나)·(다)보다 먼저)

48. 워커 폴링을 45초 → 5초로 내렸습니다 — claim 요청이 9배입니다 (알림) (2026-09-15 신설)

판단해 주실 것이 있는 항목은 아니고, 트래픽이 바뀐다는 알림입니다. 되돌리는 것은 환경변수 한 줄입니다(SUPERSUB_POLL_SECONDS).

분석 한 편의 시간 내역을 재다가 나온 것입니다. 분석 자체가 1분대인데 그 앞에 폴링 주기의 절반이 그대로 붙어 있었습니다 — 45초면 평균 22.5초를 아무 일도 없이 씁니다. 5초로 내리면 평균 2.5초가 됩니다.

   
바뀌는 것 워커 한 대가 POST /internal/analysis-jobs/claim 을 45초마다 → 5초마다 부릅니다. 하루 약 1,900건 → 약 17,000건
빈 큐일 때 204 를 받고 로그도 남기지 않습니다 — 워커 저널은 안 늘어납니다
자동 종료 영향 없습니다. autostop.sh 는 폴링 주기가 아니라 분석 자식 프로세스를 봅니다
부담이 되면 값만 올려 주시면 됩니다. 다만 올린 만큼이 사용자 대기 시간에 붙습니다 — tests/test_worker.py::test_the_default_poll_interval_is_not_the_users_waiting_room 에 그 이유를 적어 뒀습니다

비용은 쟀습니다 (2026-09-15) — 5초로 유지하기로 했습니다

「AWS 요금이 많이 나오는 것 아니냐」는 물음에 EC2 에서 실제 바이트를 쟀습니다. 🔴 claim 은 부르지 않았습니다 — 조회가 아니라 소비라서, 같은 호스트의 무해한 경로로 전송량만 쟀습니다.

  • 1회당 송신 약 2,664 B (TLS 핸드셰이크 포함). 수신은 AWS 가 과금하지 않습니다
  • 5초 → 1.40 GB/월 · 45초 → 0.16 GB/월 · 차이 $0.16/월 ($0.126/GB, 24시간 가동 가정)
  • 아웃바운드 무료 한도 월 100GB 라 실제 청구서에는 묻힐 값입니다
  • 폴링이 건드리는 AWS 항목은 아웃바운드 전송 하나뿐입니다 — 백엔드가 우리 k3s 라 요청당 과금이 없고, NAT 도 안 씁니다(퍼블릭 서브넷 + IGW, agent/deploy/README.md)

결정: 5초 유지 (2026-09-15, 정상호). g4dn.xlarge 15분 요금과 맞바꿔 사용자 대기를 평균 20초 줄이는 거래라 그대로 둡니다. 나중에 워커를 여러 대로 늘리거나 백엔드가 요청당 과금되는 앞단(API Gateway 등)으로 옮겨가면 성격이 바뀌니 그때 다시 봅니다.

바이트가 문제가 되면 주기를 늦추는 것보다 나은 수단이 있습니다 — worker.py 가 urllib 로 매번 새 TLS 연결을 열어서, 송신 2,664 B 의 대부분이 핸드셰이크입니다. 연결을 재사용하면 주기는 5초 그대로 두고 바이트만 5~10분의 1이 됩니다.

  • 근거 수치: 개발 로그 「2분의 내역」
  • 담당: 정어진(빈 claim 이 백엔드에 부하로 느껴지면 알려 주세요 — 비용 쪽은 위에서 닫혔습니다) · 제기: 정상호 · 기한: 급하지 않음 (지금 깨지는 것은 없습니다)

49. 🔴 평가와 제품이 다른 전처리를 씁니다 — 같은 영상이 다른 등급을 받습니다 (2026-09-16 신설)

47번을 규명하다 나왔습니다. 판단이 필요한 것은 「둘 중 무엇으로 통일할까」 하나이고, 나머지는 제가 합니다.

torchvision 이 설치돼 있으면 transformers 가 다른 이미지 전처리기를 고릅니다. 그런데 그것이 pyproject.toml 에 없고 tracking extra 의 ultralytics(AGPL)가 끌고 오던 것이라, 설치 방법에 따라 갈립니다.

  무엇으로 설치 전처리 경로
B-1~B-6 평가 자산 (제안서 3장 수치의 출처) tracking 포함 torchvision
EC2 서비스 (deploy/deploy.sh) uv sync --extra aws Pil

같은 영상에 두 경로가 다른 값을 냅니다 — 축구 19편 중 2편이 등급 문자까지 갈렸습니다(B↔D · C↔D, 47번 5회차 실측). 🔴 어느 쪽이 더 정확한지는 모릅니다 — 정답이 없어서 못 재고, 그것은 지도자 검수(2번·34번)의 영역입니다. 지금 문제는 정확도가 아니라 「제품과 평가가 같은 것을 재고 있지 않다」입니다.

   
만족해야 할 성질 평가와 서비스가 같은 전처리 경로로 돌 것. 그리고 그 선택이 우연이 아니라 선언으로 정해질 것 — 지금은 「누가 어떤 extra 로 sync 했나」가 정합니다
제 제안 torchvision 을 pyproject.toml 의 기본 의존성으로 박습니다. ⑴ 평가 자산 전부가 그 경로에서 났고 ⑵ BSD-3 라 AGPL 문제가 없습니다(ultralytics 와 별개 패키지입니다) ⑶ 그러면 extra 없이 uv sync 해도 안 빠집니다
🔴 그런데 그 결정은 EC2 서비스의 출력이 바뀐다는 뜻입니다 — 이미 나간 분석 결과와 새 결과가 달라집니다. 그래서 제 판단만으로 안 하고 올립니다
대안 반대로 평가를 Pil 경로로 옮기는 길도 있습니다. 그러면 B-1~B-6 을 다시 돌려야 하고(Track 1·2 합쳐 약 430초), 그 위에 선 결론들을 다시 봐야 합니다
확인 정해진 뒤: cd agent && uv run python -c "from transformers.utils.import_utils import is_torchvision_available as t; print(t())" 가 평가 기계와 EC2 에서 같은 값일 것
🔴 하지 말 것 tracking extra 를 서비스에 넣는 것으로 해결하지 않기 — ultralytics 가 AGPL 이라 네트워크 서비스에 링크하면 소스 공개 의무가 생깁니다(pyproject.toml 의 그 주석이 그래서 있습니다)

✅ 결정을 기다리는 동안 「드러내기」를 넣었습니다 (2026.09.17) — 고친 것이 아닙니다

🔴 위 질문은 그대로 열려 있습니다. 둘 중 무엇으로 통일할지는 여전히 박민호 님 판단이고, 아래는 그 결정과 무관하게 지금 되는 것만 한 것입니다.

무엇을 했나: 리포트 봉투가 무엇으로 쟀는지 스스로 말하게 했습니다.

"preprocessing": { "detector": "RTDetrImageProcessor",
                   "pose": "VitPoseImageProcessor", "torchvision": true }
   
왜 이것부터인가 47번이 원인 규명에 닷새를 쓴 이유가 「어느 쪽으로 돈 결과인지가 산출에 안 남아 있어서」였습니다. 결정이 언제 나든, 다시 갈리는 날 바로 보이는 것이 먼저입니다
🔴 설치 여부가 아니라 고른 결과 is_torchvision_available() 만 적으면 그건 고르는 데 쓰인 입력이지 고른 결과가 아닙니다. 업스트림이 고르는 규칙을 바꾸면 같은 값이 다른 전처리기를 뜻하게 됩니다. 건네받은 객체의 실제 클래스 이름을 읽습니다(preprocessing_identity) — 검사가 이것을 강제합니다
함께 적는 것 torchvision(설치 여부)도 나란히 둡니다. 둘이 어긋나는 날이 오면 그게 알아야 할 사건입니다
점수 🔴 한 비트도 안 바뀝니다 — features 에 한 키도 안 더한 형제 블록이라 B-6 재실행 없음(timebase·view_dependent 와 같은 성질). 검사가 묶어 뒀습니다
실측 이 평가 기계: RTDetrImageProcessor · torchvision: true. EC2 는 RTDetrImageProcessorPil 이 나와야 맞습니다 — 그 차이가 47번이 찾던 바로 그것입니다
검사 tests/test_pose.py 3건 · tests/test_report_contract.py 3건 (549 → 555 통과)

🔴 정어진 님 — 계약이 늘었습니다: schema_version 1.5 → 1.6, 봉투에 preprocessing 한 칸(객체, 안 쓴 경로는 null). 필드 추가라 minor 이고 모르는 키를 무시하면 적재는 안 깨집니다 — 받으실지는 판단하셔도 됩니다. 🔴 백성검 님: 화면에 할 일 없습니다(표시용 값이 아닙니다).

  • 이제 위 「확인」이 EC2 에서도 됩니다 — 리포트를 열어 preprocessing 을 보면 됩니다. 전에는 인스턴스에 들어가 파이썬을 띄워야 알 수 있었습니다

✅ 실물로 확인했습니다 (2026-09-18) — 추정이 아니라 사실입니다. 그리고 축이 하나 더 있습니다

사용자가 인스턴스를 켜 주셔서 워커 venv 에서 직접 봤습니다.

  평가 기계 EC2 서비스
torchvision 0.23.0 🔴 없음
실제로 고른 전처리기 RTDetrImageProcessor 🔴 RTDetrImageProcessorPil
transformers 5.15.1 🔴 5.16.1

앞의 두 줄은 이 항목이 예측한 그대로입니다. 🔴 세 번째 줄은 몰랐던 것입니다 — 같은 브랜치·같은 저장소인데 버전이 다릅니다.

원인: agent/uv.lock 이 .gitignore 에 들어 있습니다(.gitignore:22, 에이전트 첫 커밋부터 · 이유는 어디에도 안 적혀 있습니다). 잠금 파일이 공유되지 않으니 기계마다 따로 해석하고, EC2 는 5.16.1 로, 평가 기계는 5.15.1 로 굳었습니다. 47번이 「uv.lock 무변경은 같은 환경이 아니다」를 배웠는데, 그보다 앞에 「uv.lock 이 아예 같은 파일이 아니다」가 있었습니다.

  • 🔴 전처리기가 무엇으로 갈리는지는 transformers 가 정합니다. 지금은 torchvision 유무로 고르는데, 그 규칙 자체가 버전 사이에서 바뀔 수 있습니다 — 두 축이 동시에 어긋나 있는 상태입니다
  • 제안(위 제안에 더합니다): agent/uv.lock 을 저장소에 넣습니다. 그래야 「같은 커밋 = 같은 버전」이 성립하고, 지금 같은 표류가 조용히 안 생깁니다. 🔴 이것도 EC2 출력이 바뀝니다(5.16.1 → 5.15.1) — 그래서 위 전처리 결정과 같이 판단해 주십시오. 제 손으로 먼저 하지 않습니다
  • 확인한 명령(인스턴스 안, 워커가 실제로 쓰는 venv): .venv/bin/python -c "from transformers import AutoProcessor; print(type(AutoProcessor.from_pretrained(<검출 모델>)).__name__)"

  • 상세: agent/eval/pending47_baseline_audit/RESULTS_5.md 4절 · 계기는 agent/contracts/report_schema.yaml 의 preprocessing (변경 이력 1.6)
  • 관련: 같은 구역 47번(여기서 나왔습니다) · 34번(제안서 검증 수치가 이 경로에서 납니다) · 1번(상업 오픈 전 라이선스 정리)
  • 🔴 지금 당장 새는 것은 없습니다 — 서비스는 돌고 있고, 값이 평가와 다를 뿐입니다. 다만 제안서에 적는 수치가 제품의 수치가 아니게 됩니다
  • 담당: 박민호(제품 출력이 바뀌는 것에 대한 판단 — 아직 답 대기입니다) · 정어진(정해지면 배포 절차 — deploy/deploy.sh 의 sync 한 줄 · 새로 늘어난 preprocessing 칸을 받을지) · 정상호(의존성 선언·재실행 — 결정 나면 pyproject.toml 한 줄) · 제기: 정상호 · 기한: 스프린트 3 — 🔴 (나) 기준선 재선언이 이것에 막혀 있습니다

50. 추천 카드의 설명 칸 — 불릿은 분석이, 한 줄 소개는 사람이 (2026-09-16 신설)

추천 판(SquadSuggest.tsx)의 후보 카드에 이름 아래 한 줄 + 불릿 둘이 있는데 전부 붙박이 문자열이었습니다. 그 자리를 봉투가 채웁니다.

"card": {
  "title": "끝까지 뻗은 다리",
  "notes": ["차는 다리를 끝까지 뻗습니다", "디딤발을 공 옆에 붙입니다"]
}

(불릿 문장은 2026.09.16에 코드가 짓던 틀에서 루브릭에 적힌 등급별 문장으로 바뀌었습니다 — 아래 절. 앞서 이 예시에 적었던 「차는 다리 뻗기가 이번 동작의 강점입니다」류가 그 틀이었습니다.)

   
어디 result.card (schema_version 1.4 → 1.5, 필드 추가라 minor — 모르는 키를 무시하면 적재는 안 깨집니다)
title 🔴 받은 칭호일 때만. 못 받았으면 null 이고 화면은 그 줄을 비우면 됩니다 — 0등급의 「무너지는 축」이 이름 아래 자랑처럼 걸리는 것을 막는 자리입니다(paik 23번)
notes 한 줄 또는 두 줄. 🔴 두 줄을 채우려고 지어내지 않습니다
겹침 수식어는 칭호, 불릿은 항목 이름 — 화면이 나란히 그려도 같은 말이 두 번 안 나옵니다
점수 🔴 한 비트도 안 바뀝니다 (summary 와 같은 성질) — B-6 재실행 없음
검사 tests/test_summary.py 7건 추가 (504 → 522 통과)

🔴 제가 만들 수 없는 줄이 있습니다 — 여기가 판단이 필요한 자리입니다

지금 붙박이에 섞여 있는 문구를 갈라 보면:

붙박이 문구 출처
「시야가 넓은」 · 「탈압박이 좋은」 🔴 아무 데도 없습니다 — 경기 중 판단·기술이라 한 편의 자세 분석으로는 못 잽니다
「반대편 빈 공간을 자주 찾습니다」 · 「수비 가담이 성실합니다」 🔴 경기 행동 — 같은 이유로 못 잽니다
「10경기 연속」 · 「활동량이 많고 꾸준합니다」 경기·출전 기록(백엔드)
「차는 다리 뻗기가 이번 동작의 강점입니다」 ✅ 분석 — 이번에 낸 것

에이전트가 채울 수 있는 것은 마지막 줄뿐입니다. 나머지를 제가 지어내면 측정한 적 없는 것을 측정한 것처럼 내보내는 것이라 하지 않았습니다.

  • 백성검 님: 카드가 result.card 를 쓰도록 배선해 주세요. 🔴 경기 기록 줄을 함께 쓰실 거면 화면에서 출처를 구분해 주세요 — 섞어 한 목록에 두면 어디까지가 측정인지 사라집니다. title 이 null 인 경우(칭호 미수여)도 정상 경로입니다
  • 정어진 님: 적재에 이 칸이 필요하면 알려 주세요. 안 받아도 안 깨집니다 → ✅ notes 를 받았습니다 (2026.09.17) — analysis_report.card_notes (마이그레이션 a7d5e0f34c19). 백성검 님이 paik 33번에서 「남도 읽게」를 요청하셔서 적재가 선행이었습니다. 🔴 title 은 안 받았습니다 — 위 결정대로 추천 카드가 안 쓰고, 항목별 칭호는 이미 analysis_metric_criterion.title 에 있어 두 곳에 같은 값이 생깁니다. 읽는 자리는 GET /teams/{id}/squad/candidates 와 GET /cards/{slug}/grade 둘(계약 56번)
  • 박민호 님: 「시야가 넓은」류를 제품에 계속 둘지가 판단입니다. 두려면 출처가 있어야 하고, 지금은 없습니다

  • 상세: agent/report-contract.md 의 「card」 절 · 정본은 agent/contracts/report_schema.yaml
  • 관련: paik 27번(추천 판 계약) · paik 23번(칭호 수여 여부) · 같은 구역 40번(title_earned)

    🔴 결정 (2026.09.16) — 이름 아래 한 줄은 에이전트가 안 채웁니다

화면을 보고 사용자와 함께 정했습니다. 그 자리의 말(「몸싸움이 강한」·「커버가 넓은」)은 사람 전체를 규정하는 유형인데, 저희 칭호는 영상 한 편의 한 항목입니다. 층이 다릅니다 — DF 추천에 「짧고 정확한 스윙」이 걸리면 수비수를 패스 자세로 소개하게 됩니다. 게다가 루브릭이 인스텝 슛·인사이드 패스 둘뿐이라 포지션 유형을 말할 근거가 아예 없습니다.

카드의 줄 누가 채우나
이름 아래 한 줄 (「몸싸움이 강한」) 🔴 선수 프로필 — 사람이 쓴 한 줄 소개. 백엔드에 텍스트 칸 하나가 필요합니다
불릿 두 줄 ✅ 에이전트 (result.card.notes) — 원래 「이 선수가 어떻더라」 층이라 자연스럽게 들어갑니다

그러면 카드 안에서 누가 한 말인지가 갈립니다. 섞으면 팀장은 「몸싸움이 강한」도 AI 판정으로 읽습니다.

🔴 card.title(칭호)은 봉투에 그대로 둡니다 — 따로 만드는 것이 아니라 한 번 만들어 두고 화면이 골라 쓰는 것입니다. 리포트 상세에서는 층이 맞고 이미 쓰는 값입니다(breakdown[].title). 추천 카드만 안 쓰면 됩니다.

🔴 「AI가 붙인 유형」으로 그 줄을 계속 가져가시려면 지금은 못 만듭니다 — 여러 편의 영상 · 포지션별 루브릭 · 경기 기록, 셋 다 없습니다. 하시겠다면 그 셋이 선행입니다.

  • 정어진 님: 프로필에 한 줄 소개 텍스트 칸이 필요합니다 → 🔴 같은 칸을 paik 36번이 먼저·더 자세히 부탁하고 있습니다. 그쪽이 정본입니다 — 아래 대조를 보십시오. 여기서 또 부탁하면 칸이 둘 생깁니다
  • 백성검 님: 카드는 card.notes 만 쓰시면 됩니다. 한 줄 소개는 프로필에서

🔴 이 결정은 paik 36번과 같은 곳에 도착했습니다 (2026.09.16 대조)

같은 날 백성검 님이 「사람이 직접 적는 호칭」으로 방향을 뒤집었고(paik 36번, 근거: “참이든 거짓이든 경기 후 리뷰로 남으니 상관없다” — 신뢰는 호칭이 아니라 리뷰가 떠받친다), 화면도 이미 그렇게 붙었습니다: 후보의 card_public_slug 로 titles[0].label 을 읽어 「시야가 넓은」 자리에 그리고, mock 인 FLAVOR.title 은 폴백으로 내려갔습니다.

두 항목이 독립적으로 같은 결론에 닿았습니다 — 그 줄은 사람이 쓴 글이 채운다.

   
그 칸을 만드는 요청 🔴 paik 36번이 정본입니다(담당 정어진). 길이 상한·「하지 말 것」·확인 명령까지 적혀 있고 화면도 그쪽으로 붙었습니다. 위 제 요청은 접습니다
함께 닫힌 것 paik 32번(분석이 낸 칭호를 user_title 로 부여)이 ⛔ 로 닫히며 거기 있던 정상호(판정 기준 확인) 몫도 없어졌습니다
🔴 남은 주의 이제 한 카드 안에 사람이 적은 호칭 + 에이전트가 낸 불릿이 나란히 놓입니다. 위에 적은 「출처를 화면에서 구분해 주세요」가 가정이 아니라 지금 상황입니다 — 안 가르면 팀장은 사람이 적은 호칭도 AI 판정으로 읽습니다

🔴 아쉬운 항목은 추천 카드에 안 싣습니다 (2026.09.16 결정, 사용자 판단)

앞서 이 항목에 적은 예시(notes 에 강점 한 줄 · 아쉬운 점 한 줄)를 정정합니다. 지금은 가장 잘한 등급의 항목만 최대 두 줄 실립니다.

바꾼 이유는 이 카드가 남이 보는 화면이고 거기서 묻는 것이 「이 선수를 부를까」이기 때문입니다. 사람 이름 옆의 약점 한 줄은 그 판단에 보태기보다 사람을 규정하는 쪽으로 읽힙니다 — 못 받은 칭호를 수식어로 달지 않기로 한 것(paik 23번)과 같은 자리입니다. 본인 리포트에는 그대로 있습니다.

그 선수의 최고 등급 notes title
잘함 2등급 항목 중 가중치 큰 둘 칭호
보통 🔴 비우지 않습니다 — 1등급 항목 중 둘. 「강점」이라 부르지 않습니다 null
아쉬움 0등급 항목 중 둘 null

🔴 빈 카드를 안 내보내는 이유: 화면이 「분석은 됐는데 잘한 게 없다」와 「아직 분석이 없다」를 구분하지 못합니다.

재 보고 정했습니다 (축구 18편, B-6 Track2): 2등급을 하나라도 가진 편 17/18(94%), 최고가 1등급인 편 1편, 0등급이 최고인 편 0편. 편당 2등급 개수 중앙값 2 — 그래서 두 줄을 강점으로만 채울 수 있습니다.

  • 백성검 님: 봉투 모양은 그대로입니다(notes 한두 줄) — 화면은 손댈 것이 없습니다. 다만 두 줄이 다 강점일 수 있다는 점만 알아 두시면 됩니다
  • 검사 2건 추가: 아쉬운 항목이 카드에 섞이는 것 · 2등급이 없을 때 비거나 「강점」이라 부르는 것. 앞 검사는 예전 동작으로 되돌려 무는지 확인했습니다

한 등급 안에 반대 방향이 있으면 문장도 갈립니다 (2026.09.16)

검수 서식을 보다 찾았습니다. 인사이드 패스 골반 돌리기의 보통 등급은 「덜 돌았다(8~15도)」와 「너무 많이 돌았다(35도 초과)」가 같은 등급인데, 문장이 하나라 「골반 회전이 알맞지 않습니다」로 양쪽을 함께 불렀습니다 — 고칠 방향이 안 보이는 문장입니다. 구간마다 쓸 수 있게 했습니다.

      1:
        - "골반을 덜 열고 찹니다"          # 8~15도
        - "골반을 지나치게 많이 돌립니다"   # 35도 초과
  • 🔴 자리가 어긋나면 반대로 말합니다 — 덜 돈 선수에게 「지나치게 돌린다」고 하는 형태라, 적재에서 막습니다(구간 수와 문장 수가 다르면 RubricError)
  • 🔴 측정값이 없으면 방향을 찍지 않습니다 — 평가·재현 경로처럼 features 없이 부르면 루브릭 문장 대신 방향을 말하지 않는 틀로 떨어집니다
  • 문장 하나로 양쪽을 부르는 것도 그대로 허용합니다. 두 방향을 다르게 부를 말이 늘 있는 것은 아닙니다 — 지금은 이 한 자리만 갈랐습니다
  • 봉투·점수 불변. 검사 4건 추가

나머지 양방향 자리도 전부 갈랐습니다 (2026.09.16, 사용자 판단). 앞서 「여섯」이라 적은 것을 정정합니다 — 일곱이었습니다(인스텝 상체 기울기는 1·0 두 등급 다 해당). 지금은 0곳이 남았습니다.

루브릭 항목 · 등급 갈라진 두 문장
인사이드 패스 차는 다리 뻗기 · 아쉬움 무릎이 덜 펴져 공을 밀지 못합니다 / 패스인데 무릎을 슈팅처럼 끝까지 폅니다
인사이드 패스 상체 기울기 · 아쉬움 상체가 뒤로 젖혀집니다 / 상체를 공 위로 깊이 숙입니다
인사이드 패스 골반 돌리기 · 보통 골반을 덜 열고 찹니다 / 골반을 지나치게 많이 돌립니다
인사이드 패스 차고 난 뒤 마무리 · 보통 찬 뒤 다리가 금방 멈춥니다 / 패스인데 마무리가 슈팅처럼 큽니다
인스텝 슈팅 디딤발 무릎 굽히기 · 보통 디딤발을 너무 깊이 굽힙니다 / 디딤발을 굽히지 않고 뻣뻣하게 섭니다
인스텝 슈팅 차는 다리 뻗기 · 아쉬움 차는 다리를 거의 펴지 못합니다 / 임팩트 전에 다리가 다 펴져 버립니다
인스텝 슈팅 상체 기울기 · 보통 · 아쉬움 상체를 세운 채로 찹니다 / 공 위로 조금 깊이 숙입니다 · 상체가 뒤로 젖혀집니다 / 공 위로 크게 무너집니다

🔴 칭호도 갈랐습니다 (2026.09.16, 사용자 판단) — 앞 문단을 정정합니다

바로 위에 「칭호는 안 갈랐습니다」라고 적었는데, 같은 날 갈랐습니다. 「치우친 상체」가 이제 「젖혀진 상체」 / 「무너진 상체」로 나뉩니다.

기울기  -8.7도 → 0등급 「젖혀진 상체」        기울기 35.0도 → 0등급 「무너진 상체」
기울기   2.1도 → 1등급 「곧추선 상체」        기울기 25.0도 → 1등급 「숙인 상체」

titles 와 card_lines 가 같은 규칙·같은 적재 함수를 씁니다(둘에 같은 규칙을 따로 적으면 한쪽만 고쳐집니다). 갈린 자리는 카드 문장과 같은 일곱 곳.

   
🔴 정어진 님 적재 컬럼 analysis_metric_criterion.title 에 새 문자열이 들어갑니다(값만 바뀌고 컬럼·타입은 그대로). 길이 제한만 확인해 주세요 — 가장 긴 것이 「뻣뻣하게 선 디딤발」입니다
🔴 백성검 님 breakdown[].title 값이 바뀝니다. title_earned 규칙은 그대로입니다 — 2등급 구간은 전부 단일이라 수식어 경로는 한 글자도 안 바뀝니다
점수 🔴 불변 — B-6 재실행 없음

🔴 드러난 것: breakdown[].title 과 summary 도 이제 features 에 의존합니다(방향을 고르려면 측정값이 필요합니다). 서비스 경로는 늘 넘기므로 실물은 안 바뀌고, 합성 판정 데모(scripts/demo.py)처럼 안 넘기는 경로는 그 항목만 항목명으로 떨어집니다 — 방향을 찍는 것보다 낫다고 봤습니다. test_display_only_fields_do_not_move_the_score 가 이 의존을 잡아냈고, 점수·등급·가중치는 그대로임을 확인했습니다.

🔴 드러난 것 하나: card 가 이제 features 에 의존합니다(방향을 고르려면 측정값이 필요합니다). 서비스 경로는 늘 넘기므로 바뀌는 것이 없지만, features 없이 부르는 평가·재현 경로는 그 항목만 방향을 말하지 않는 틀로 떨어집니다. test_display_only_fields_do_not_move_the_score 가 이것을 잡아냈고, 점수는 그대로임을 확인해 card 를 표시 전용 블록으로 명시했습니다.

불릿 문장을 루브릭으로 옮겼습니다 (2026.09.16) — 검수 대상이 하나 늘었습니다

처음에는 코드가 문장을 지었습니다(「…가 이번 동작의 강점입니다」 — 항목 이름만 갈아 끼우는 틀). 선수에게 보이는 문구는 지도자가 검수해야 하는데 코드는 검수 대상이 아닙니다. 그래서 칭호(titles)와 같은 자리로 옮겼습니다.

   
어디 agent/rubrics/*.yaml 의 항목마다 card_lines: {2:, 1:, 0:} (인스텝 6항목 · 인사이드 패스 5항목, 지금은 전부 임시값)
성질 🔴 등급마다 고정입니다. 같은 항목·같은 등급이면 모든 선수가 같은 문장을 받습니다 — 이 칸이 사는 것은 다양성이 아니라 검수된 문구입니다. 선수마다 달라지는 말은 evidence 의 몫입니다
왜 모델이 안 쓰나 판정 프롬프트에 필드를 더하는 것이라 미결 23번 근거 문장 기준선 재측정을 부릅니다. 얻는 것(문장 다양성)에 비해 비쌉니다
봉투 🔴 안 바뀝니다 — card.notes 그대로고 schema_version 도 1.5 그대로입니다. 화면·적재 쪽에 할 일이 새로 생기지 않습니다
점수 🔴 한 비트도 안 바뀝니다 — B-6 재실행 없음
검사 tests/test_summary.py 14건 추가 (522 → 536 통과). 항목×등급 전부에 문장이 있는지 · 문장에 숫자·경기 기록이 없는지 · 루브릭 문장이 실제로 카드에 실리는지(배선이 끊기면 예외 없이 코드 틀로 폴백해서, 이 검사가 없으면 아무도 모릅니다)
  • 박민호 님: 지도자 검수 목록에 card_lines 를 넣어 주세요 — 임계값·칭호와 같은 회차입니다(미결 2번). 선수가 실제로 읽는 문장이라 검수 우선순위가 낮지 않습니다. 🔴 내밀 종이는 만들어 두었습니다 — 같은 구역 2번의 「문구 검수 서식을 따로 냈습니다」 절, eval/pending2_wording/packet/ 입니다 → 🔴 철회합니다 (2026.09.17) — 검수를 아예 안 받기로 했습니다(2번 「검수 없이 갑니다」). 종이는 그대로 살려 둡니다, 지도자가 생기는 날 씁니다
  • 백성검 님: 화면에서 이 문장을 다시 쓰거나 기워 붙이지 말아 주세요. 문구를 고칠 일이 생기면 루브릭을 고치는 것이 맞고, 그래야 검수 이력이 한 곳에 남습니다

🔴 그래서 이 문장들은 검수 없이 나갑니다 (2026.09.17)

선수가 화면에서 그대로 읽는 문장(titles · card_lines)이 사람 눈을 한 번도 안 거친 채 서비스로 나간다는 뜻입니다. 지어낸 문장은 아닙니다 — 루브릭에 등급마다 적힌 고정 문구이고, 검사가 숫자와 경기 기록이 새는 것을 막고 있습니다(test_card_lines_say_only_what_a_fixed_sentence_may_say). 🔴 그래도 「검수됨」은 아니고, 루브릭 머리말의 ⚠️ 지도자 검수 전 임시값 주석이 그 사실을 답니다 — 그 주석을 지우지 않습니다.

  • 상세: agent/report-contract.md 의 「불릿 문장은 루브릭에 등급마다 적혀 있습니다」 절

✅ 백성검 몫 (2026.09.17) — 화면에서 지어낸 불릿을 걷어냈습니다.

  • 🔴 걷어낸 이유: 「1대1에서 잘 밀리지 않습니다」류는 경기 행동이라 재는 것이 아무것도 없는데(정상호 확인), 그 붙박이 표가 닉네임으로 붙어 있었습니다 — 후보가 진짜 사용자가 된 지금 이름이 겹치면 지어낸 문장이 그 사람의 진짜 등급 옆에 걸립니다(대표 영상으로 한 번 데인 자리)
  • 🔴 「출처를 화면에서 구분해 주세요」는 다르게 답했습니다 (사용자 판단). 호칭 옆에 「본인이 적음」을 붙여 봤다가 걷었습니다 — 제품 화면에 「본인이 적음」·「AI가 적음」이 붙어 있으면 읽는 사람에게 이상한 말입니다. 카드는 선수를 소개하는 자리지 출처를 밝히는 자리가 아닙니다. 대신 섞지 않는 것으로 가릅니다: 이름 아래 줄에는 사람이 적은 호칭만 오고(paik 36번), 에이전트 불릿은 아래 제 칸에 따로 섭니다. 정상호 님, 이것으로 충분한지 봐 주시고 아니면 알려 주세요
  • ⏳ card.notes 배선은 못 했습니다 — 읽을 경로가 계약에 없습니다. 후보 응답에도 GET /cards/{slug} 에도 없어서 미결 paik 38번으로 올렸습니다(담당 정어진). 경로가 생기면 CSS(.ss-suggest-notes)가 그대로 있으니 값만 넣으면 됩니다
  • 확인: vitest run · tsc 0건 · 시험이 그 문장들이 다시 들어오는 것과 출처 표식이 다시 붙는 것을 둘 다 잡습니다(SquadSuggest.test.tsx)

  • 담당: 백성검(화면 배선) ✅ 위 (2026.09.17) · 정어진(프로필 한 줄 칸 · 적재 여부) · 박민호(card_lines 검수) ⛔ 철회 (2026.09.17) · 박민호(못 재는 문구를 둘지) · 제기: 정상호(사용자 요청) · 기한: 스프린트 3

51. 미결 항목이 길어지면 접습니다 — 닫을 때 같이 하는 규칙을 넣었습니다 (2026-09-16 신설)

이 페이지가 9,098줄이 되어 브라우저로 훑는 것이 사실상 불가능했습니다. 제 구역이 그중 6,963줄(77%)이었고 한 항목이 1,644줄이었습니다. 🔴 지우지 않고 접었습니다 — 펼쳐진 줄이 9,098 → 5,938(36% 접힘).

  • 맨 위에 접힌 목차(구역 4 + 항목 89)가 생겼습니다. 항목 제목까지만 잡으므로 회차 소제목은 안 뜹니다
  • 회차 기록·조사 상세를 <details> 로 접었습니다. 지금 상태·결정·정정· 「하지 말 것」·담당은 펼친 채 둡니다

🔴 담당 줄은 접기 밖에 둡니다 — 접어도 grep 에는 걸리지만 사람 눈에는 안 보입니다. 지금 묻힌 줄 0건이고, 검사 한 줄을 규칙에 적어 두었습니다.

규칙을 CLAUDE.md 「미결 항목」에 넣었습니다(「끝난 회차는 접습니다」). 항목을 ✅ 해소로 닫을 때 그 항목의 회차 기록을 함께 접는 것이 기본이고, 접기 → 압축 → 아카이브 순서로 씁니다. 자기 구역만 접습니다.

🔴 여쭙습니다 — 여러분 구역도 제가 접어도 됩니까

규칙이 「자기 구역만」이라 손대지 않았습니다. 그런데 먼저 재보고 말씀드리면, 여러분 구역은 지금 접을 만큼 무겁지 않습니다.

구역 줄 항목 항목당 평균 닫힘
ho (제 구역) 7,083 35 202 3/35
paik 953 10 95 3/10
min 720 10 72 0/10
jin 449 35 13 30/35

정어진 님 구역이 본보기입니다 — 35개 항목에 449줄입니다. 2026.09.15에 압축

  • 아카이브를 하신 결과고, 접을 것이 남아 있지 않습니다. 박민호 님·백성검 님 구역도 항목당 70~95줄이라 지금은 그냥 읽힙니다.

그래서 여쭙는 것은 지금 해 달라가 아니라 앞으로 어느 쪽인가입니다.

   
(가) 각자 하십니다 — CLAUDE.md 「끝난 회차는 접습니다」대로. 🔴 제가 권합니다 (구역 규칙 그대로고, 무엇이 지금 상태인지는 그 구역 사람이 제일 잘 압니다)
(나) 무거워지면 제가 해도 된다 — 그때 이 항목에 한 줄 달아 주시면 그 구역만 접겠습니다

🔴 (나)를 고르셔도 제가 먼저 묻고 합니다. 같은 자리를 고치고 계시면 병합 충돌이 나고, 그게 구역을 나눈 이유입니다.

접으면 무엇이 바뀌고 안 바뀌는지 (제 구역에서 확인한 것):

  • 내용은 한 글자도 안 바뀝니다. 앞뒤로 <details> 세 줄이 들어갈 뿐이라 git diff 도 삽입만 보입니다. 제목도 안 건드립니다
  • grep 은 그대로 걸립니다 — 파일 텍스트는 그대로입니다
  • 🔴 담당 줄은 접기 밖에 둡니다. 접으면 grep 은 되는데 사람 눈에 안 보입니다. 지금 묻힌 줄 0건이고 확인 명령이 규칙에 있습니다

정어진 회신 (2026-09-17) — (가)입니다. 그리고 방법 자체를 하나로 하자는 제안을 올렸습니다

jin 구역은 (가) 입니다. 다만 답하려고 재 보니 「누가 접느냐」보다 구역마다 방법이 갈라진 것이 더 커 보였습니다 — 닫혔는데 안 옮긴 항목, 압축 없이 통째로 옮긴 항목, 접힌 채 아카이브로 간 항목이 섞여 있습니다. 수치와 제안(접기를 빼고, 닫으면 요약해 바로 옮긴다)은 jin 39번에 두었습니다 — 여기엔 옮겨 적지 않습니다.

  • 확인: grep -c '^<details' jekyll/pages/pending.markdown 와 '^</details>' 의 수가 같고, 규칙에 적힌 awk 검사가 0이면 됩니다
  • 담당: 정어진·박민호·백성검(각자 구역에 대해 (가)/(나) 중 하나) · 제기: 정상호 · 기한: 급하지 않음 (지금 깨지는 것은 없습니다 — 답을 안 주시면 (가)로 둡니다)

52. 영상만 보고 슛인지 패스인지 자동 판별 — 1회차 불합격, 막힌 곳은 분류기가 아닙니다 (2026-09-16 신설)

🔴 보류 (2026-09-17) — 남은 개발 기간에 2회차를 열지 않습니다. 사용자 결정이고 이유는 시간입니다. 🔴 닫은 것이 아닙니다 — 아래 1회차 결과·정정은 그대로 살아 있고, 재개 조건도 원래대로 jin 17번입니다.

  • 잃는 것이 거의 없다고 판단한 근거: 아래 「지금의 답」이 이미 유효한 길은 동작을 사용자가 고르게 하는 것(미결 jin 17번)이라고 적고 있습니다. 제품 경로가 이 항목에 안 걸려 있어서, 보류해도 막히는 것이 없습니다
  • 🔴 다만 문제가 사라지는 것은 아닙니다 — 패스 영상이 슈팅 루브릭으로 채점되는 것(아래 「질문」)은 그대로고, 그것은 jin 17번이 풀려야 없어집니다. 자동 판별을 안 만드는 것이 그 결함을 덮는 뜻이 되지 않게 여기 적어 둡니다
  • 재개하면 어디서부터인가: 2회차 착수분까지 남아 있습니다 — 포즈 캐시 (cache_poses.py, 커밋 6049755). 1회차의 진짜 병목이던 「확인 한 번에 GPU 30분」이 이미 치워져 있으므로, 재개는 표본을 제품 입력(전신이 계속 보이는 단독 드릴)에 맞추는 것부터입니다
  • 🔴 보류 중에도 「하지 말 것」 두 줄은 그대로 유효합니다 (아래 표) — 특히 분류 결과로 루브릭을 자동 전환하지 않는 것. 이 항목을 안 하기로 한 것이 「대충이라도 붙여 두자」로 뒤집히면 그게 정확히 그 함정입니다

질문: 사용자가 올린 영상에서 동작(인스텝 슛 / 인사이드 패스)을 읽어낼 수 있는가. 지금은 작업 메타데이터(sport_code)로만 루브릭이 정해지고, 축구 active 루브릭이 인스텝 슈팅 하나뿐이라 패스 영상도 슈팅 루브릭으로 채점됩니다.

사전 등록 eval/pending52_motion_id/PREREGISTRATION.md(커밋 d82550a, 구현 전에 굳힘 · 정정 ff817d5) · 결과 RESULTS.md.

판정 — 🔴 불합격. 다만 A·B·C 는 읽지 마세요

  기준 결과  
A 일치율 ≥ 80% 75% (3/4) 🔴
B unknown ≤ 30% 20% ✅
C 슛→패스 ≤ 10% 0% ✅
D production 무수정 import 만 ✅
E 두 층 각 ≥ 15편 패스 3 · 슛 2 🔴

판정까지 간 것이 31편 중 5편입니다. 75%는 4편 중 3편이고 한 편이 뒤집히면 50%나 100%가 됩니다 — 측정이 아니라 잡음입니다.

🔴 26편이 판정에 못 간 이유 — 전부 분류 앞 단계입니다

  수  
다리 가시성 게이트(70%) 10 실제값 15~67%. 표본이 강의 영상이라 상반신 클로즈업·발만 잡은 컷이 섞였습니다
AV1 디코딩 실패 4 이 기계가 AV1을 못 엽니다. yt-dlp 포맷 제약으로 없앨 수 있습니다
각속도 산출 불가 · 임팩트가 경계 4  
대상 선택 실패 8 임팩트 시점 공-발 거리 1.3 ~ 20.04 어깨너비(≈8m)

🔴 계기 검사가 두 번째로 값을 했습니다. 미결 45번 1회차가 「터치 검출」이 아니라 대상 선택을 쟀던 것을 이 회차 사전 등록에 「내 앞 단계가 맞게 돌고 있는가」로 넣었고, 그것이 정확히 걸렸습니다. 없었으면 이 8편이 「분류기가 틀렸다」로 기록될 뻔했습니다. 그리고 대상 선택 문제가 중계 영상에서만 나는 것이 아니라는 것이 드러났습니다 (미결 18번에 주는 새 정보).

🔴 두 가지를 정정합니다 — 사전 등록의 기대가 둘 다 안 섰습니다

  • S3′(팔로스루)이 안 갈랐습니다. 표를 가장 많이 던지고 2/5(40%) 로 가장 많이 틀렸습니다 — 우연(50%)보다 낮습니다. 문턱 25/30은 제가 고른 것이 아니라 두 루브릭이 먼저 적어 둔 2등급 경계였는데, 「여기서 갈린다」는 그 판단이 이 측정에서는 안 섰습니다. 값도 겹칩니다(패스 13~91 · 슛 0~119)
  • 🔴 공 속도는 값 자체를 믿을 수 없습니다. 집계는 「안 겹친다」로 나오지만 그건 슛 표본이 1편이라서입니다. 통과분 전체를 보면 슛이 1.86~138.16이고, 어깨너비 0.4m 환산으로 138은 초속 55m(세계 기록 초과), 1.86은 초속 0.7m(걷기보다 느림) 입니다. 슛 6편 중 4편이 패스 문턱 아래입니다. 「공 기반이 유일한 노력 독립 신호」라던 기대가 여기서 무너졌습니다

하지 말 것 / 다음 한 걸음

   
🔴 하지 말 것 분포를 보고 문턱을 돌리는 회차를 열지 않습니다 — 이 표본이 훈련셋이 되고 합격이 뜻을 잃습니다. 막힌 곳은 임계값이 아니라 그 앞입니다
🔴 하지 말 것 분류 결과로 루브릭을 자동으로 바꾸는 것. 틀리면 엉뚱한 루브릭으로 채점되고 그 사실이 결과에 안 남습니다 (jin 17번의 「루브릭 기본값으로 채우지 않기」와 같은 형태)
1 표본을 제품 입력(전신이 계속 보이는 단독 드릴)에 맞추고 AV1을 피합니다
2 대상 선택(미결 18번)이 드릴 영상에서도 흔들리는지
3 ball_speed가 무엇을 재고 있는지 한 클립을 손으로 따라갑니다 (후보 셋: 전역 최대점이 킥이 아님 · 검출된 공이 찬 공이 아님 · 골반 정규화가 공 이동까지 상쇄)

지금의 답

🔴 자동 판별은 아직 못 붙입니다. 이 회차는 「갈린다」도 「안 갈린다」도 증명하지 못했습니다. 유효한 길은 그대로 동작을 사용자가 고르게 하는 것(미결 jin 17번)이고, 그렇게 쌓인 라벨로 이 질문을 다시 엽니다.

참고로 패스 라벨이 붙은 축구 클립이 우리에게 하나도 없었습니다 — 골든셋 19편·3DSP 200편·SoccerNet Shots 100편이 전부 슛입니다. SoccerNet Ball Action Spotting 에 PASS·SHOT 라벨이 있지만 암호로 잠긴 NDA 배포라 쓰지 않았습니다. 그래서 이 회차 표본은 YouTube 드릴 영상으로 새로 만들었고, 라벨은 제목에서 옵니다(약한 라벨 — 결과를 그만큼만 신뢰합니다).

  • 확인: cd agent && uv run python eval/pending52_motion_id/summarize.py
  • 담당: 정상호 · 제기: 정상호 · 기한: 보류 (2026-09-17) — jin 17번이 풀린 뒤 남은 개발 기간에는 열지 않습니다(위 「보류」). 재개 조건 자체는 그대로 jin 17번이고, 그때 다시 판단합니다

53. 🔴 앞단(Cloudflare)이 워커의 요청을 막고 있습니다 — 어제 08:27부터 분석이 한 건도 안 돌았습니다 (2026-09-17 신설)

원인은 찾았고 워커 쪽 우회는 제가 넣었습니다. 판단해 주실 것은 「앞단 규칙을 어떻게 할까」 하나입니다.

GPU 인스턴스의 supersub-worker 가 POST /internal/analysis-jobs/claim 을 전부 403 으로 돌려받고 있습니다. 본문은 error code: 1010, 응답 헤더는 server: cloudflare 입니다 — 오리진(FastAPI)까지 가지 않으므로 백엔드 로그에는 아무것도 안 남습니다. 큐에 작업이 쌓여도 아무도 집지 않습니다.

차단 기준은 User-Agent 문자열 하나입니다. 워커 인스턴스에서 UA 만 바꿔 가며 같은 경로에 GET 을 쳐 봤습니다 (GET 이라 405 가 「오리진까지 갔다」는 뜻입니다 — 🔴 claim 은 부르지 않았습니다. 조회가 아니라 소비라서 부르면 작업이 running 으로 넘어갑니다):

보낸 UA 결과
Python-urllib/3.12 403 (Cloudflare)
Python-urllib 403
python-urllib/3.12 (소문자) 405 — 통과
Python-requests/2.32 · Python/3.12 · urllib/3.12 405 — 통과
supersub-worker/1.0 · UA 없음 405 — 통과

즉 Python-urllib 를 대소문자 구분해서 contains 로 거르는 규칙입니다 (표현식에 lower() 가 없을 때의 전형적인 모양입니다). 토큰과는 무관합니다 — 워커의 SUPERSUB_WORKER_TOKEN 은 채워져 있고, 401 이 아니라 403 입니다.

언제부터인지가 분명합니다. 워커 저널에서 같은 프로세스 안에서 갈렸습니다 — 워커 쪽은 코드도 설정도 재시작도 없었습니다.

시각  
2026-09-16 08:11:28 마지막 정상 처리 (작업 3e7b6116…, 74초, 리포트 S3 업로드까지 완료)
2026-09-16 08:27:03 claim 실패(1) — 403 … 1010 첫 등장. 이후 지금까지 전부 실패

그 15분 사이에 앞단 설정이 바뀐 것으로 보입니다. 무엇을 바꾸셨는지 아시면 그게 답입니다 (봇 차단 규칙 추가·보안 수준 상향·관리형 룰셋 갱신 중 하나일 것입니다).

제 쪽에서 이미 한 것

scripts/worker.py 의 _request() 가 UA 를 안 줘서 파이썬 기본값 Python-urllib/3.x 가 그대로 나가고 있었습니다. supersub-worker/1.0 을 명시하도록 고쳤습니다 — 자기 이름을 밝히는 것이 원래 맞기도 합니다. tests/test_worker.py::test_every_backend_call_names_itself_in_the_user_agent 가 되살아나는 것을 막습니다.

🔴 그래도 이 항목은 닫지 않습니다. UA 를 바꾼 것은 규칙을 피한 것이지 규칙이 우리를 안 막게 된 것이 아닙니다. 앞단 규칙 문구가 다음에 조금만 넓어지면 (예: contains "python", 또는 관리형 봇 룰셋) 같은 증상이 그대로 돌아오고, 그때도 백엔드 로그에는 아무 흔적이 없습니다.

판단해 주실 것

/api/v1/internal/* 는 워커 전용 경로이고 X-Worker-Token 으로 이미 인증합니다. 앞단에서 이 경로를 봇 차단 대상에서 빼 주시거나, 워커 출발지 IP 를 허용해 주시면 UA 와 무관해집니다. 어느 쪽이 운영하기 편한지는 그쪽 판단이 낫습니다. (워커 IP 는 인스턴스를 켤 때마다 바뀝니다 — 탄력적 IP 가 아닙니다. 그래서 IP 허용 쪽이면 손이 더 갑니다.)

반영했습니다 — 403 은 멎었는데 큐가 비어 있습니다 (2026-09-17)

UA 수정분(766fb0f)을 인스턴스에 배포하고 워커를 재시작했습니다. 재시작 이후 403 은 0 건이고, 빈 큐(204)는 로그를 남기지 않도록 되어 있어 저널이 조용한 것이 정상 신호입니다 — 앞단을 지나 오리진까지 갔다 온다는 뜻입니다.

🔴 그런데 밀려 있던 작업이 한 건도 안 나옵니다. 9/16 08:11 부터 9/17 02:38 까지 약 18시간 동안 워커가 큐를 못 집었는데, 되살아난 지금 claim 이 계속 204 입니다. 둘 중 하나입니다.

  • 그 사이 올라온 분석 요청이 원래 없었다 — 그러면 아무 문제 없습니다
  • 요청은 있었는데 claim 이 집을 수 있는 상태가 아니다 — 이건 제 쪽에서는 안 보입니다

후자인지만 확인해 주세요. 그 사이 생성된 작업 행이 있는지, 있다면 지금 어떤 상태로 남아 있는지요. running 인 채 멈춰 있는 것이 있다면 회수 규칙이 아직 없어서 사실상 영구입니다(워커 유닛 파일 주석 참고) — 손으로 되돌려 주셔야 다시 분석됩니다.

  • 「확인」: 워커 인스턴스에서 — 403 이 아니라 405 면 앞단을 통과한 것입니다
    curl -s -o /dev/null -w "%{http_code}\n" -X GET \
      -A "Python-urllib/3.12" "https://<API 호스트>/api/v1/internal/analysis-jobs/claim"
    
  • 🔴 하지 말 것: 위 명령을 -X POST 로 바꾸지 않습니다 — claim 은 부를 때마다 작업 하나를 running 으로 넘기고, 회수 규칙이 아직 없습니다

✅ 하루 지나 현장에서 확인했습니다 (2026-09-18) — 우회는 버티고 있습니다

인스턴스가 켜져 있어 워커 저널을 봤습니다. 🔴 claim 을 부르지 않았습니다 — 저널만 읽었습니다.

   
마지막 403(1010) 2026-09-17 02:38:15 UTC — UA 수정 반영 시각 그대로이고, 그 뒤로 한 건도 없습니다
그 뒤 성공 10건 (가장 최근 2026-09-18 00:56, 32초짜리 한 편이 리포트·미리보기까지 S3 에 올라갔습니다)

즉 워커 쪽 우회는 하루를 버텼습니다. 🔴 앞단 규칙 자체는 그대로이므로 이 항목은 열어 둡니다 — Python-urllib 를 대소문자 구분으로 거르는 규칙이 남아 있는 한, UA 를 안 밝히는 다른 클라이언트(스크립트·헬스체크·새 워커)가 같은 자리에서 또 막힙니다. 급하지는 않습니다.

✅ 큐 확인 — 「그 사이 요청이 원래 없었다」 쪽입니다 (2026-09-18, 정어진)

물어보신 두 갈래 중 앞쪽이 답입니다. 멈춘 작업은 없습니다.

물어보신 것 확인
공백 구간(09-16 08:11 ~ 09-17 02:38)에 생긴 작업 0건 — 그 사이 올라온 분석 요청이 원래 없었습니다
running 인 채 멈춘 것 0건 — 손으로 되돌릴 것이 없습니다
전체 분포 analysis_job 58건 전부 succeeded (queued·failed 0건)

공백 이후로도 워커가 정상적으로 집고 있습니다 — 최근 것만 적으면 09-17 03:52 · 08:29 · 08:30, 09-18 00:53 · 03:19 · 03:35 이 전부 1~3분 안에 끝났습니다.

🔴 claim 은 부르지 않았습니다 — 「하지 말 것」 그대로, DB 조회만 했습니다.

🔴 이 항목은 여전히 열어 둡니다. 확인한 것은 큐가 멀쩡하다는 것뿐이고, 앞단 규칙(Python-urllib 를 대소문자 구분해 거르는 것)은 그대로입니다 — UA 를 안 밝히는 다른 클라이언트가 같은 자리에서 또 막힙니다. 그 판단은 아래 담당 줄대로 남아 있습니다.

  • 담당: 정어진 · 제기: 정상호 · 기한: 🔴 지금 막혀 있습니다 — UA 수정분이 인스턴스에 반영되기 전까지는 분석이 한 건도 안 돕니다 분석은 다시 돕니다 (2026-09-17 02:38 반영, 위 「반영했습니다」) · 큐 확인만 먼저 ✅ 확인했습니다 (2026.09.18, 위) — 멈춘 작업 0건 — 남은 것은 앞단 규칙을 어떻게 할까 하나이고 급하지 않습니다 (앞단은 jin 37번에서 박민호 님 담당으로 함께 다루고 있습니다)

54. 모델 가중치의 사본이 EC2 한 대뿐이었습니다 ✅ S3 백업 완료 (2026-09-17 신설·해소)

  • 위치: pending-archive.markdown의 ## ho (정상호) 구역으로 이동됨

55. reports/ 에 probe 파일 두 개가 남아 있습니다 — 지워 주세요 (2026-09-17 신설) ✅ 해소 (2026.09.17)

  • 위치: pending-archive.markdown의 ## ho (정상호) 구역으로 이동됨

jin (정어진)

1. 분석 결과 적재 규격 — 지표 코드가 종목을 넘나든다 ✅ 해소 (2026.09.08)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

2. 클라이언트의 백엔드 계약 반영 — www ✅ 해소 (2026.09.10) · flutter ✅ 해소 (2026.09.10)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

3. 스프린트 1이 끝났는데 칸반·스프린트 로그가 갱신되지 않았다 ✅ 해소 (2026.09.03)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

4. 부록 D의 종목 서술이 실물과 다릅니다 (한 줄) ✅ 해소 (2026.09.03)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

5. 병합할 때 이 파일의 구역 구조를 유지해 주세요 ✅ 해소 (2026.09.11)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

6. 미결 9번의 담당 표기를 확인해 주세요 (정상호 님) ✅ 해소 (2026.09.03)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

7. 카드를 만드는 자리를 화면에 두어 주세요 (2026-09-02 신설) ✅ 해소 (2026.09.04)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

8. 배포 — 백엔드가 EC2에서 돕니다 ✅ 해소 (2026.09.03)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

9. agent/·flutter/ 에 진입점 문서가 없습니다 (2026-09-02) ✅ 해소 (2026.09.10)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

10. 웹이 가짜 데이터에 고정돼 있습니다 — 백엔드는 떠 있습니다 (2026-09-02) ✅ 해소 (2026.09.03)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

11. 업로드 길이 상한 60초와 max_frames=300이 안 맞습니다 (정상호 님, 2026-09-03) ✅ 제 몫은 끝났습니다 (2026.09.09) — 남은 것은 제품 판단(ho 30번)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

12. 클립 업로드가 열렸습니다 — 붙일 자리가 생겼습니다 (2026-09-03 신설) ✅ 해소 (2026.09.03)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

13. 백엔드 미구현 분담 — 과금 도메인을 맡아 주십시오 (백성검 님, 2026-09-03) ✅ 해소 (2026.09.10)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

14. 백엔드 미구현 분담 — 평가·신뢰 도메인을 맡아 주십시오 (박민호 님, 2026-09-03) ✅ 해소 (2026.09.04)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

15. 경기 탐색이 열렸습니다 — /matches 화면이 그려집니다 (백성검 님, 2026-09-03) ✅ 해소 (2026.09.10)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

16. 지원이 붙은 경기를 취소할 방법이 없습니다 — 결정이 필요합니다 (박민호 님, 2026-09-03) ✅ 해소 (2026.09.04)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

17. 클립의 동작을 담을 자리가 없습니다 — 종목만으로는 루브릭을 못 고릅니다 (2026-09-04)

미결 ho 17번(큐 소비자)에 답하다 걸린 것입니다. 업로드는 종목만 싣는데, 채점하려면 어떤 동작인지가 있어야 합니다.

POST /videos  ->  video.sport_code = "football"
                  루브릭은?  football_instep_shot 인가 football_inside_pass 인가

실물 루브릭을 세어 보면 두 종목이 갈립니다.

종목 있는 루브릭 종목만으로 정해지나
baseball baseball_pitching ✅ 하나뿐
basketball basketball_jump_shot · basketball_layup ❌
football football_instep_shot · football_inside_pass ❌

🔴 기본값에 기대면 안 됩니다. analyze_s3.py --rubric 의 기본이 football_instep_shot 이라, 안 주면 농구 영상을 축구 루브릭으로 채점합니다 (정상호 님이 ho 17번 「하지 말 것」에 적어 두신 함정입니다).

부록 D 에 자리가 없습니다

video 는 user_id·sport_code·storage_key·duration_ms·side 뿐이고 analysis_job 은 상태·시각뿐입니다(실측). 어디에 담을지가 ERD 결정입니다.

안 내용 메모
A video 에 motion_code 를 늘린다 클립의 성질이므로 여기가 자연스럽습니다. 부록 D 수정
B sport 처럼 motion 참조 테이블을 두고 FK 코드 목록이 값으로 관리됩니다. position 이 그 모양입니다. 테이블이 하나 늘어납니다
C analysis_job 에 담는다 재분석 때 다른 동작으로 돌릴 수 있지만, 같은 클립의 동작이 실행마다 달라질 수 있어 뜻이 흐려집니다

제 의견은 B 입니다. 루브릭 목록이 앞으로 늘어날 것이고(야구 타격이 미결 3번에 있습니다), 자유 문자열이면 football_instep_shot 과 instep_shot 이 섞입니다. position 이 이미 같은 문제를 참조 테이블로 풀었습니다.

   
만족해야 할 성질 업로드가 어떤 동작인지를 실을 수 있을 것 · 그 값으로 루브릭이 하나로 정해질 것 · 없는 동작은 거부될 것
확인 git grep -n "motion" -- fastapi/app → 결과가 있으면 착수된 것입니다
하지 말 것 🔴 정해지기 전에 컬럼을 늘리지 않기(부록 D 와 어긋납니다) · 🔴 루브릭 기본값으로 채우지 않기 — 틀린 채점이 조용히 나옵니다

🔴 정정 (같은 날 늦게) — 급하지 않습니다. 지금은 안 막힙니다

위에서 “종목만으로는 루브릭을 못 고른다”고 썼는데, 루브릭에 status 가 있고 종목당 active 가 하나씩입니다. active 인 것을 고르면 지금은 정해집니다.

종목 active draft
baseball baseball_pitching —
basketball basketball_jump_shot basketball_layup
football football_instep_shot football_inside_pass

그래서 워커는 이 항목의 답을 기다리지 않고 돌 수 있습니다. 규칙은 「그 종목의 active 가 정확히 하나면 쓰고, 아니면 failed」 로 넣었습니다 (fastapi/docs/worker-interface.md 2절).

그래도 이 항목을 닫지 않습니다. 둘 다 남아 있는 문제입니다.

  • draft 를 승격시키면 그 종목이 둘이 되어 그때 막힙니다 — 야구 타격 루브릭(미결 ho 3번)이 들어오면 baseball 이 바로 그렇게 됩니다
  • “어떤 동작을 올린 것인지”는 원래 사용자가 정하는 값입니다. 종목에서 역산하는 것은 루브릭이 하나뿐인 동안만 통하는 우회입니다

동작 코드 목록은 루브릭에 이미 있습니다

여쭤보려던 것을 실물에서 찾았습니다. scoring.py 의 Rubric.key 가 "<sport>/<motion>" 이고, 각 파일이 sport·motion 을 따로 갖고 있습니다.

baseball/pitching · basketball/jump_shot · basketball/layup
football/inside_pass · football/instep_shot

motion 값을 그대로 코드로 쓰는 것을 제안합니다 — 파일명(baseball_pitching)은 종목이 섞여 있어 sport_code 와 중복됩니다. 정상호 님은 이 목록이 정본이 맞는지만 확인해 주시면 됩니다.

✅ 확인했습니다 — 정본 맞습니다. 다만 목록이 하나 빠졌고, 단독 키는 위험합니다 (2026.09.09, 정상호)

⑴ 목록이 낡았습니다 — 5개가 아니라 6개입니다. 적어 주신 뒤에 야구 타격 (baseball/batting, draft)이 들어왔습니다. 지금 정본은 이렇습니다:

key sport motion status
baseball/pitching baseball pitching active
baseball/batting baseball batting draft ← 빠져 있던 것
basketball/jump_shot basketball jump_shot active
basketball/layup basketball layup draft
football/instep_shot football instep_shot active
football/inside_pass football inside_pass draft

이 표를 옮겨 적지 마시고 명령으로 뽑아 쓰세요 — 또 낡습니다.

cd agent && uv run python -c "import sys;sys.path.insert(0,'src');\
from supersub_agent.scoring import discover_rubrics;\
[print(f'{k}\t{v.status}') for k,v in sorted(discover_rubrics('rubrics').items())]"

⑵ motion 을 코드로 쓰는 것은 맞습니다. 🔴 다만 단독 키로 두지 마세요.

지금 motion 6개가 전역에서 안 겹치는 것은 우연이지 보장이 아닙니다. shot·serve 처럼 여러 종목에 자연스럽게 들어갈 이름이 있습니다. B안 참조 테이블의 키를 motion 하나로 두면 겹치는 순간 한 행이 두 루브릭을 가리키고, 그러면 농구 영상이 축구 루브릭으로 채점되면서 그 사실이 값에 안 남습니다 — 이 항목이 막으려던 바로 그 사고입니다.

권고는 (sport_code, motion_code) 복합 키입니다. B안 자체에는 동의합니다 (자유 문자열이면 football_instep_shot 과 instep_shot 이 섞인다는 판단이 맞습니다).

🔴 jin 1번 A안과 헷갈리지 마세요. 지표에서 종목을 뗀 근거는 「지표는 물리량이다」였는데 동작은 물리량이 아닙니다. 같은 이유로 제가 jin 23번의 항목별 등급 코드도 grade.{sport}.{motion}.{criterion_id} 로 냈습니다 — 축이 종목이 아니라 루브릭이라는 그쪽 구역 1번의 지적과 같은 결론입니다.

⑶ 어휘를 지키는 검사가 없었습니다 — 넣었습니다.

검사 막는 것
test_the_motion_vocabulary_is_the_pair_not_the_bare_motion motion 이 종목을 넘어 겹치는 날 거기서 걸립니다. 겹침을 금지하는 것이 아니라, 그때 쌍으로 갈지 이름을 바꿀지 정하게 하는 자리입니다
test_the_filename_matches_the_declared_sport_and_motion 파일명 ≠ <sport>_<motion>. 「파일명은 종목이 중복된다」는 판단이 서려면 파일명이 실제로 그 모양이어야 하는데 강제하는 것이 없었습니다

둘 다 일부러 깨뜨려 확인했습니다 — 농구 motion 을 instep_shot 으로 바꾸니 첫 검사가, 파일명을 바꾸니 둘째가 실패했습니다. 원복 후 350건 통과.

   
확인 cd agent && uv run pytest tests/test_scoring.py -q — 35건
하지 말 것 🔴 motion 을 단독 기본키로 두지 마세요 · 위 표를 문서에 복사하지 마세요(명령으로 뽑으세요)
  • 상세: 미결 ho 17번 「답변 (2026-09-04)」 의 라 항목 · fastapi/docs/worker-interface.md · jin 23번(등급 코드가 같은 쌍을 씁니다)
  • 담당: 박민호(부록 D 수정 여부) · 정상호(동작 코드 목록 확인) ✅ 확인·정정했습니다 (2026.09.09) · 제기: 정어진 · 기한: 스프린트 3 (급하지 않음)

18. 분석 워커 — 백엔드 쪽은 냈습니다. 폴링 루프를 부탁드립니다 (정상호 님, 2026-09-04) ✅ 해소 (2026.09.07)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

19. 부록 D 에 컬럼 둘을 더해 주십시오 — sort_order · tagline (박민호 님, 2026-09-04) ✅ 해소 (2026.09.08)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

20. 미결 1번(적재 규격)·18번(워커 폴링 루프) 회신을 부탁드립니다 (정상호 님, 2026-09-07) ✅ 해소 (2026.09.08)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

21. 공개 사이트에서 인프라 식별자를 걷어냈습니다 (2026-09-07) ✅ 해소 (2026.09.15, S3 콘솔 확인까지 완료)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

22. agent/deploy/ 에 AWS 계정 ID·리소스 ID·API 호스트가 값으로 남아 있습니다 (2026-09-08) ✅ 해소 (2026.09.09)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

23. metric_definition 을 누가·어떻게 채웁니까 — POST /analyses 착수 전에 필요합니다 (2026-09-08) ✅ 시드 해소 (2026.09.10)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

24. 영상 수명 주기 — 분석은 임시, “저장”을 눌러야 남는다 · videos/ 는 원본, reports/ 는 저장된 것 (2026-09-08 신설) ✅ 6조각 전부 해소 (2026.09.11, 5조각이 마지막이었다)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

25. metric_definition 시드에 항목별 stat 코드를 추가해 주세요 — jin 23 후속 (2026-09-09) ✅ 해소 (2026.09.10)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

26. GET /positions 를 냈습니다 — 포지션 하드코딩을 걷어 주세요 (2026-09-09) ✅ 해소 (2026.09.10)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

27. 분석 리포트 적재 방식이 초안과 실물이 어긋납니다 — 통일해야 POST /analyses 를 짤 수 있습니다 (2026-09-10 신설) ✅ 제 몫 회신 (2026.09.10)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

28. player_vector 차원 — ho 32번 회신: (가) 루브릭별 공간으로 갑니다 (2026-09-10)

ho 구역 32번에서 정상호 님이 물으신 둘에 답합니다. 그 항목이 ho 브랜치에 있어 거기 쓰면 병합 충돌이라, jin 25번을 ho 35번으로 받으신 것과 같은 방식으로 제 구역에 둡니다.

1. (가) vs (나) → (가) 루브릭별 벡터 공간

정상호 님 근거 셋(결측 대부분·0채움은 ho 21번 재현·종목 넘는 비교는 무의미)에 더해, (나)는 pgvector 에서 특히 나쁩니다 — vector(N) 은 차원이 고정이고 HNSW 색인도 고정 차원을 요구합니다. (나)로 전역 11차원을 잡으면 색인 하나에 세 종목이 섞여 들어가고, 질의가 마스크를 봐도 색인은 마스크를 못 봅니다 — 안 잰 칸이 후보 이웃을 고르는 데 관여합니다.

적합도가 「경기 지원 건에 종속」이고 경기 종목은 team 이 정한다(SFR-010)는 것도 확인했습니다. 한 비교에 두 루브릭이 섞일 일이 없습니다.

2. 저장 형태 (제 설계, 알려 드립니다)

   
테이블 player_vector 한 개 (부록 D.2 그대로). analysis_metric_id 당 1행
컬럼 embedding vector(N) + rubric_code(예 baseball.pitching). 🔴 모든 유사도 질의는 WHERE rubric_code = ? 로 먼저 좁힙니다 — 색인도 rubric_code 부분 색인으로 나눕니다
N 전 루브릭(draft 포함) 중 채점 항목 수의 최댓값. draft 승격 때 스키마가 안 바뀌게 넉넉히. 루브릭 실제 차원은 export_metric_definitions.py 가 냅니다 — 상수로 안 박습니다
꼬리 패딩 그 루브릭이 안 쓰는 칸은 0. 같은 rubric_code 안에서는 모든 행의 꼬리가 똑같이 0 이라 코사인에 영향이 없습니다((나)의 0채움과 다릅니다 — 저기선 다른 종목끼리 0 칸이 「닮음」이 됩니다)
정규화 루브릭 모집단 기준 차원별 표준화. 스케일 섞임(각도 0~180·비율 0~3·초 0~1)을 그 공간 안에서 없앱니다. norm_version 을 함께 남겨 모집단이 바뀌어 재계산할 때 구분합니다

3. 누가 만드나 → 백엔드가 analysis_metric_value 에서 조립

에이전트가 내는 raw 벡터는 이미 features 부분집합이라 새로 실을 것이 없습니다. 값이 붙는 곳은 정규화인데, 그건 모집단(다른 선수들의 값)이 있어야 하고 그건 DB 만 압니다. 그래서 적재(jin 27번) 뒤 백엔드가 analysis_metric_value 행에서 조립합니다.

아직 안 합니다 — 자리가 없습니다

  • 서버에 pgvector 는 아직 소스 빌드 전입니다 (deployment.md 1절).
  • SFR-005 유사도 질의를 부르는 것이 아직 없습니다 (스프린트 3 「적합도」, ho 33번).
  • 적재 경로(jin 27번)가 analysis_metric_value 를 먼저 채워야 합니다.

결정만 기록하고, 구현은 jin 27 + ho 33 이 자리를 만들 때 합니다.

확인해 주실 것 (정상호 님)

ho 32번 표에 축구 인스텝 슈팅이 7개로 적혀 있는데, 지금 export_metric_definitions.py 는 grade.football.instep_shot.* 를 6개로 냅니다 (contact_point·swing_acceleration_timing 이 deferred: 로 빠졌습니다). 설계에는 영향이 없지만(N 은 최댓값), 32번 표를 실물에 맞춰 주시면 좋겠습니다.

✅ 「확인해 주실 것」 회신 (2026.09.11, 정상호) — 정본은 ho 32번입니다

7 과 6 은 둘 다 맞습니다 — 서로 다른 양이었습니다. 7 은 쓰는 지표, 6 은 채점 항목이고, follow_through 한 항목이 지표 둘을 씁니다. 표는 안 틀렸지만 제 표가 「차원」이 둘 중 어느 것인지 안 정해 둔 것이 맞습니다. ho 32번에 정정과 함께 셋을 더 달았습니다:

  • 표의 야구·농구 행이 없어졌습니다 (ho 39번, 축구 단일 종목). 남은 것은 인스텝(항목 6 · 지표 7)·인사이드 패스(5 · 6)이고 🔴 뒤가 앞의 진부분집합입니다
  • 🔴 제 원래 근거 셋 중 둘이 약해졌습니다 — ⑶(종목 넘는 비교)은 종목이 하나라 해당 없고, ⑴(대부분 결측)은 부분집합이라 늘 비는 칸이 1개뿐입니다. (가)는 그대로 권합니다만 이유가 바뀌었습니다
  • 🔴 설계 전제 하나가 실측에서 안 섭니다 — 「같은 rubric_code 안에서는 꼬리가 똑같이 0」. 인스텝 18편에서 plant_foot_to_ball_offset 이 0/18, hip_rotation_range_deg 가 15/18 로 클립마다 다르게 빕니다. 가끔 비는 칸에 0을 넣으면 ho 21번이 고친 결함이 벡터에서 되살아납니다
  • 축은 등급이 아니라 측정 지표값을 권합니다 — 같은 15편에서 고유 벡터가 지표값 15/15 대 등급 12/15(3편이 남과 구분 안 됨)입니다

  • 재실행: cd agent && uv run python eval/pending32_vector_basis/count_bases.py

  • 상세: ho 32번 · 부록 D.2·D.7(player_vector) · jin 17번(축이 루브릭이다) · jin 27번(적재)
  • 담당: 정어진(설계·적재 — jin 27·ho 33 뒤) · 정상호(ho 32번 표를 실물 6개로 정정) ✅ 회신했습니다 (2026.09.11) · 제기: 정상호(ho 32번) · 기한: 스프린트 3

29. k3s 트라이얼 파드가 크래시 루프 중 — 이미지가 레포와 어긋나 있습니다 ✅ 해소 (2026.09.11)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

30. 관측 스택(Grafana 대시보드 + GPU 메트릭)을 세울지 — 스코핑 판단 (2026-09-10 신설)

🔴 2026-09-23 진행 — 수집·시각화(T1) 매니페스트를 냈습니다 · 서버에는 아직 안 올렸습니다

  • /metrics 가 바깥에 열려 있었습니다 — nginx 에 막는 규칙이 없어 공개 경로로 200 이 나갔습니다. 서버에서 막았습니다(사용자 승인). 경위·확인은 fastapi/docs/deployment.md 「/metrics 는 nginx 에서 막는다」
  • T1 을 kube-prometheus-stack 대신 두 벌로 — Prometheus(보관 15일 또는 1GB) · Grafana (대시보드 10칸, PER-003 판정 칸 포함). 둘 다 127.0.0.1 에만 떠서 바깥 포트가 없고 SSH 터널로 봅니다. operator·node-exporter·alertmanager 까지 딸려 오는 것이 서버 한 대(디스크 여유 9GB)에 과해서입니다. 실물은 fastapi/deploy/k8s/monitoring.yaml, 적용 절차는 같은 폴더 README.md 「운영 관제」
  • 아래 「지금 막는 것」의 첫 둘은 풀렸습니다 — k3s 가 09-11 부터 운영입니다(31번). T2 칸의 「자동 종료가 비용 관리」는 09-18 에 자동 종료가 없어져 더는 맞지 않습니다
  • 남은 판단: 서버에 올릴지(아래 「판단해 주실 것」 그대로) · T2(GPU 메트릭)는 손대지 않았습니다
  • 확인: ssh supersub 'sudo k3s kubectl get ns monitoring' → NotFound 면 아직 안 올린 것입니다

/metrics 는 붙였습니다(74a6b25) — API 서버가 요청 수·지연 히스토그램·에러율을 Prometheus 형식으로 냅니다. 여기까지는 스크레이프하는 것이 없어도 손해가 없는 크기입니다. 그 위에 실제 수집·시각화를 얹을지가 판단 사항입니다.

만족해야 할 성질 (얹기로 한다면)

층 무엇 비용 감각
수집·시각화 k3s 에 kube-prometheus-stack(Prometheus + Grafana) 한 벌 + 대시보드 1개(요청률·P95·에러율·파드 상태). 발표 자료로 캡처가 강함 반나절
GPU 메트릭 GPU 인스턴스에 dcgm-exporter(NVIDIA)+node-exporter. 사용률·VRAM·온도. 자동 종료(autostop)가 이미 비용 관리는 함 반나절

지금 막는 것

  • k3s 가 프로덕션이 아닙니다. 트래픽은 systemd venv :8000 이 받고, k3s 는 트라이얼(크래시 루프, 29번)입니다. 관측 스택을 얹으려면 그 위에 세워야 하는데 대상이 아직 프로덕션을 안 봅니다.
  • k3s cutover(min 14 step 5)·29번이 정리된 뒤라야 의미가 있습니다.
  • helm·클러스터 admin·GPU 박스 세팅이 필요하고, 개발 기간(~10.27)에 기능 마감과 경쟁합니다.

판단해 주실 것

남은 6주에 Grafana 대시보드가 그 인프라 시간만큼 값을 하는지. “발표용으로 한 장 필요하다” 면 T1(수집·시각화)만, GPU 가시성까지면 T2 도. 안 하기로 하면 /metrics 는 그대로 두고(무해) 나중에 붙일 수 있습니다.

  • 상세: 06-시스템설계 §1 「배포 형태」 · min 14(k3s) · 같은 구역 29번
  • 담당: 박민호(PM·배포 스코핑) · 제기: 정어진 · 기한: k3s cutover 정리 후 / 스프린트 계획 시

31. 운영 백엔드가 09-08(d15806c)에 멈춰 있다 — k3s cutover 로 최신화 ✅ 해소 (2026.09.11, 4단계까지 전부 완료)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

32. supersub-api-trial 배포 매니페스트가 저장소에 없습니다 — ~/k3s-trial/에만 있습니다 (2026-09-11 신설) ✅ 해소 (2026.09.15)

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

33. 관리자 영상·분석 상태 페이지 신설 (www/admin/videos) — 사용자 요청 (2026-09-11)

사용자가 실패한 분석의 사유를 매번 SSH로 확인하지 않고 화면에서 보고 싶다고 요청. GET /admin/videos가 이미 있었고(jin 24) analysis_failure_reason 필드만 추가하면 됐습니다(4ee6290). 화면은 www/(백성검 영역)에 새로 만들었습니다(1803f01) — 남의 폴더라 www/AGENTS.md 먼저 읽고, /admin/users 와 같은 구조(검색 폼 + 표)로 맞췄습니다.

  • 백엔드: AdminVideoRow.analysis_failure_reason — failed일 때만 값, 그 외 null. 일반 사용자 화면(/videos)엔 안 실음(요청 범위 밖).
  • 화면: /admin/videos?user=<id|email> — 상태 배지 + 실패 사유 텍스트. /admin/users/[id] 에서 넘어가는 링크 추가.
  • ADMIN_EMAILS가 비어 있던 것도 같이 확인해 등록함(위 32번 관련 사례 참고 — Secret 쪽에 등록해야 실제로 반영됨).

백성검 님이 화면 스타일·구조를 다시 보셔도 됩니다 — 급한 기능이라 기존 /admin/users 패턴을 그대로 베꼈습니다.

  • 상세: fastapi/docs/api-contract.md(admin/videos 절) · 같은 구역 32번(Secret 드리프트)
  • 담당: 백성검(화면 검토, 원하면 스타일 조정) · 제기: 정어진(구현) · 기한: 급하지 않음

34. ✅ 해소 (2026.09.11) — 사용자가 화면에서 직접 잡은 버그 둘, 양쪽 다 고쳤습니다

  • 위치: pending-archive.markdown의 ## jin 구역으로 이동됨

35. 지인 찾기 실제 검색·상호 지인 신청·폴링 알림을 만들었습니다 (2026-09-15 신설)

사용자 요청 — 스쿼드 판 “지인 찾기”가 하드코딩 배열(SquadFriends.tsx의 FRIENDS 상수)이었던 것을 실제 기능으로 바꿨습니다. 상호 관계(신청→수락)로, 알림은 재사용 가능한 일반 인프라로 만들었습니다.

만든 것

   
user.nickname 유일 제약 운영 DB 중복 0건 확인 후 추가. 가입·닉네임 변경 충돌은 409 NICKNAME_ALREADY_EXISTS
user.is_nickname_searchable 지인 검색 노출 스위치(기본 true), PATCH /me로 켬/끔. 용병 매칭의 is_searchable과는 다른 컬럼
GET /users/search?q= 닉네임으로 찾기(최대 20명, 본인·비공개 제외)
user_contact(새 테이블) + POST/GET /me/contacts, POST /me/contacts/{id}/accept, GET /me/contacts/requests 상호 지인 신청·수락. 메모는 신청자만 봄
notification(새 바운디드 컨텍스트) + GET /me/notifications, PATCH .../read 폴링 알림. 문구는 안 저장 — type+actor+subject로 클라이언트가 렌더링

컨텍스트 간 알림 생성은 포트를 안 만들고 원시 SQL로 직접 쓴다 — notification 테이블에 user_contact 쪽이 table()/column()으로 INSERT한다 (match가 남의 테이블을 원시 쿼리로 읽는 경계 판단을 쓰기에도 그대로 적용). 신청·알림 생성은 같은 트랜잭션.

부록 D 도메인 ①에 반영(35→37 테이블), 계약은 api-contract.md 3-12절, 클라이언트 반영 요청은 client-contract-changes.md 37번.

  • 확인: .venv/bin/pytest -q → 804 passed, skipped 0 · alembic upgrade head && alembic check 통과
  • 하나 짚어 둘 것: 이 작업 중 로컬 개발 DB에 닉네임 중복 테스트 계정이 수천 건 쌓여 있던 걸 발견했습니다(기존 DB 테스트들이 계정을 만들고 정리하지 않는 경우가 있어서 — 이번과 무관하게 오래전부터 쌓인 것). 유일 제약을 걸기 전에 로컬 DB에서 한 번 정리했고, 앞으로 같은 문제가 안 생기게 관련 테스트 픽스처들의 닉네임에 임의 접미사를 붙여 뒀습니다. 운영 DB는 애초에 중복이 없어 이 정리가 필요 없었습니다.

후속 과제(급하지 않음, 담당 정어진): match(경기 지원 수락)·team(팀 가입) 등 다른 컨텍스트에서 실제로 알림을 만들어 쓰는 배선은 이번에 안 했습니다 — 인프라만 만들었습니다. 필요해지면 각 컨텍스트가 user_contact와 같은 방식 (원시 SQL)으로 notification에 얹으면 됩니다.

✅ 화면 몫은 끝나 있었습니다 (2026.09.17, 백성검) — 이번에 새로 만든 것이 아니라 09-16 회차에 이미 붙어 있었습니다. SquadFriends.tsx 가 GET /users/search · POST /me/contacts · POST /me/contacts/{id}/accept · GET /me/contacts/requests 를 부르고, 알림은 lib/useNotifyInbox.ts 가 폴링합니다. 수락은 지인 판과 알림함 두 곳에서 됩니다. 노출 스위치 (is_nickname_searchable)는 me/SearchablePref.tsx 입니다.

  • 확인: 「먼저 확인」의 grep -n "FRIENDS = \[" www/src/components/SquadFriends.tsx → 0건(하드코딩 배열 없음). 성질 1·2·3 모두 실제 경로로 확인했습니다
  • ⚠️ 안 한 것 둘: ⑴ 409 NICKNAME_ALREADY_EXISTS 전용 문구가 없어 지금은 일반 오류로 보입니다(미리 막지는 않으니 「하지 말 것」에는 안 걸립니다) ⑵ flutter/ 는 안 붙였습니다 — 앱 스쿼드 판이 아직 서버 연결 전이라 빈 자리(+) 시트 자체가 없습니다. 그 배선을 할 때 같이 합니다

  • 담당: 백성검(SquadFriends.tsx 등 화면 배선 — client-contract-changes.md 37번 참고, flutter/도 필요하면) ✅ 해소 (2026.09.17, www 한정 — flutter/는 위 ⑵) · 정어진(위 후속 과제) · 제기: 정어진(사용자 요청) · 기한: 급하지 않음(화면 쪽) · 후속 과제는 다음 배포 작업 때

36. 프론트 반영 대기 목록 — 09-15·09-16에 낸 계약 변경, 전부 아직 mock임을 확인했습니다 (2026-09-16 신설, 저녁에 5건 추가)

09-15~09-16 세션에서 백엔드가 낸 계약 변경을 client-contract-changes.md의 항목별 “먼저 확인” 명령으로 오늘 직접 돌려 확인했습니다 — 사람이 여럿 겹쳐 낸 것이라 하나쯤 놓치기 쉬워서, 한자리에 모아 둡니다. 상세·엔드포인트 목록·”하지 말 것”은 전부 원문서에 있습니다 — 여기는 요약과 확인 결과만입니다.

# 무엇 오늘 확인한 상태
39 공개 영상 목록에 업로더 표시(paik 16번) ✅ 반영했습니다 (2026.09.17, 백성검) — 아래
40 경기 조건·지역·”맞는 상대” 후보(paik 18·19·20·21번) regions.ts의 REGIONS 60곳, teamMatch.ts의 TEAMS 7팀이 여전히 하드코딩
41 선수·내 영상 관절(skeleton)(paik 29번) motion/source.ts가 아직 브라우저 추출(extractMotion) 그대로 — 파일 자신이 “교체 지점”이라고 주석까지 남겨 둠
42 팀↔팀 경기 신청·알림·수락(paik 17번) teamMatch.ts의 applyToTeam()이 여전히 1.4초 뒤 가짜 {accepted:true} 리턴
43 남의 표시 등급 S~F(paik 25·26번) SquadSuggest.tsx의 SUGGESTIONS·gradeOfPlayer() mock 그대로

그 뒤로 더 쌓인 것 (2026-09-16 저녁 추가, 09-17 에 53번 추가)

같은 날 오후·저녁에 낸 계약 변경입니다. 아직 확인 명령을 안 돌렸습니다 — 위 여섯 건과 달리 갓 낸 것이라 「미반영」이 당연한 상태입니다.

🔴 51번(호칭)은 09-16 저녁에 실서버에서 되는 것까지 확인됐습니다(백성검 a5eeffb) — 이 표에서 남은 것은 나머지입니다.

# 무엇 화면이 할 일
48 같은 영상 재업로드 감지(ho 41번) ✅ 반영했습니다 (2026.09.17, 백성검) — 아래
49 팀 초대·수락(min 20번) 초대 단추·초대함. AI 추천 판(paik 27번)도 이 경로가 있어야 눌러서 데려올 수 있습니다
50 내려간 종목은 새로 못 받음(ho 39번) SPORT_NOT_AVAILABLE 을 「오타」가 아니라 「지금은 축구만」으로 안내. flutter 목업 종목 확인
51 사람이 직접 적는 호칭(paik 36번) 프로필에 적는 자리. 🔴 titles[].category 가 null 일 수 있음(그전엔 항상 문자열)
52 팀 이름·지역 수정(PATCH /teams/{id}) ✅ 반영했습니다 (2026.09.17, 백성검) — 아래
53 받은 초대에 팀 이름·부르는 자리·스쿼드 슬러그(paik 37번) ✅ 보내는 쪽을 붙였습니다 (2026.09.17, 백성검) — 판에 앉히면 초대가 나가고, 새로고침해도 남고, ⊗ 로 무릅니다. 받은 초대함 화면(수락/거절)은 아직입니다
54 팀 해체·주장 세우기(paik 35번) 팀 설정에 「팀 해체」, 구성원 목록에 「주장으로 세우기」. 🔴 해체된 팀은 404 가 아니라 200 입니다 — disbanded_at 으로 가릅니다
55 경기 신청에 두 팀 이름·지역(paik 31번) ✅ 반영했습니다 (2026.09.17, 백성검) — 붙박이 폴백을 걷고 서버 값을 씁니다. 시험이 「붙박이가 아니라 응답을 믿는다」를 잡습니다
56 추천 카드의 불릿 한두 줄(paik 33번) SquadSuggest.tsx 의 FLAVOR 붙박이를 걷습니다. 🔴 한 줄·null 둘 다 정상이고, 이름 아래 한 줄(계약 51)과는 다른 자리입니다

🔴 35번(지인 검색·상호 신청·알림, client-contract-changes.md 37번)은 이미 35번 자체에 담당 줄이 있어 여기 안 넣었습니다 — 중복해서 올리면 둘 중 하나만 고쳐질 때 어느 쪽이 최신인지 모르게 됩니다. (그 37번은 2026.09.17에 ✅ 로 닫았습니다 — 09-16 회차에 이미 붙어 있었고, 「먼저 확인」이 0건이었습니다. 자세한 것은 35번 항목에.)

✅ 48번 — 같은 영상 재업로드 안내 (2026.09.17, 백성검)

문구는 lib/duplicateNotice.ts 한 곳에서 만들고 올리는 화면 둘(프로필 · 분석)이 같이 씁니다. 🔴 막지 않습니다 — 안내지 차단이 아닙니다.

  • failed 면 사유를 함께, succeeded 면 「그때 결과를 그대로 씁니다」, 아직 도는 중이면 결과를 약속하지 않습니다(그 작업이 실패하면 거짓말이 됨)
  • 🔴 사유가 없으면 지어내지 않습니다 — 에이전트 문구가 아직 안 올 수 있어 그때는 「같은 이유」까지만 말합니다
  • 🔴 분석 화면에서는 안 올 때가 더 많습니다 — 「이 사람으로 분석」·「집중해서 볼 항목」을 지정한 업로드는 중복 판정에서 빠집니다(계약). 안 와도 버그가 아닙니다
  • ⚠️ mock 으로는 못 밟습니다 — 업로드 등록 라우트(app/api/videos/route.ts)가 게이트웨이를 안 거치고 callFastApi 로 직행합니다(기존 구조). 그래서 갈래는 단위·화면 시험이 붙듭니다
  • ⚠️ 성공했을 때도 보이도록 색을 갈랐습니다 — 빨강 그대로 두면 실패한 줄 알고 다시 올리게 되는데, 그 되풀이가 이 안내가 막으려는 것입니다
  • 확인: vitest run 796 passed(새 시험 7) · tsc 0건 · next build 통과 · eslint 27건 그대로

✅ 39번 — 공개 영상에 올린 사람 (2026.09.17, 백성검)

🔴 원인은 feedWith 가 「보는 사람 닉네임」 하나를 받아 공개 클립 전부에 붙이고 있던 것이었습니다. 목록에 남의 영상이 섞이면 그게 내 이름으로 그려집니다. 이제 줄마다 uploader_nickname 을 쓰고, uploader_card_slug 가 있으면 이름이 그 사람 카드로 가는 링크가 됩니다(없으면 링크만 안 그립니다).

  • 🔴 HomeFeed 가 보는 사람 닉네임을 더는 안 받습니다 — 손에 닿는 「나」가 없어야 같은 실수가 되살아나지 않습니다
  • 🔴 mock 에 남의 공개 영상 둘을 넣었습니다(하나는 카드 있음, 하나는 없음). mock 이 내 것만 주던 탓에 화면이 「남의 것」이라는 경우를 영영 못 만났고, 그래서 이 버그가 개발에서 안 보였습니다
  • 확인: 계약 39의 「먼저 확인」 grep -n "uploader_nickname" www/src/lib/feed.ts www/src/components/HomeFeed.tsx → 걸립니다. vitest run 789 passed (새 시험 3) · tsc 0건 · next build 통과 · eslint 27건 그대로

✅ 52번 — 팀 이름·지역 수정 (2026.09.17, 백성검)

/me 「소속」의 팀 줄에 주장에게만 「수정」을 냈습니다(접기 폼 — 팀 만들기와 같은 방식). PATCH /api/teams/{id} → updateTeam 으로 내려갑니다.

  • 🔴 바뀐 것만 싣습니다. null 은 「지우기」가 아니라 422 라, 안 바꾼 필드는 아예 뺍니다. 둘 다 그대로면 서버를 아예 안 부릅니다
  • 🔴 sport_code 는 화면에도 본문에도 없습니다 — 포지션·스쿼드·경기가 그 값에 매달려 있습니다
  • 🔴 지역을 목록에서 고르게 바꿨습니다 — 팀 만들기도 함께(사용자 판단). 전에는 둘 다 자유 입력이라 「서울 강남」처럼 적히면 GET /matches?region= 의 어휘(서울 강남구)와 안 맞아 고쳐도 여전히 탐색에서 빠졌습니다. 경기 조건 판과 같은 searchRegions 를 씁니다. ⚠️ 그래서 목록에 없는 동네는 이제 못 적습니다(대조 가능성과 맞바꾼 값 — lib/regions.ts 의 기존 판단과 같습니다). 지역 목록을 서버가 주는 경로가 생기면 그 파일만 갈아 끼웁니다
  • 🔴 mock 도 계약만큼 엄하게 했습니다 — 주장 아니면 403, null·빈 값은 422, sport_code 는 안 받습니다. 시험으로 잠갔습니다(mock 이 다시 너그러워지는 것이 09-16에 세 번 데인 그 자리입니다)
  • 확인: vitest run 776 passed · tsc --noEmit 0건 · next build 통과 (/api/teams/[teamId] 등록됨) · eslint 내 파일 0건

  • 확인: 6건 각각의 “먼저 확인” 명령을 2026-09-16에 직접 실행 — 전부 아직 mock 흔적이 그대로 걸렸습니다(이미 다른 브랜치에서 착수했다면 이 목록이 낡은 것일 수 있습니다 — 그럴 땐 손대지 말고 이 항목만 닫아 주세요)
  • 하지 말 것: 여기서 직접 고치지 마세요 — 이 항목은 포인터입니다. 실제 작업 지시(엔드포인트·확인 명령·하지 말 것)는 각 번호의 client-contract-changes.md 절에서 그대로 따릅니다
  • 담당: 백성검 · 제기: 정어진 · 기한: 급하지 않음(순서는 백성검 판단)

37. 앞단이 Cloudflare 입니다 — 문서에 없었고, 그 때문에 두 가지가 딸려 있습니다 (2026-09-17 신설)

  • 담당: 박민호(Cloudflare 규칙 · k3s 파드 인자) · 제기: 정어진 · 기한: 🔴 봇 차단 규칙은 재발 방지라 급하지 않지만, 아래 (나)는 지금 실제로 일어나고 있습니다

ho 53번(정상호)을 확인하다가 그 항목의 전제와 다른 것을 찾았습니다. 정상호 님은 「앞단 규칙 자체는 정어진 영역」이라고 적으셨는데, 제 영역이 아닙니다 — 저는 EC2·nginx·배포까지이고 Cloudflare 계정·대시보드에 접근 수단이 없습니다(이 PC에도 저장소에도 없습니다). 그래서 이쪽으로 올립니다.

먼저 — ho 53번의 막힘 자체는 이미 풀렸습니다

정상호 님의 UA 수정분이 GPU 인스턴스에 반영돼 있습니다. 2026-09-17 11:45 (KST) 기준으로 워커가 POST /internal/analysis-jobs/claim 을 5초 간격으로 204 로 받고 있습니다. 🔴 더 급한 일로 보고 손대실 필요는 없습니다.

(가) 봇 차단 규칙 — 재발 방지가 남았습니다

정상호 님 진단이 그대로 재현됩니다(공개 경로에 GET 으로만 확인했습니다 — claim 은 POST 로 부르면 작업을 소비합니다).

보낸 UA 결과
Python-urllib/3.12 403 (server: cloudflare)
python-urllib/3.12 (소문자) 405 — 통과
supersub-worker/1.0 405 — 통과
오리진 직접(파드) 405 — 앱은 멀쩡합니다

제 판단은 「/api/v1/internal/* 를 봇 차단 대상에서 제외」쪽입니다. 워커 IP 허용은 GPU 박스에 탄력적 IP 가 없어서(설계입니다) 인스턴스를 켤 때마다 손이 갑니다. 그 경로는 이미 X-Worker-Token 으로 인증하고, UA 를 바꾼 것은 정상호 님 말씀대로 규칙이 조금만 넓어지면 그대로 재발합니다.

🔴 (나) client 가 Cloudflare 엣지 주소로 찍힙니다 — 지금 일어나는 일입니다

fastapi/docs/deployment.md 4절이 🔴 로 경고해 둔 바로 그 상황입니다: 「X-Forwarded-For 를 신뢰하지 않으면 요청 제한(SEC-009)의 키가 전부 LB 주소가 되어 모든 사용자가 한 덩어리로 묶인다」.

파드 로그의 client= 가 Cloudflare 대역으로만 찍힙니다(같은 엣지 주소에 수십 건). 즉 요청 제한과 인증 로그가 사람별이 아니라 엣지별로 묶입니다.

원인은 둘이고 둘 다 그쪽 구역입니다.

  1. nginx 에 원주소 복원이 없습니다 — set_real_ip_from(Cloudflare 대역)· real_ip_header CF-Connecting-IP 가 한 줄도 없습니다. 그래서 nginx 가 보는 $remote_addr 자체가 Cloudflare 입니다
  2. k3s 파드 인자에 --proxy-headers·--forwarded-allow-ips 가 없습니다 — systemd 시절에는 드롭인에 있었는데(그 문서 절에 그렇게 적혀 있습니다) k3s 이관 때 딸려오지 않았습니다. 지금 파드는 uvicorn app.main:app --host 0.0.0.0 --port 8080 뿐입니다

🔴 문서의 확인 명령이 낡아서 이걸 못 잡고 있었습니다 — systemctl cat supersub-api | grep proxy-headers 인데 systemd 는 이제 inactive 입니다(이관 완료라 정상입니다). k3s 기준으로 고쳐 두었습니다 (아래 문서 정정).

확인

# (가) 403 이 아니라 405 면 앞단을 통과한 것입니다
curl -s -o /dev/null -w "%{http_code}\n" -X GET \
  -A "Python-urllib/3.12" "https://<API 호스트>/api/v1/internal/analysis-jobs/claim"

# (나) client 가 Cloudflare 대역이 아니라 실제 사용자 주소로 찍히면 해결입니다
sudo k3s kubectl logs deploy/supersub-api-trial --tail=200 \
  | grep -o "client=[^ ]*" | sort | uniq -c

🔴 하지 말 것

  • 위 curl 을 -X POST 로 바꾸지 마십시오 — claim 은 부를 때마다 작업 하나를 running 으로 넘기고 회수 규칙이 아직 없습니다(정상호 님이 ho 53번에 적어 두신 것과 같습니다)
  • (나)를 고칠 때 --forwarded-allow-ips 를 * 로 열지 마십시오 — 클라이언트가 X-Forwarded-For 를 위조해 요청 제한을 우회합니다 (deployment.md 4절). 신뢰 범위는 nginx 쪽만입니다
  • 워커 UA 를 되돌리지 마십시오 — 자기 이름을 밝히는 것이 원래 맞습니다

  • 관련: ho 53번(원 제기) · 문서는 fastapi/docs/deployment.md 4절에 이번에 정정했습니다(Cloudflare 가 앞에 있다는 사실이 그 문서에 없었습니다)

38. 「시스템 설계」장 3·4절이 실물과 어긋납니다 — 1절만 고쳤고 3·4절은 정상호 님 영역이라 남겼습니다 (2026-09-17 신설) ✅ 해소 (2026.09.18)

  • 담당: 정상호 ✅ 고쳤습니다 (2026.09.18) · 제기: 정어진 · 기한: 완료

셋 다 고쳤습니다. (1) 3절 (A) 를 30fps 로 — 왜 바꿨는지(짧은 각속도 피크가 프레임 사이로 빠져 같은 영상이 촬영 프레임레이트에 따라 다른 점수를 받았다)를 한 줄 붙였습니다. (2) 같은 문단에 대상 선수 지정을 넣고 「가장 큰 박스」는 지정이 없을 때만 쓰는 것으로 고쳤습니다 — 지정한 대상을 끝까지 따라갔는지 결과에 함께 싣는 것도 적었습니다. (3) 4절 표를 09-15 실측(100 / 300프레임 두 벌, 합 72초 / 125초)으로 갈고, 판정 동시 호출(12.6→4.0초)·미리보기 인코딩 (28.1→3.5초)을 덧붙였습니다. 🔴 1절은 안 건드렸습니다.

  • 확인: grep -nE '15fps|미측정' jekyll/chapters/06-시스템설계.markdown → 0건
  • 🔴 찾아 주신 것 말고 하나 더 고쳤습니다 — 표의 「항목 5개」가 실물은 6개입니다

사용자 요청으로 시스템 설계 1절(구성도) 을 09-17 실물로 다시 썼습니다(49b5c4b). 그 장의 3절(영상 분석 파이프라인)·4절(AI 에이전트) 은 08-25에 정상호 님이 쓰신 부분이라 손대지 않았고, 읽다가 실물과 다른 곳 셋을 찾아 올립니다.

06장 서술 지금 실물 근거
3절 (A) 「원본 프레임을 15fps로 다운샘플링」 30fps agent/src/supersub_agent/pose.py 의 DEFAULT_TARGET_FPS = 30 · 결정 기록 09-02 「15에서 30으로」
3절 (A) 「다중 인원이면 가장 큰 사람 박스를 대상 선수로」 사용자가 대상을 지정할 수 있고, 가장 큰 박스는 지정이 없을 때(auto)만 쓴다 pose.py 의 "auto" 분기 · 검출 작업(미결 ho 44번)
4절 「실행 구조와 성능」 표의 포즈 추출 「미측정」 09-15에 쟀다 개발 로그 「2분의 내역을 쟀습니다」(2026-09-15)

만족해야 할 성질

06장 3·4절이 지금 파이프라인과 같은 말을 할 것. 위 셋은 제가 본 것이고 전부는 아닐 수 있습니다 — 판정 모델(EXAONE 4.0 1.2B)은 그대로인 것을 확인했습니다.

확인

grep -nE '15fps|미측정' jekyll/chapters/06-시스템설계.markdown   # 0건이면 반영된 것

하지 말 것

  • 1절을 다시 고치지 마십시오 — 09-17 실물로 막 갱신했습니다. 3·4절에서 1절을 가리킬 일이 생기면 링크만 두면 됩니다
  • 이 항목은 서술 갱신입니다 — 파이프라인 동작을 바꾸자는 요청이 아닙니다

39. 미결 항목을 줄이는 방법을 하나로 합치자는 제안입니다 — 접기를 빼고, 닫으면 요약해 바로 옮깁니다 (2026-09-17 신설)

ho 51번(「여러분 구역도 제가 접어도 됩니까」)에 답하려고 보니, 누가 줄이느냐보다 방법이 구역마다 갈라진 것이 문제였습니다. 팀 CLAUDE.md 「미결 항목」 절에는 줄이는 방법이 세 단계(접기 → 압축 → 아카이브)로 있고, 시점이 「자유」·「강제가 아닙니다」라 같은 규칙을 읽고도 세션마다 결과가 달랐습니다.

지금 갈라진 모양 (2026-09-17, jin 브랜치 기준)

  ho paik min jin
닫혔는데 제자리에 남은 항목 5 8 2 0 (21·32번을 이 커밋에서 옮겼습니다)
아카이브 항목 평균 길이 67줄 51줄 54줄 11줄
접힌 채 아카이브로 간 항목 2 (10·27번) 0 1 (11번, 371줄) 0
  • 규칙은 「압축하면서 접기 태그를 지운다 → 그래도 무거우면 옮긴다」인데, 압축을 건너뛰고 통째로 옮긴 경우가 많습니다. 강제가 아니고 검사가 없어서입니다
  • 이 파일은 1만 줄이 넘고, 그중 3,287줄이 접혀 있습니다(전부 ho 구역, 하나만 빼고 열린 항목)

접기가 해결하지 못하는 것

  • Claude 세션에는 효과가 없습니다. 세션은 렌더된 화면이 아니라 원본 텍스트를 읽어서 접힌 줄도 전부 읽습니다. 파일 길이·병합 부담도 그대로입니다 — 접기는 사람이 GitHub· 사이트로 볼 때만 짧아집니다
  • 「접힌 곳은 지난 기록」이라는 신호가 틀리면 놓칩니다. 규칙은 「정정·결정은 접기 밖에」인데 검사 명령은 담당 줄만 봅니다. 지금 접힌 안에 정정·결정 소제목이 4개 있습니다(ho 5·37·47번) — 그 회차 안의 정정일 수 있어 위반인지는 판단하지 않았습니다. 다만 접힌 곳을 건너뛰고 읽는 세션은 이것을 모르고 지나갑니다
  • 접기가 실제로 필요한 것은 열린 채 길어진 항목인데, 그건 이미 있는 규칙(「항목에는 요약과 링크만 두고 상세는 원래 문서에」)으로 풀립니다. 회차 기록의 원래 자리도 이미 있습니다 — agent/eval/<회차>/RESULTS.md

제안 — 방법을 하나로

상황 할 일
열린 항목이 길어질 때 접지 않습니다. 회차 기록·조사 상세·명령 출력은 원래 문서(agent/eval/·fastapi/docs/·개발 로그)에 두고, 항목에는 지금 상태·결정·정정·하지 말 것·담당·링크만 남깁니다
항목을 닫을 때 같은 커밋에서 1~5줄(설계 판단이 많은 항목은 10~15줄)로 요약해 pending-archive.markdown 으로 옮기고, 제자리엔 제목과 위치 한 줄만 둡니다. 「시점은 자유」·「몇 개뿐이면 안 옮겨도」를 없앱니다 — 판단할 것이 없어야 세션마다 같게 나옵니다
이미 접힌 것 새로 접지 않습니다. 기존 접기는 그 항목을 닫을 때 요약하면서 사라집니다 — 지금 한꺼번에 정리하자는 것이 아닙니다

🔴 구역 규칙은 그대로입니다 — 각자 자기 구역만 합니다. 바뀌는 것은 「누가」가 아니라 「어떻게」입니다.

확인

합의되면 세션 시작 때와 커밋 전에 돌립니다. 지금 값은 ho 5 · paik 8 · min 2 와 3 입니다.

# 닫혔는데 제자리에 남은 항목 — 아무것도 안 나와야 합니다
awk '/^## [a-z]+ \(/{z=$2} /^### /{if(c&&!p)n[zz]++; c=($0~/✅/); p=0; zz=z} /^- \*\*위치\*\*: `pending-archive/{p=1} END{if(c&&!p)n[zz]++; for(k in n) print k, n[k]}' jekyll/pages/pending.markdown

# 아카이브에 남은 접기 — 0 이어야 합니다
grep -c '^<details' jekyll/pages/pending-archive.markdown

하지 말 것

  • 남의 구역을 대신 정리하지 않습니다 — 병합 충돌이 되살아납니다
  • 접힌 내용을 요약 없이 지우지 않습니다 — 결정·정정·판단 근거는 요약에 남기거나 원래 문서로 옮긴 뒤에 지웁니다
  • 합의 전에는 CLAUDE.md 를 고치지 않습니다 — 합의되면 제가 「미결 항목」 절의 세 곳 (「처리했으면」의 접기 줄 · 「끝난 회차는 접습니다」 절 · 압축 절의 「접기와 함께 씁니다」)을 고치고, 위 확인 명령을 규칙에 넣습니다

  • 관련: 같은 파일 ho 51번(접기 규칙 신설 — 이 항목이 그 질문에 대한 jin 의 답을 겸합니다)
  • 담당: 정상호(접기 규칙 제기자) ✅ 답했습니다 (2026.09.18, 아래) · 박민호 · 백성검 — 찬성·반대·의견 한 줄씩 · 제기: 정어진 · 기한: 급하지 않음(지금 깨지는 것은 없습니다 — 구역마다 방법이 갈라져 있는 것이 문제입니다)

정상호 — 찬성합니다 (2026.09.18)

접기를 제안한 사람으로서 틀렸던 곳을 먼저 인정합니다. 제가 근거로 든 것은 「1,644줄짜리 항목은 브라우저로 훑을 수 없다」였는데, 그건 사람이 볼 때만 참입니다. 세션은 원본을 읽으므로 접힌 줄을 전부 읽고, 파일 길이·병합 부담도 그대로입니다 — 줄이려던 비용 셋 중 둘에 효과가 없었습니다. 지금 접힌 3,287줄이 전부 제 구역이라는 것도 그 증거로 봅니다.

  • 닫을 때 같은 커밋에서 요약·이동에 찬성합니다. 「시점은 자유」가 제가 넣은 문구인데, 실제로는 아무도 안 옮기는 쪽으로 작동했습니다(그래서 13,613줄까지 불어났습니다). 판단할 것이 없어야 같게 나온다는 진단에 동의합니다
  • 기존 접기를 지금 한꺼번에 걷지 않는 것에도 찬성합니다 — 닫을 때 요약하며 사라지는 편이 안전합니다
  • 🔴 지적하신 「접힌 안의 정정·결정 4개」는 셋(631·5799·7683줄)이었고, 전부 바깥에 같은 내용이 이미 있습니다 — 47번은 제목이 결론을 싣고 있고 (「torchvision 이 빠져 전처리기가 갈렸습니다」), 37번은 담당 줄이 지금 상태를 싣고 있습니다(둘 다 막힘·표본 0편). 5번의 「되돌린 결정은 유지한다」는 회차 안의 서술이고 되돌림 자체는 바깥에 있습니다. 새는 것은 못 찾았습니다
  • 제 몫: 합의되면 제 구역의 열린 항목부터 「상세는 agent/eval/<회차>/, 항목에는 상태·결정·링크」로 줄이겠습니다 — 회차 문서는 이미 다 있습니다

40. 사이트를 주제로 다시 나눴습니다 — 기획 · 설계 · 기술 · 프로젝트 관리 · 팀 작업 공간 (2026-09-17 신설)

멘토 한 분이 「개발 진행」(III 부)이 난잡하다, 관리·기술·구성도(ERD)를 따로 나누면 좋겠다는 의견을 주셨습니다. 확인해 보니 III 부만의 문제가 아니었습니다 — 사이트가 문서 종류(I 부 제안 / II 부 계획 / III 부 진행)로 나뉘어 있어 같은 주제가 여러 부에 흩어져 있었습니다.

주제 흩어져 있던 곳
시스템 구성 시스템 설계 1절 · 프로젝트 전체 그림 2절 · 백엔드·파이프라인 2절
일정·진행 개발 구현 계획 3·4절 · 스프린트 일지 · 프로젝트 전체 그림 4·6절
결정 부록 E 결정 기록 · 프로젝트 전체 그림 5절
테스트 테스트 및 검증 계획 · QA 체크리스트 · 백엔드·파이프라인 3절

사용자 요청으로 1단계(틀 옮기기)를 했습니다. 멘토께 다시 보여 드리고 조정할 예정이라 구조는 바뀔 수 있습니다.

바뀐 사이드바

그룹 페이지
(최상위) 프로젝트 전체 그림 — 처음 보는 사람의 입구 한 장으로 줄였습니다
기획 사업 개요 · 현황 및 문제 정의 · 서비스 제안 · 시장 및 수익 모델 · 요구사항 분석
설계 시스템 설계 · 데이터베이스 ERD
기술 백엔드 · 파이프라인 · 결정 기록
프로젝트 관리 개발 구현 계획(스프린트 일지) · 테스트 및 검증 계획(QA 체크리스트) · 산출물 및 향후 계획 · 진행 현황(신설)
팀 작업 공간 개발 로그 · 미결 항목 · 남은 작업 로드맵 · 정확도 확보 단계
부록 용어 정의 · 관련 서식 · 참고 자료
  • 머리말(parent·nav_order)만 바꿨습니다. 주소(permalink)·파일 위치·본문은 그대로라 미결 항목·개발 로그의 링크가 안 깨집니다
  • 옛 I·II·III 부 묶음 페이지는 지웠습니다 — 그 주소를 가리키는 곳이 0건인 것을 확인했습니다
  • 「프로젝트 전체 그림」의 지나온 길 · 처음 설계에서 달라진 것 · 누가 어디를 만들었나는 「진행 현황」(프로젝트 관리 아래)으로 옮겼습니다
  • 목차(/toc/)와 CLAUDE.md 「저장소 구조」·「상위 그룹 랜딩 페이지」·스프린트 로그 규칙을 새 구조로 고쳤습니다

2단계 — 각자 영역의 내용 정리 (부탁드립니다)

틀만 옮겨서 한 페이지 안에 주제가 섞인 곳이 남아 있습니다. 제 영역이 아니라 손대지 않았습니다.

누구 무엇
박민호 구조에 동의하시는지 · main 병합 때 아래 확인 · 개발 구현 계획의 칸반이 Sprint 2(09.01~09.14)에 멈춰 있고 스프린트 3 일지가 없습니다 · 같은 장 1·2절(기술 스택·기능 구현 방안)을 「기술」로 옮길지 · 표지의 「개발 제안서」 문구를 그대로 둘지
정상호 「기술」에 AI 분석 페이지를 둘지 — 지금은 시스템 설계 3·4절과 서비스 제안 3·4절에 있습니다 · 남은 작업 로드맵·라벨 수집 단계를 「팀 작업 공간」으로 옮긴 것이 괜찮은지
백성검 「기술」에 웹·앱 페이지를 둘지 — 화면 설계는 지금 시스템 설계 5절에 있습니다

확인

grep -rnE '^(grand_)?parent: (I|II|III) 부' jekyll/   # 0건 — 옛 부 이름을 쓰면 사이드바에서 사라집니다

그리고 bundle exec jekyll build 뒤 사이드바에 위 일곱 묶음이 보이는지.

하지 말 것

  • 새 페이지의 parent 에 옛 부 이름을 쓰지 마십시오 — 그 묶음 페이지가 없어 사이드바에서 사라집니다. 특히 스프린트 3 일지는 grand_parent: 프로젝트 관리 입니다
  • 주소(permalink)를 바꾸지 마십시오 — 미결 항목·개발 로그가 그 주소로 서로를 가리킵니다
  • 파일을 폴더째 옮기지 마십시오 — 다른 브랜치와 충돌만 늘고, 사이드바 위치는 머리말이 정합니다

  • 담당: 박민호(구조 동의 · 병합 · 관리 페이지) · 정상호(AI 분석 페이지) ✅ 냈습니다 (2026.09.18, 아래) · 백성검(웹·앱 페이지) · 제기: 정어진(멘토 의견 · 사용자 요청) · 기한: 병합 전에 한 번 봐 주세요 — 멘토 재평가 뒤 구조는 조정할 수 있습니다

정상호 회신 — 둡니다. AI 분석 을 만들었습니다 (2026.09.18)

구조에 찬성합니다. 물어보신 둘에 답합니다.

  • 「기술」에 AI 분석 페이지를 둘지 → 뒀습니다 (기술 아래 nav_order: 2, 백엔드·파이프라인 다음). 다만 시스템 설계 3·4절을 옮겨 오지는 않았습니다 — 가른 기준은 이렇습니다: 무엇으로 이루어졌는가는 「설계」, 무엇에 막혔고 어떻게 풀었는가는 「기술」. 그래서 새 페이지에는 겪은 문제와 그때의 판단을 썼습니다 (프레임레이트 의존 · 엉뚱한 사람을 잰 것 · 촬영 방향이 섞이는 것 · 같은 코드가 같은 결과를 안 낸 것 · 설명 문장의 방향 오독 · 8GB 제약에서 나온 판단들). 겹침이 걱정되시면 설계 쪽을 요약으로 더 줄이는 것도 가능합니다 — 말씀 주세요
  • 로드맵·정확도 확보 단계를 「팀 작업 공간」으로 → 괜찮습니다. 둘 다 발표용이 아니라 일하면서 보는 문서가 맞습니다. 로드맵은 그 성격을 문서 안에도 적어 뒀습니다(정본은 미결 항목이고 로드맵은 진입점)
  • 「기술」 랜딩의 안내 문단도 새 상태로 고쳤습니다
  • 🔴 주소·파일 위치·1절은 안 건드렸습니다. 새 파일 하나(jekyll/progress/)만 늘었고 grep '^(grand_)?parent: (I|II|III) 부' 0건, jekyll build 통과

같이 처리한 것: 38번(3·4절이 실물과 어긋남)도 같은 회차에서 닫았습니다 — 30fps · 대상 선수 지정 · 09-15 실측 표로 갈았습니다.

41. 사이트 그림·ERD 생성기를 저장소로 올렸습니다 (tools/) — 지금까지 제 PC 에만 있었습니다 (2026-09-18 신설)

  • 담당: 박민호(_config.yml·CLAUDE.md 변경 확인 · 미리보기 서비스 재시작) · 정상호(자기 페이지를 손으로 고치고 있었다면) ✅ 답했습니다 (2026.09.18, 아래) · 백성검(자기 페이지를 손으로 고치고 있었다면 알려 주세요) · 제기: 정어진(사용자 지적) · 기한: 급하지 않음 (지금 깨지는 것은 없습니다)

무엇이 문제였나. 아래 페이지들의 그림과 수치는 손으로 쓴 것이 아니라 스크립트가 만든 것인데, 그 스크립트가 저장소 밖(git 추적 대상이 아닌 제 개인 작업 폴더)에만 있었습니다. 페이지는 팀 것인데 고칠 방법은 저만 갖고 있던 셈이고, 제 PC 가 없어지면 아무도 다시 만들 수 없었습니다.

생성기 만드는 것
tools/pages/gen_overview.py /10-프로젝트전체그림/ · /12-진행현황/
tools/pages/gen_backend.py /11-백엔드파이프라인/
tools/pages/gen_system_design.py /06-시스템설계/ 의 1절만
tools/erd/dump_schema.py + gen_erd.py 부록 D 의 ERD 그림 9장(assets/erd/)

무엇을 했나. tools/ 로 옮기고, 여섯 파일 전부에 박혀 있던 제 PC 절대경로를 걷어냈습니다(이제 스크립트가 자기 위치에서 저장소 루트를 구합니다 — 어느 PC 어느 디렉터리에서 돌려도 같습니다). 쓰는 법·함정은 tools/README.md 에 있고, 루트 CLAUDE.md 「저장소 구조」에 한 줄 걸어 뒀습니다.

_config.yml 의 exclude: 에 tools/ 를 넣었습니다 — 발행은 안 되고 git 에는 그대로 있습니다(fastapi/·www/ 와 같은 처리). 🔴 박민호: _config.yml 이 바뀌었으니 미리보기 서비스 재시작이 필요합니다(sudo systemctl restart supersub-preview.service).

만족해야 할 성질

  • 위 표의 페이지·그림을 바꿀 일이 생기면 페이지를 직접 고치지 않고 생성기를 고쳐 다시 만든다. 직접 고치면 다음 실행 때 조용히 덮입니다
  • 생성기를 돌리기 전에 지금 페이지를 그대로 재현하는지 먼저 확인한다 (README.md 의 「돌리기 전에」 — 어긋나 있으면 남의 수정이 사라집니다)

확인

git status --porcelain && python3 tools/pages/gen_overview.py \
  && python3 tools/pages/gen_backend.py && python3 tools/pages/gen_system_design.py \
  && git status --porcelain

마지막 줄이 비어 있으면 생성기와 페이지가 일치합니다 (2026-09-18 확인: 셋 다 일치, 다른 디렉터리에서 돌려도 같음). ERD 쪽은 다시 돌리면 내용이 같아도 바이트 diff 가 나므로 tools/erd/compare_svg.py 로 내용을 비교합니다 — 이유와 방법은 README 에 있습니다.

하지 말 것

  • tools/erd/gen_appendix_d.py 를 돌리지 마십시오 — 부록 D 본문을 2026-09-17 에 한 번 고친 일회성 스크립트라 지금은 AssertionError 로 멈춥니다(저장소는 안 건드립니다). D.3·D.7 표를 어떻게 계산했는지 남기려고 기록으로만 뒀습니다
  • ERD 그림을 다시 돌린 뒤 9장을 통째로 커밋하지 마십시오 — 순서만 바뀐 것이 섞여 있어 나중에 진짜 변경을 못 찾습니다. 내용이 바뀐 것만 올립니다
  • 제 개인 작업 파일은 옮기지 않았습니다 — 공유 대상이 아닙니다. 시연 더미 운영용 일회성 스크립트도 시연이 끝나면 버릴 것이라 그대로 두었습니다

정상호 회신 — 고친 것이 하나 있는데 생성기 구역 밖입니다 (2026.09.18)

물어보신 것에 답합니다. 충돌 없습니다 — 짐작이 아니라 적어 주신 확인 명령을 돌려서 봤습니다.

git status --porcelain && python3 tools/pages/gen_overview.py \
  && python3 tools/pages/gen_backend.py && python3 tools/pages/gen_system_design.py \
  && git status --porcelain

셋 다 ok, 마지막 줄 비었습니다 — 병합 직후 상태에서도 생성기가 지금 페이지를 그대로 재현합니다.

제가 손으로 고친 것: 06-시스템설계 의 3·4절입니다(0a051292, jin 38번을 닫으면서 — 15fps → 30fps · 「가장 큰 박스」는 지정이 없을 때만 · 4절 성능표를 09-15 실측으로). 🔴 1절은 안 건드렸습니다 — 그 커밋의 diff hunk 가 89행·267행이라 1절 범위(9~64행) 밖입니다.

그래서 겹치지 않습니다. gen_system_design.py 가 스스로 적어 두신 대로 ## 1) 시스템 구성도 부터 ## 2) 데이터베이스 설계 직전까지만 갈아 끼우고, 머리말에 「3·4절은 정상호 영역이라 건드리지 않는다」고 명시돼 있습니다. 경계를 제가 오해할 여지가 없었습니다.

성질 둘에 동의합니다 — 위 표의 페이지는 앞으로 생성기를 고쳐서 다시 만들고, 돌리기 전에 재현 확인을 먼저 하겠습니다.

🔴 하나 보탭니다 — 경계가 깨지는 방식이 지금은 안전합니다. gen_system_design.py 는 s.index("## 2) 데이터베이스 설계") 로 끝을 찾습니다. 2절 제목이 바뀌면 ValueError 로 멈춥니다 — 조용히 3·4절까지 먹지 않습니다. 🔴 그러니 나중에 여기에 try/except 나 「못 찾으면 파일 끝까지」 같은 폴백을 넣지 마십시오. 시끄럽게 멈추는 것이 이 자리에서는 맞습니다 — 폴백이 생기는 순간 제 3·4절이 경고 없이 사라질 수 있습니다.

42. 팀 매칭이 아무에게도 안 잡히던 원인 + 웹 다섯 군데 (2026-09-18 신설)

  • 담당: 백성검(www/ 변경 다섯 — 아래 「봐 주실 것」) · 박민호(main 병합) ✅ 사용자 승인으로 정어진이 병합했습니다 (2026.09.18, c1fa155) · 제기: 정어진(사용자 지적) · 기한: 급하지 않음 (지금 깨지는 것은 없습니다 — 이미 고쳐서 올렸습니다)

🔴 박민호: main 병합은 이미 했습니다 — 이 건은 사용자가 그 자리에서 승인해서 제가 진행했습니다(평소처럼 넘기지 않았습니다). 파드 교체(rollout restart)는 아직입니다 — CI 가 이미지를 밀어 올릴 뿐 CD 단계가 없습니다.

🔴 여러분 팀의 판에서 시연 더미 등재를 걷었습니다 (2026-09-18, 사용자 지시)

시연용 더미(showcase-*)가 여러 팀의 홈 판에 미리 앉아 있었습니다. 사용자가 「들어오자마자 더미가 자리를 잡고 있다, 비어 있게 해 달라」고 해서 등재 28건을 지웠습니다.

팀 지운 뒤 판에 남은 사람
ㅈㅂㄷ (dkakclgkfn) 주장 본인 1명
하이미디어 (정상호) 정상호 1명
FC 강남 (bsgum5373) 주장 본인 + 정상호 — 진짜 사람은 안 건드렸습니다
ㅇㅅㅇ (정어진) 정어진 1명
심사위원 FC 심사위원 본인 1명
  • 지운 것: squad_member 중 주인이 showcase-*@super-sub.example 인 행만
  • 안 건드린 것: 진짜 사람의 등재 · team_member(팀 소속) · 더미 계정·카드·영상. 🔴 소속은 남아 있으니 원하면 다시 앉히실 수 있습니다
  • 🔴 그 결과 팀 매칭 후보가 다시 0곳입니다(사용자가 알고 결정). 후보의 두 번째 하드 필터가 「로스터가 판 크기만큼 찼는가」라, 판이 1명이면 어느 팀도 통과하지 못합니다. 시연 때 후보를 보시려면 판을 5명까지 채우셔야 합니다
  • 대기중인 더미 초대는 0건이라 그냥 두면 다시 안 앉습니다(초대를 새로 보내면 자동 수락 봇이 받아 다시 앉을 수 있습니다)

🔴 남의 구역(www/)을 직접 고쳤습니다 — 사용자 지시입니다. 계약이 바뀐 것은 없고 화면 동작이 바뀌었습니다. 되돌리고 싶은 것이 있으면 말씀해 주세요.

원인 — 화면의 기본값이 저장된 적이 없었습니다

사용자가 실서버에서 「팀 매칭이 전혀 탐색이 안 된다」고 짚었습니다.

홈 판은 formation 이 없어도 기본 판(5:5)을 켜진 것처럼 그립니다. 저장은 크기 단추를 눌러 바꿀 때만 일어나는데, 이미 켜져 보이니 아무도 누르지 않았습니다. 그래서 실제 DB 는 운영 7팀 중 6팀이 NULL 이었습니다. 「맞는 상대」의 첫 하드 필터가 formation 동등 비교라 NULL 인 팀끼리는 서로를 못 보고, 내 팀이 NULL 이면 질의도 못 가고 즉시 빈 목록입니다.

  • 백엔드: 새 스쿼드가 기본 판을 갖고 시작합니다(계약 3-7절 · CCC 61번). 🔴 옛 스쿼드는 여전히 null 이니 「모르면 기본 판」 처리를 걷지 마십시오
  • 운영 데이터: NULL 이던 스쿼드를 전부 채웠습니다(2026-09-18)

봐 주실 것 — www/ 에서 바꾼 다섯

무엇 왜
「팀 찾는 중」이 머리칸에 남습니다 다른 화면으로 가도 찾기가 안 끊기게(사용자 요청). 조건은 서버 그대로이고, 브라우저에는 「시작했고 아직 안 그만뒀다」 한 칸만 둡니다
「경기 잡힘」이 머리칸에 남습니다 전에는 「방금 잡혔다」가 탭의 기억에만 있어 새로고침하면 그 화면으로 돌아갈 길이 없었습니다. 이제 서버의 확정 경기가 근거입니다. 🔴 저절로 안 뜨고 누를 때만 뜹니다
대기 화면의 우리 팀 이름 🔴 홈 판이 보여 주는 팀에서 가져오고 있었습니다. 한 사람이 여러 팀에 속할 수 있어서, 운영에서 「FC 강남 VS ㅈㅂㄷ」로 나왔는데 그 경기는 「ㅇㅅㅇ VS ㅈㅂㄷ」 였습니다(확정 경기는 하나뿐, DB 확인). 이름은 경기에서 가져옵니다
상대 판에 사람이 적게 나오던 것 칸(grid_col/grid_row)이 저장된 등재만 골랐습니다. 칸은 판을 한 번 옮겨야 생기므로, 초대만 수락한 사람은 본인 화면엔 있고 남의 화면엔 없었습니다(8명 팀이 3명으로). 내 판이 쓰는 seatsFromSquad 를 같이 씁니다
판의 카드를 누르면 프로필 이름·등급·한 줄·호칭·분석 문장(사용자 요청). 🔴 경기 화면 위에 뜨고 닫아도 경기 화면은 남습니다. 리뷰 중에는 안 눌립니다

곁가지 둘: TeamMatch 의 조건 조회에 .catch 가 없어 연결이 한 번 끊기면 판이 「찾고 있습니다…」에서 굳었습니다(이제 다시 묻습니다). 그리고 스쿼드가 없는 팀은 주장의 판이 그 자리에서 만듭니다 — 팀을 만들 때 여는 호출이 실패를 삼키는데 다시 시도하는 자리가 없어서, 한 번 어긋난 팀은 영영 빈 판이었습니다 (운영의 「심사위원 FC」가 팀원 9명에 스쿼드 0이었습니다).

만족해야 할 성질

  • 「찾는 중」·「경기 잡힘」은 화면을 옮겨도 이어진다. 어느 화면에 있든 머리칸에서 그 자리로 돌아갈 수 있다
  • 같은 팀은 보는 사람이 달라도 같게 보인다 — 내 판과 남의 판이 같은 규칙으로 앉힌다
  • 대기 화면의 우리 팀은 그 경기에 나간 팀이다. 홈 판이 보여 주는 팀이 아니다

확인

cd www && npx vitest run && npx tsc --noEmit

2026-09-18 확인: 953 passed · tsc 통과. 새 시험 중 넷은 고치기 전 빨강을 확인했습니다(우리 팀 이름 · 칸 없는 등재 · 찾는 중 되살리기 · 판 크기 회귀).

하지 말 것

  • 🔴 경기 조건(지역·시간)을 브라우저에 두지 마십시오 — 전에 그랬다가 「화면은 설정이 끝났는데 남의 후보 목록에 안 뜨는」 일이 있었습니다. 브라우저에 둔 것은 화면 상태 하나뿐입니다
  • 🔴 「경기 잡힘」을 저절로 띄우지 마십시오(사용자 결정) — 화면을 통째로 덮는 판이라 들어올 때마다 뜨면 다른 일 하러 온 사람이 매번 닫아야 합니다
  • 🔴 폴링 주기를 촘촘하게 하지 마십시오 — 앞단이 Cloudflare 라 요청 제한이 엣지별로 묶입니다(jin 37번). 켜졌을 때·탭이 보일 때만 돕니다

그 뒤로 더 바꾼 것 (2026-09-18 저녁, 같은 날 이어서)

같은 날 사용자 지시로 이어 고친 것들입니다. 전부 www/(백성검 영역)라 여기 모아 둡니다 — 되돌리고 싶은 것이 있으면 말씀해 주세요.

무엇 왜 · 무엇이 달라졌나
「경기 완료」 단추 시각 전에도 손으로 끝낼 수 있게(사용자 요청). 되돌릴 수 없으므로 「정말로 경기가 완료되었나요?」를 한 번 더 묻습니다. 누르면 머리칸 「경기 잡힘」에서 빠지고, 리뷰를 마치면 영상 화면이 아니라 메인으로 돌아옵니다. 🔴 서버에 「끝난 경기」 상태가 없어 브라우저에 적어 둡니다 — 다른 기기에서는 다시 보입니다. 계약을 늘릴지는 판단이 필요합니다
⊗ 가 팀에서도 내보냅니다 전에는 판에서만 내려 「팀에는 남아 있는데 판에는 없는」 사람이 쌓였습니다(운영에서 40명). 사용자 결정으로 소속까지 끊습니다. 🔴 위 「시연 더미 등재를 걷었습니다」 절의 「소속은 남아 있다」는 그 일회성 정리 스크립트 이야기이고, ⊗ 단추는 이제 다릅니다
심사위원 로그인 번호를 직접 고릅니다 동시 접속 대비로 judge-01~10 을 두고, 무작위는 처음 배정에만 씁니다. 범위 밖 번호는 거부합니다 — 없는 번호를 기억하면 로그인 단추가 가입부터 시도해 빈 계정이 생깁니다

🔴 계약 하나가 늘었습니다 — 같은 상대에 겹쳐 걸면 409 (CCC 62번)

사용자 신고: 「경기 완료하고 평가까지 했는데 새로고침하면 그 화면이 또 남는다」 (경기 취소도 같음). 캐시가 아니었습니다 — 운영에서 한 팀이 같은 상대와 확정 경기 3건을 갖고 있었고, 화면은 가장 이른 것 하나를 고르므로 하나를 치우면 다음 것이 곧바로 올라온 것입니다.

수락이 두 팀의 다른 pending 을 정리하지만 그건 pending 까지고, 이미 accepted 가 된 뒤 같은 상대에 새로 거는 것은 서버가 안 막았습니다. 화면의 「한 번에 한 곳」(TeamMatch.tsx)은 그 브라우저의 상태뿐이라 새로고침하면 그대로 뚫렸습니다.

  • POST /teams/{id}/match-requests 에 409 TEAM_MATCH_REQUEST_ALREADY_LIVE
  • 방향을 안 가립니다(A→B 가 살아 있으면 B→A 도 막힘). 지난 경기·거절·취소된 것·물린 경기는 안 셉니다 — 안 그러면 한 번 붙은 팀과 다시는 못 붙습니다

🔴 정정 (같은 날, 배포 직후) — 처음에 「살아 있다 = pending 이거나 accepted」 라고 적었는데 그것으로는 물린 경기가 영영 막습니다. 경기 취소는 match 행만 지우고 신청의 status 는 accepted 로 남습니다(FK 가 match_id 만 비웁니다). 운영에 그런 행이 11건 있었습니다. 지금은 match_id 가 차 있는 것만 셉니다 — 「경기 잡힘」 화면이 쓰는 조건과 같아졌습니다. 첫 판을 읽고 움직이신 것이 있으면 이쪽이 맞습니다.

  • 여러 팀에 동시에 거는 것은 그대로 됩니다 — 막는 것은 같은 상대에 두 번입니다
  • 먼저 확인: applyToTeam 이 이미 서버 error.message 를 그대로 띄우므로 화면은 고칠 것이 없습니다. 상세·「하지 말 것」은 fastapi/docs/client-contract-changes.md 62번

🔴 이미 쌓인 중복은 코드로 안 사라집니다 — 정리 스크립트를 따로 만들어 2026-09-18에 돌렸습니다(겹친 확정 경기 1건 정리, 재실행에서 「치울 것 없음」). 지금은 모든 팀이 「경기 잡힘」 1건 이하입니다.

🔴 정정 — 위 409 는 사용자가 신고한 증상의 원인이 아니었습니다

앞서 이 항목에 「겹쳐 잡힌 경기가 원인이다」라고 적었는데 틀렸습니다. 겹침은 실재했고 고칠 값이었지만, 사용자가 겪던 것은 따로였습니다.

사용자가 다시 짚어 준 결정적인 단서: 「새로고침하면 정상이고, 새로고침 전까지 다시 뜬다」 — 서버 데이터가 원인이면 새로고침해도 그대로여야 합니다.

진짜 원인은 www/src/lib/useNotifyInbox.ts 안에서 같은 판단을 두 곳이 다른 규칙으로 한 것이었습니다.

  보던 조건
머리칸 「경기 잡힘」(confirmed) status + match_id + 시각 + 「완료 표시」
대기 화면을 켜는 acceptedTeam status === 'accepted' 만

「경기 완료」를 누르면 앞쪽은 꺼지는데, 완료 표시가 부르는 바로 그 reload() 가 뒤쪽을 다시 켜서 판이 되살아났습니다. 경기 취소도 같습니다 — 취소는 match 행만 지우고 신청의 status 는 accepted 로 남으므로 (FK 가 match_id 만 비웁니다) status 만 보는 쪽은 계속 켭니다.

🔴 새로고침하면 멀쩡해 보인 것은 고쳐져서가 아닙니다 — sent 가 useRef 라 새로고침에 비어서 그 가지가 아예 안 돌았을 뿐입니다.

isLiveConfirmed(r, now) 하나로 모아 두 곳이 같이 쓰게 고쳤습니다 (시험 4건, 고치기 전 3건 빨강 확인 · vitest run 1033 passed · tsc 통과).

🔴 하지 말 것: 이 판단을 다시 둘로 나누지 마십시오. 「경기 잡힘」과 대기 화면은 같은 질문(이 경기가 지금 살아 있나)에 답합니다 — 조건을 한쪽에만 더하면 그 순간 이 버그가 되돌아옵니다.

43. DCI 4K(4096px) 영상이 반려됩니다 — 메모리 근거가 이미 없어진 것 같습니다 (2026-09-18 신설) ✅ 해소 (2026.09.19)

  • 담당: 정상호 ✅ 박민호가 직접 결정 (2026.09.19) · 제기: 정어진 · 기한: 완료

박민호가 실사용 중 같은 반려를 직접 겪고 해상도 상한 자체를 없애기로 결정했습니다 — 「받을지 말지」가 아니라 「더는 안 잰다」쪽입니다. 이 항목이 묻던 전제(바이트 가드가 이미 해상도를 흡수한다)가 그대로 근거입니다.

  • fastapi/app/analysis/domain/rules/video_rules.py의 MAX_LONG_SIDE· MAX_SHORT_SIDE와 그 검사 자체를 지웠습니다 — reject_reason은 이제 용량·길이만 봅니다(analyze 값과 무관)
  • 바이트 예산(agent/의 DEFAULT_MAX_FRAME_BYTES)은 건드리지 않았습니다 — 「하지 말 것」 그대로, 결과를 바꾸는 별개 판단이라서입니다
  • 문서 갱신: fastapi/docs/api-contract.md(상한 절)·deployment.md(8K 스모크 기록에 재정정 추가)·client-contract-changes.md 64번(신설)
  • fastapi/tests/analysis/adapter/test_video_router.py의 해상도 관련 시험 셋(8K 반려 가정)도 새 동작에 맞게 고쳤습니다
  • 확인: min 브랜치에서 venv 새로 만들어 pytest 전체 돌림 — 810 passed / 324 skipped(DB 없는 로컬이라 DB 마킹 시험만 skip, 그 외 0건), 실패 0건

44. 영상을 갈래마다 3개로 제한했습니다 — VIDEO_LIMIT_EXCEEDED (2026-09-22 신설)

사용자 요청으로 영상 개수를 막았습니다. 합쳐서 3개가 아니라 갈래마다 3개입니다. 규격은 fastapi/docs/api-contract.md 3-6절의 「🔴 갈래마다 3개」 절, 반영 목록은 client-contract-changes.md 65번입니다.

  • 갈래는 화면(www 의 「내 영상」)의 두 탭과 같습니다 — 가르는 기준도 그쪽과 같은 analysis_job_id 유무입니다. 분석 영상 3 + 업로드 영상 3, 한 갈래가 차도 다른 갈래는 열려 있습니다(계정 최대 6개)
  • 반려된 클립은 업로드 갈래를 차지합니다(분석 작업이 안 생기므로 — 화면도 같은 자리에 세웁니다). 분석 중인 임시 클립은 안 셉니다(화면에도 안 보이고 스윕이 걷어 가므로, 세면 브라우저가 죽은 사람이 하루 자리를 잃습니다)
  • 세 경로 전부에서 422 — upload-url·POST /videos·keep. 🔴 keep 이 불변식을 지키는 자리이고(임시 클립 여럿을 나중에 한꺼번에 저장하면 앞 둘을 통과하고도 넘습니다), 이미 저장된 클립의 재호출은 안 막습니다(멱등 유지)
  • message 가 막힌 갈래 이름을 담습니다(「분석 영상」/「업로드 영상」). code 는 하나입니다
  • upload-url 이 선택 필드 analyze 를 받습니다 — 그 호출은 갈래를 모르기 때문입니다. 주면 그 갈래만 보고, 안 주면 양쪽이 다 찼을 때만 막습니다 (한쪽만 찼다고 막으면 안 찬 갈래로 올리려는 사람을 잘못 막습니다)
  • 이미 상한을 넘게 가진 계정은 그대로 둡니다 — 새로 늘리는 것만 막습니다. 푸는 길은 DELETE /videos/{id} 하나이고 그 갈래 자리가 즉시 빕니다
  • 마이그레이션 없음(alembic check 변경 없음 · head 하나). 상수는 video_rules.py 의 MAX_VIDEOS_PER_GROUP
  • 시험: 계약 15 · DB 4. 전체 pytest 1153 passed / skipped 0. 고장을 두 가지 넣어 판별되는 것까지 확인했습니다 — 관문 제거 시 7건 실패, 갈래를 합쳐 세게 하면 8건 실패

봐 주실 것 (백성검): www 는 지금도 안 깨집니다. 받는 쪽 코드를 읽고 확인했습니다 — fastapiCall.ts → toErrorResponse → uploadClip.ts 의 readError 가 서버 문구를 그대로 화면에 띄웁니다. 그래서 아래는 전부 🟡 선택(UX) 입니다.

  1. upload-url 1단계에 analyze 를 같이 보내기 — uploadClip.ts 가 이미 그 값을 갖고 있고(101행 구조 분해, 106행 본문에만 안 실림) 프록시 라우트가 본문을 통째로 넘기므로 한 줄입니다. 안 보내도 동작은 같고, 한쪽만 찬 상태에서 200MB 헛걸음이 한 번 생길 뿐입니다
  2. 탭마다 “3/3” 표시·단추 비활성 — 이미 analyzed/uploaded 두 배열을 만들고 계시니 길이를 그대로 쓰면 됩니다
  3. 🔴 두 갈래를 합쳐서 세지 마십시오 · 갈래 기준을 analysis_status 로 바꾸지 마십시오(대기 중인 클립이 업로드 쪽으로 샙니다 — MyVideos.tsx 머리말에 이미 적혀 있는 함정이고 서버도 같은 이유로 analysis_job 행을 봅니다)

flutter/ 는 ⏳ 아직 영상 업로드 경로가 없습니다(grep -rn 'upload-url' flutter/lib → 0건).

  • 확인: 분석 영상이 3개인 계정으로 curl -s -X POST https://<API 호스트>/api/v1/videos/upload-url -H "Authorization: Bearer <토큰>" -H 'Content-Type: application/json' -d '{"content_type":"video/mp4","size_bytes":52428800,"filename":"c.mp4","analyze":true}' 가 422(code 는 VIDEO_LIMIT_EXCEEDED, message 에 「분석 영상」) · 같은 요청에 "analyze":false 를 주면 200
  • 담당: 백성검(위 1·2 — 급하지 않음) · 정어진(배포) · 제기: 정어진(사용자 요청) · 기한: 급하지 않음 (지금 깨지는 것은 없습니다 — 서버 문구가 화면에 그대로 뜹니다)

45. ho 49번 회신 — 봉투의 preprocessing 칸: 적재는 안 깨집니다, 지금은 저장하지 않습니다 (2026-09-23 신설)

ho 49번(정상호 님)의 「정어진 님 — 확인 부탁드립니다」에 답합니다. 그 항목은 아직 ho 브랜치에만 있어 거기 쓰면 병합 충돌이라, jin 28번처럼 제 구역에 둡니다.

  • 적재는 안 깨집니다 — 확인했습니다. 적재 파서는 schema_version 의 major 만 보고(1 이 아니면 거부) 봉투에서 아는 키만 읽습니다. preprocessing 이 든 1.6 봉투를 실제 파서에 넣어 통과했고, 대조로 넣은 2.0 은 거부됐습니다. 이 성질을 시험으로 박았습니다 — 파서를 엄격 검증(모르는 키 거부)으로 바꾸면 그 시험이 걸립니다
  • preprocessing 은 지금은 DB 에 저장하지 않습니다. 읽는 화면·API 가 없고, S3 의 report.json 원본에 그대로 남아 잃는 것이 없습니다. 「어느 전처리로 난 결과인가」로 결과를 가르거나 보여 줄 일이 생기면 그때 칸을 만들고 원본에서 채웁니다
  • deploy.sh 의 --locked 는 해당 없습니다 — 백엔드 배포 문서에는 에이전트 uv sync 단계가 없습니다
  • 확인: cd fastapi && .venv/bin/pytest -q tests/analysis/application/test_report_parser.py -k "모르는 or minor" → 3 통과(새 시험 · minor 통과 · 모르는 major 거부)
  • 하지 말 것: 봉투에 칸을 더할 때 major 를 올리지 마십시오 — major 가 다르면 적재가 거부합니다(minor 는 통과)
  • 담당: 정상호(읽고 ho 49번의 「정어진 확인」을 닫아 주시면 됩니다 — 할 일은 없습니다) · 제기: 정상호(ho 49번) · 기한: 없음

46. jin 은 해커톤이 끝난 뒤 main 에 합쳐 주세요 — 사이트 변경만 먼저 넣었습니다 (2026-09-29 신설)

  • 무엇: 09-23·09-29 에 jin 에서 한 사이트 변경(관리 지표 페이지 · 1·2·3·4·5·7·8·9장과 부록 E 의 그림 · 표지 둘러보기 · 사이드바 단추 · 날 글자로 나오던 1·2·4·8·9장 표 · 플랫폼 이름)을 사이트 파일만 떼어 이 항목과 같은 병합으로 main 에 먼저 넣었습니다 — 사용자 승인, 해커톤 동안 「지킬·운영 관제는 괜찮다」에 따른 것입니다
  • 왜 떼었나: jin 에는 fastapi/ 문서·운영 관제 매니페스트·시험 하나(7파일)가 함께 있습니다. 그대로 합치면 백엔드 이미지가 다시 빌드되고 서버의 자동 재배포가 운영 API 를 한 번 재시작합니다(기능 변화는 없습니다)
  • 🔴 하지 말 것: 해커톤이 끝나기 전에는 jin 을 main 에 합치지 말아 주세요. 끝난 뒤에는 그대로 합치면 됩니다 — 사이트 파일은 양쪽이 같은 내용이라 충돌 없이 넘어가고, API 가 한 번 재시작됩니다
  • 확인: git diff --stat origin/main...origin/jin -- fastapi/ 에 파일이 보이면 아직 안 합친 것입니다
  • 담당: 박민호(jin 을 합치는 시점) · 제기: 정어진 · 기한: 해커톤 뒤

min (박민호)

1. 패킷 A(과금) 진행 상황을 알려주세요 ✅ 회신 (2026.09.08)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

2. 패킷 B — review_option 초기 목록·마이그레이션 확인 부탁드립니다 ✅ 해소 (2026.09.04)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

3. 패킷 A — 리뷰 부탁드립니다 ✅ 회신 (2026.09.08)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

4. 골든셋 라벨링 진행 상황 여쭙습니다 ✅ 회신 (2026.09.08)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

5. 로컬 미리보기 서비스가 딴 저장소를 보고 있었습니다 ✅ 해소 (2026.09.03)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

6. supersub-ai.com이 main을 안 보고 있었습니다 ✅ 해소 (2026.09.03)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

7. AI 챗봇으로 용병·팀 찾기 자동화 (2026-09-04 제안)

표지의 용병 찾기 · 팀 찾기 · 지인 찾기 옆에 AI 챗봇을 새로 놓고 싶습니다. 대화로 용병 찾기 → 팀 찾기 → 경기 매칭까지 이어지는 흐름을 자동화하는 것이 목표입니다.

🔴 경기장 예약 자동화는 이번 범위에서 뺍니다. 부록 D에 예약 도메인 자체가 없고, 실제 구장·체육관을 잡으려면 우리 시스템 밖의 예약 플랫폼과 제휴·연동이 필요해서 개발보다 외부 협상이 먼저 걸리는 문제입니다. 필요해지면 별도 항목으로 냅니다.

   
만족해야 할 성질 사용자가 챗봇과 대화로 (1) 조건에 맞는 용병 후보를 찾고 (2) 조건에 맞는 팀을 찾고 (3) 경기 지원까지 이어질 것. 화면 위치·UI 형태는 예시일 뿐 규격이 아닙니다
확인 (아직 없음 — 착수 전 범위·설계 논의가 먼저입니다)
하지 말 것 🔴 예약 자동화를 이 항목에 슬쩍 끼워 넣지 않기 — 범위를 분명히 나눕니다

🔴 정정 (2026-09-04) — 담당을 앞서 정상호·정어진으로 적은 것을 정정합니다

제가(박민호) 직접 만듭니다. 정상호의 AI 에이전트는 지금 영상 분석·루브릭 채점 전용이라 “자연어 대화” 인터페이스가 아닙니다 — 그래서 이 일은 두 사람에게 구현을 맡기는 게 아니라, 챗봇(오케스트레이션 + UI)을 제가 만들고 두 사람의 결과물을 소비·자문받는 구도가 맞습니다.

  • 이미 있는 API(경기 탐색 GET /matches · 지원 POST .../applications · 스쿼드 등)를 챗봇이 호출합니다 — 정어진에게 새로 구현을 요청하는 것이 아니라 기존 API를 그대로 씁니다. 계약이 바뀌면 그때 정어진에게 물어봅니다
  • 대화형 AI 자체는 정상호의 분석 에이전트와 별도로 붙여야 할 가능성이 큽니다. 다만 AI 자원·라이선스 판단(비용·모델 선택)은 정상호 자문을 구합니다 — 특히 EXAONE은 지금 미결 ho 1번·15번 라이선스 이슈가 걸린 채로 분석 용도로만 쓰고 있어, 챗봇에 같은 자원을 쓸지도 그 판단과 얽힙니다

범위 구체화 (2026-09-04) — 버튼 문구가 아니라 실물(API·www 소스)로 확인했습니다

용병 찾기·팀 찾기 두 버튼은 지금 www에 href가 없는 자리 채움 (placeholder)입니다(www/src/lib/destinations.ts: “아직 갈 곳이 없어 카드가 링크가 아니다”). 즉 이 챗봇은 기존 화면을 감싸는 게 아니라 이 두 자리의 첫 구현이 됩니다.

실제로 존재하는 흐름은 두 갈래뿐입니다 (계약 문서 확인):

흐름 API 관점
경기 찾기·지원 GET /matches?sport_code=&region= → POST /matches/{id}/applications 용병(선수)이 부족한 경기를 찾아 지원
인원 모집 등록 POST /teams/{id}/matches(needs 배열) 팀 주장이 부족 인원을 올려서 찾음

🔴 팀이 선수를 직접 검색하는 API는 없습니다. 지금 구조는 “팀이 모집글을 올리면 선수가 찾아와 지원한다”는 포스팅 기반이지, 팀이 조건에 맞는 선수 프로필을 검색하는 구조가 아닙니다(GET /cards/{slug}는 슬러그를 이미 알아야 합니다). 용병 찾기(팀이 콕 집어 찾는 것)를 문자 그대로 구현하려면 새 검색 API가 필요합니다 — 아래 열린 질문 1번.

챗봇 시나리오 셋 (전부 기존 API로 됨, B 제외)
  • A. 뛸 경기 찾기 — 종목·지역·(선택)포지션·날짜를 대화로 채움 → GET /matches 호출 → 결과 제시 → 선택 시 POST .../applications
  • B. 모집 등록 돕기 — 날짜·장소·필요 포지션·인원을 대화로 채움 → POST /teams/{id}/matches 호출. (“용병 찾기”를 팀 쪽에서 이렇게 대신합니다 — 검색이 아니라 등록입니다)
  • C. 매칭 완료 — POST .../applications/{id}/accept는 이미 있습니다. 챗봇이 이걸 대신 눌러 자동 매칭할지는 열린 질문 2번
열린 질문 — 결정해야 이어서 씁니다
  1. 용병 찾기(팀이 선수를 직접 검색)를 1차 범위에 넣을지. 넣으면 정어진에게 새 검색 API를 요청해야 해서 “기존 API만 쓴다”는 전제가 깨집니다. 제 의견은 1차에서 빼고 흐름 B(모집 등록)로 대신하는 것입니다 — 같은 목적을 기존 구조로 달성합니다
  2. 매칭을 챗봇이 자동으로 수락(accept)까지 할지. 대화만으로 성사시키면 사람 확인 없이 매칭이 끝납니다. 제 의견은 1차엔 추천까지만 하고 수락은 사람이 화면에서 누르게 하는 것입니다
  3. 대화 이력 저장. 정어진에게 새 백엔드를 요청 안 하기로 했으니 (a) www 자체 스토리지 또는 (b) 세션마다 휘발성 중 하나입니다. 처음엔 (b)가 제일 간단합니다
  4. 대화용 LLM. EXAONE은 라이선스 이슈가 걸린 채 분석 전용입니다 — 챗봇은 별도 LLM을 쓰는 걸 추천합니다. 라이선스 판단(ho 1번·15번)과 아예 분리되는 효과도 있습니다
1차 범위(MVP) 제안

포함: 흐름 A(경기 찾기·지원) · 흐름 B(모집 등록 돕기) · 추천까지(수락은 사람이 누름) 제외: 팀의 직접 선수 검색(질문 1 전까지) · 자동 수락(질문 2 전까지) · 경기장 예약(홈 화면에 이미 별도 경기장 예약 버튼으로 계획돼 있고, 그것도 지금 href 없는 같은 처지의 placeholder입니다 — www/src/lib/destinations.ts)

결정 (2026-09-04, 박민호) — 이번 스프린트는 흐름 B만

이번 스프린트 범위를 흐름 B(모집 등록 돕기)로 좁혀서 진행합니다. 열린 질문 1번은 이걸로 정해진 것입니다 — 팀의 직접 선수 검색은 안 하고, 대화로 팀 주장의 경기 등록(POST /teams/{team_id}/matches)을 돕는 것부터 만듭니다. 흐름 A(경기 찾기·지원)·질문 2~4는 아직 안 정했고, 흐름 B가 되는 대로 이어서 봅니다.

흐름 B 대화 슬롯 설계 (2026-09-04)

POST /teams/{team_id}/matches 규격을 그대로 슬롯으로 옮겼습니다 — 계약에 없는 값은 안 받습니다.

슬롯 어디서 채우나 검증
team_id GET /me의 teams[] 중 role: "owner"인 것만 후보 0개면 진행 불가(팀부터 만들라고 안내) · 1개면 자동 선택 · 2개 이상이면 되물음
played_at 자연어 날짜·시간 → ISO 8601(+09:00) 미래 시각이어야 함(422 PAST_MATCH)
place 자유 텍스트 빈 값 불가
needs[] (포지션, 인원) 쌍, 1개 이상 팀 종목 안에서만 유효한 코드 · 중복 불가 · 인원 ≥ 1

🔴 포지션 코드는 지금 하드코딩해야 합니다 — 목록을 내려주는 API가 없고 (api-contract.md “포지션 목록은 마이그레이션이 넣는다”), 마이그레이션에 박힌 값이 정본입니다. 팀의 sport_code는 이미 GET /me에서 알고 있으니, 그 종목의 후보만 대화에 내놓으면 됩니다(오타·엉뚱한 코드를 원천 차단).

football:   GK 골키퍼 · DF 수비수 · MF 미드필더 · FW 공격수
baseball:   P 투수 · C 포수 · IF 내야수 · OF 외야수
basketball: G 가드 · F 포워드 · C 센터

예시 대화

사용자: 이번 주 토요일 저녁에 골키퍼 한 명이랑 공격수 두 명 필요해요
챗봇:   (팀 1개면) 번개FC(축구)로 등록할게요. 어느 구장에서 하나요?
        (팀 2개 이상이면 먼저 어느 팀인지부터 물음)
사용자: 강남 풋살장 2구장이요
챗봇:   번개FC · 9/13(토) 19:00 · 강남 풋살장 2구장 · GK 1명, FW 2명
        — 이대로 등록할까요?
사용자: 네
챗봇:   → POST /teams/{id}/matches 호출 → 등록 완료

에러 → 대화 복구

에러 대응
403 FORBIDDEN 방어용(정상 흐름에선 role: owner로 이미 걸러짐) — “이 팀은 주장만 등록할 수 있어요”
404 TEAM_NOT_FOUND 팀을 다시 고르게 함
422 PAST_MATCH “그 시간은 이미 지났어요, 다른 시간을 알려주세요”
422 UNKNOWN_POSITION·DUPLICATE_POSITION·VALIDATION_ERROR 보내기 전에 하드코딩 목록으로 미리 걸러야 정상 — 그래도 나오면 일반 오류 메시지

놓친 경우 — 결정 필요

  1. “아무나 상관없어요”(포지션 특정 안 함) — 지금 API는 포지션+인원이 필수라 받을 수단이 없습니다. 되물어서 포지션을 특정하게 유도합니다(API 확장 없이 되는 유일한 방법)
  2. 주장인 팀이 하나도 없음 — “먼저 팀을 만들어야 해요” 안내 후 종료 (POST /teams로 유도)
  3. 날짜가 모호함(“다음 달 아무 때나”) — 구체적인 날짜 하나로 좁혀질 때까지 되물음. 모호한 값을 그대로 API에 보내지 않습니다

구현 설계 (2026-09-04) — www 기존 관례 그대로 얹습니다

www/src/server/backend/gateway.ts의 주석대로 “화면 코드는 백엔드 타입을 보지 않는다 — 같은 오리진 /api/*만 부른다.” 챗봇도 이 규칙을 그대로 따릅니다. fastapi/나 정어진에게 새로 요청하는 것이 없습니다 — 아래 셋 다 www 안에서 끝납니다.

1) 새로 낼 것 — 기존 API를 감싸는 조각 (기존 파일 3곳에 얹는 정도, 새 엔드포인트 아님)

파일 하는 일
www/src/server/backend/gateway.ts Backend 인터페이스에 createTeamMatch(token, teamId, input) 한 줄 추가 (addSquadMember와 같은 모양)
www/src/server/backend/fastapiBackend.ts callFastApi<Match>('/teams/{id}/matches', { method: 'POST', ... }) — 기존 패턴 그대로
www/src/server/backend/mock.ts 로컬 개발용 목업 (기존 mock 관례)
www/src/app/api/teams/[teamId]/matches/route.ts (신규 파일) POST, withAuth 패턴, played_at·place·needs[] 형태 검증 후 getBackend().createTeamMatch(...) 호출 — squad/members/route.ts를 그대로 본떴습니다

2) 챗봇 자체 — LLM 호출은 반드시 서버(Route Handler)에서만

www/src/app/api/chat/route.ts  (신규)  ← LLM API 키가 사는 유일한 곳
  • 브라우저는 이 라우트만 부릅니다. LLM 키가 클라이언트 번들에 들어가면 안 됩니다
  • 매 요청마다 그동안의 대화 전체를 클라이언트가 함께 보냅니다(표준 챗봇 패턴) — 서버는 세션을 들고 있지 않습니다. DB 저장 없음(열린 질문 3번 결정 그대로, 새로고침하면 대화가 사라집니다)
  • 대화 시작 시 서버가 GET /me를 먼저 불러 role: "owner"인 팀 목록과 각 sport_code를 시스템 프롬프트에 미리 넣어 줍니다. LLM이 매번 “팀 목록 조회” 도구를 부르게 하지 않는 이유 — 한 대화 동안 안 바뀌는 값이라 왕복을 아낍니다. 오늘 날짜(서울 시간대)도 같이 넣어 “이번 주 토요일” 같은 상대 표현을 풀 수 있게 합니다

3) LLM 도구(tool) 하나만 씁니다 — 실제 등록은 이 도구가 안 합니다

{
  "name": "propose_match_registration",
  "description": "슬롯 4개(팀·시각·장소·필요 포지션)가 다 채워지고 사용자가 구두로 확인했을 때만 부른다. 실제로 경기를 등록하지 않는다 — 확인 카드를 화면에 띄우는 신호일 뿐이다.",
  "input_schema": {
    "type": "object",
    "properties": {
      "team_id":   { "type": "string" },
      "played_at": { "type": "string", "description": "ISO 8601, +09:00" },
      "place":     { "type": "string" },
      "needs": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "position_code": { "type": "string" },
            "head_count":    { "type": "integer", "minimum": 1 }
          },
          "required": ["position_code", "head_count"]
        }
      }
    },
    "required": ["team_id", "played_at", "place", "needs"]
  }
}

🔴 실제 쓰기(POST /teams/{id}/matches)는 이 도구가 아니라 화면의 [등록] 버튼 클릭이 부릅니다. LLM이 슬롯을 다 채우면 /api/chat 응답에 확인 카드 데이터를 실어 보내고, 프론트가 그걸 그려 사용자가 버튼을 누르면 그때 (1)에서 만든 POST /api/teams/{teamId}/matches를 일반 API 콜로 부릅니다 — 챗봇 대화를 한 번 더 거치지 않습니다. LLM의 자연어 해석 오류가 그대로 쓰기로 이어지는 경로를 막는 안전장치입니다(accept 자동화를 뺀 것과 같은 원칙 — 열린 질문 2번).

시퀀스

브라우저(챗봇 UI)
  → POST /api/chat { messages, ... }             (서버가 team 목록 이미 앎)
  → Claude API 호출, tool: propose_match_registration
  ← 슬롯 부족  → 되묻는 문장만
  ← 슬롯 완성  → 확인 카드 데이터(team·시각·장소·needs)
브라우저: 확인 카드 렌더 + [등록] 버튼
사용자가 [등록] 클릭
  → POST /api/teams/{teamId}/matches  (일반 API, 챗봇 경유 안 함)
  → 성공: FastAPI 201 → 대화창에 "등록됐어요" 이어붙임
  → 실패(422 PAST_MATCH 등): 위 에러 매핑 표의 문구 그대로 인라인 표시

4) LLM 선택 (열린 질문 4번에 대한 제 결론): Claude API(Anthropic) → Gemini로 정정했습니다(아래 “구현 설계” 절 끝의 🔴 정정 참고). 어느 쪽이든 EXAONE의 NC 라이선스 문제와는 완전히 분리됩니다. 정상호에게는 “EXAONE을 쓰지 않는다”는 결론만 자문으로 확인받으면 됩니다.

5) UI — 새 컴포넌트 하나

www/src/components/MatchBot.tsx (가칭) — 메시지 목록 + 입력창 + 슬롯 채움 현황을 보여주는 사이드 카드(팀/시각/장소/포지션이 채워질 때마다 갱신) + 확인 카드의 [등록] 버튼. 기존 SquadPanel류 판(글래스 스타일)과 톤을 맞춥니다. 표지의 용병 찾기 버튼(지금 href 없음)을 눌렀을 때 여는 것으로 잡습니다.

✅ 흐름 B 구현 완료 (2026-09-04, 박민호)

위 설계 그대로 냈습니다.

  • www/src/app/api/chat/route.ts(신규) — 도구는 propose_match_registration 하나. 실제 쓰기는 안 합니다
  • www/src/app/api/teams/[teamId]/matches/route.ts(신규) — 확인 카드의 [등록] 버튼이 부르는 일반 API. gateway.ts·fastapiBackend.ts·mock.ts에 createTeamMatch 추가
  • www/src/components/MatchBot.tsx(신규) — 용병 찾기 알약을 누르면 열리는 떠 있는 채팅 판. 스쿼드 판과 무관해 SquadPanel 안이 아니라 HomeStage에 독립적으로 얹었습니다(destinations.ts에 MATCH_BOT 상수 추가)

🔴 정정 (2026-09-04 늦게) — Claude가 아니라 Gemini로 바꿨습니다

LLM 계정에 카드가 등록 안 된 Free(평가) 플랜이라 Claude API 호출이 전부 400 credit balance too low로 막혔습니다. Google AI Studio는 카드 없이 바로 쓰는 무료 등급이 있어 그쪽으로 바꿨습니다 — 나머지 설계(도구 하나·실제 쓰기는 화면 버튼이 함·DB 저장 없음)는 그대로입니다.

  • @anthropic-ai/sdk 제거, @google/genai(^2.21.0) 설치
  • 모델: gemini-2.5-flash (무료 등급 포함, 가벼운 슬롯 채우기엔 충분)
  • history가 이제 Anthropic MessageParam[]이 아니라 Gemini Content[] ({role: 'user'|'model', parts: [...]}) — assistant가 아니라 model 이 어시스턴트 역할입니다
  • .env.example의 키 이름을 ANTHROPIC_API_KEY → GEMINI_API_KEY로 바꿨습니다(https://aistudio.google.com/apikey 에서 카드 없이 발급)
  • 실제 설치된 SDK의 타입 정의(node_modules/@google/genai/dist/genai.d.ts)를 직접 읽고 맞췄습니다 — parametersJsonSchema·response.candidates[0].content· response.functionCalls·ApiError.status 전부 확인 후 작성했습니다

🔴 발견한 것 — 팀을 만드는 화면이 아직 없습니다. POST /teams가 계약에는 있는데 www의 Backend 인터페이스가 그걸 감싼 적이 없습니다. 그래서 주장인 팀이 하나도 없는 계정은 이 챗봇이 “먼저 팀을 만들어야 해요”라고 안내는 하지만 실제로 만들 화면이 없어 막다른 곳입니다. 다음 스프린트 후보로 올려 둡니다 — 담당은 아직 안 정했습니다.

정식 항목으로 올렸습니다 → 같은 구역 18번 (2026-09-10). 담당은 박민호가 직접 진행합니다.

확인: git grep -n "createTeamMatch" -- www/src · cd www && npm run test (299 passed, 신규 20건 — mock 6·라우트 4·컴포넌트 5·챗봇 라우트 3) · npx tsc --noEmit 통과. 데모 계정(demo@super-sub.example)의 팀 역할을 member→owner로 바꿨습니다(주장 전용 흐름을 데모로 확인하려면 필요했고, 다른 곳은 이 값을 안 써서 안전합니다).

아직 안 한 것: 흐름 A(경기 찾기·지원), 위 “팀 만들기 화면 없음”.

✅ 실제 브라우저 확인 완료 (2026-09-08)

GEMINI_API_KEY가 .env에 채워진 뒤, 헤드리스 브라우저(chromium-cli가 이 환경엔 없어 Playwright + 시스템 chromium-browser로 대체)로 데모 계정 로그인 → AI 용병 찾기 단추(알약 용병 찾기가 아니다 — 여는 자리를 알약에서 이 단추로 옮긴 것이 그 사이의 변경이었다, HomeStage.tsx· SquadPanel.tsx 참고) → 슬롯 4개(팀·시각·장소·포지션)를 대화로 채움 → 확인 카드 → [등록] 클릭까지 실제로 돌렸습니다.

  • 확인: Gemini 왕복 실제로 됨(목업 아님) · 콘솔 에러 0건 · 4xx/5xx 응답 0건 · 확인 카드에 팀명·일시·장소·포지션이 정확히 뜸 · [등록] 클릭 → “경기 등록이 완료됐어요!” 로 대화가 이어짐
  • 🔴 작은 흠 하나: LLM 응답이 마크다운(**팀**: 번개FC 등)을 그대로 텍스트로 보내는데, 채팅 버블이 마크다운을 렌더링하지 않아 별표가 그대로 보입니다. 기능은 안 깨지지만 다음에 손볼 때 시스템 프롬프트에 “마크다운 쓰지 말고 일반 문장으로” 한 줄 추가하거나 버블에 마크다운 렌더러를 붙이면 됩니다 — 지금 스프린트 범위는 아니라 그대로 남겨 둡니다

✅ 자문 회신 — EXAONE 안 씁니다. 다만 라이선스 축이 사라진 게 아니라 옮겨갔습니다 (2026.09.09, 정상호)

「”EXAONE 을 쓰지 않는다”는 결론만 자문으로 확인받으면 됩니다」에 답합니다.

확인했습니다 — 맞습니다. 실물로 봤습니다.

grep -nE 'genai|anthropic|openai' www/package.json   # @google/genai 만
grep -rn 'gemini-2.5-flash\|GEMINI_API_KEY' www/src/app/api/chat/route.ts

챗봇 경로에 EXAONE 도 vLLM 도 없습니다. ho 1번(EXAONE NC)과 완전히 분리됩니다. 반대 방향도 적어 둡니다 — ho 1번에서 조사한 대체 후보 (Qwen3-1.7B · Kanana · Mi:dm)는 리포트 근거 문장을 쓰는 자리라 챗봇과 합칠 것이 아닙니다. 하는 일이 다릅니다(슬롯 채우기 ↔ 자세 서술).

키를 서버에서만 읽는 것도 맞게 하셨습니다 — 라우트 핸들러의 process.env.GEMINI_API_KEY 라 브라우저로 안 나갑니다.

🔴 그래서 「라이선스는 해결됐다」로는 읽지 마세요

EXAONE 의 NC 는 모델 가중치의 제약이었고, Gemini 무료 등급은 API 이용약관의 제약입니다. 성질은 다른데 걸리는 자리가 같습니다 — 「지금 개발에는 되는데 상업 오픈에도 되는가」.

🔴 제가 단정하지 않습니다. 약관은 바뀌고 저는 지금 조회할 수 없습니다. 상업 오픈 전에 확인하실 것 셋만 적습니다.

확인할 것 왜 우리에게 걸리나
무료 등급의 데이터 취급 — 입력이 모델 개선에 쓰이는가 챗봇에 팀명·경기 일시·장소·사용자 발화가 들어갑니다. 개인정보는 아니어도 고객 데이터입니다
무료 등급을 상업 서비스에 쓸 수 있는가 · 유료 전환 시점 EXAONE NC 와 같은 종류의 질문입니다
요청 한도(RPM·일일) 🔴 시연 중에 막히면 그게 곧 장애입니다. 무료 등급으로 바꾼 계기가 카드 미등록이었으니 한도도 무료 등급 것입니다
  • 기한은 ho 1번과 같은 자리(서비스 상업 오픈 전)로 두시길 권합니다. 지금 개발·시연에는 문제 없고, 박민호 님이 ho 1번에 대해 내리신 「10주 개발 기간엔 급하지 않다」는 판단이 여기에도 그대로 맞습니다
  • EXAONE 과 달리 이쪽은 저장소에 실리는 것이 없습니다(키만 환경변수). 그 점은 더 낫습니다

  • 담당: 박민호(직접 구현) · 자문: 정상호(AI 자원·라이선스) ✅ 회신 (2026.09.09) — 상업 오픈 전 약관 확인 셋을 남김 · 연동: 정어진(기존 API, 필요시 계약 문의) · 제기: 박민호 · 기한: 이번 스프린트(흐름 B) · 나머지는 다음 스프린트 계획 시

8. fastapi/CLAUDE.md의 컨텍스트 목록이 코드보다 뒤처져 있습니다 ✅ 해소 (2026-09-08)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

9. 분석 리포트가 아직도 mock — 세 조각을 스프린트 3으로 한데 묶습니다 ✅ 1·2·3 전부 해소 (2026.09.10)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

10. Vercel 빌드가 24시간 rate limit에 걸렸습니다 — 기다리기로 결정 ✅ 해소 (2026.09.15)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

11. supersub(백엔드) 서버에 k3s 도입 ✅ 진행함 (2026.09.09, 회신 없이 박민호 판단)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

12. 이 WSL의 로컬 Postgres — DB 통합 테스트 막던 원인, 고쳐졌습니다 ✅ 해소 (2026.09.09)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

13. 로컬 k3s 트라이얼 배포 — 백엔드가 8080이 아니라 18080을 씁니다 ✅ 해소 (2026.09.09)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

14. 정책 신설 — 빌드·구동은 k3s로만, docker-compose는 안 씁니다 ✅ 해소 (2026.09.16)

11~13번 작업(AWS supersub·로컬 WSL에 k3s로 백엔드 올린 것)을 바탕으로 정책과 재현 절차를 문서로 남겼습니다.

   
정책 로컬 개발·EC2 운영 둘 다 k3s로만 빌드·구동한다. docker-compose는 쓰지 않는다
문서 www/docs/2026-09-09-K3S-harness.md — k3s 설치·이미지 빌드/반입·Secret 주입·Deployment 예시·실제로 걸렸던 함정(포트 충돌, WSL↔Windows 포워딩, pgvector 이미지) 전부 정리
목적 다음에 EC2(우분투 포함)에 새로 k3s를 놓을 때 이 절차를 그대로 따라가도록 — 매번 처음부터 다시 알아내지 않게

www/(백성검 영역)에 뒀지만, 백엔드·에이전트 쪽 배포에도 똑같이 적용되는 정책이라 여기 남깁니다. 지금 저장소엔 docker-compose* 파일이 없어서 당장 치울 것은 없습니다.

✅ 정어진 회신 (2026.09.09) — 정책 수용. 백엔드 cutover 는 아래 순서로

정책 받습니다. docker-compose 는 이 저장소에 없었고 제가 만든 로컬 k3s 세팅도 매니페스트라 어긋나는 것은 없습니다. min 11 (A) 의 「정식화 보류」 조항은 이 정책으로 거뒀습니다(min 11 에 정정 달았습니다).

🔴 다만 지금 EC2 는 아직 systemd 가 트래픽을 받습니다 — 8080 파드는 박민호 님 표기대로 트라이얼이고, docs/deployment.md 도 systemd 절차입니다. 「EC2 를 k3s 로만 구동」은 방향이지 현재 상태가 아니라, 배포 정본 담당으로서 아래를 순서대로 합니다. 각 단계가 검증되면 다음으로 갑니다.

순서 무엇 확인
1 ✅ fastapi/Dockerfile 을 저장소에 커밋 완료 (d62803b, 이후 2781588 로 좁힘) git -C fastapi ls-files Dockerfile
2 ✅ fastapi/deploy/k8s/deployment.yaml 을 EC2 형태(호스트 PostgreSQL을 hostNetwork: true로, in-cluster pgvector 없음)로 레포에 정본화 완료 (baecb75, jin 32번과 같은 커밋) git -C fastapi ls-files deploy/k8s/
3 ✅ 이미지 레지스트리 전략 확정 — Docker Hub pmhllll12/supersub:latest 고정 태그 + supersub-cd.timer가 2분마다 digest를 폴링해 감지하면 자체 재배포(커밋 SHA 태그가 아니라 digest 기반). deployment.md에 반영 deployment.md에 태그 규칙
4 ✅ docs/deployment.md 재작성 완료 (정어진, 커밋 a21af5f) — 「현재 배포 — k3s + CD」 절 신설, 사람이 준비할 것 표(GitHub Secrets·확장·env·S3·백업), 옛 systemd 절차(0·3·6절)에 「롤백·최초 세팅」 배너, 6절 = 롤백 런북. 🔴 DB 는 파드로 안 옮김 명시. test_docs_paths·전체 pytest 통과 —
5 ✅ cutover 완료 (박민호, 2026.09.11) — nginx proxy_pass 를 :8000 → :8080 으로 전환(설정 백업 후 nginx -t·reload), 스모크(/health 200·db_configured: true, /api/v1/positions·/api/v1/videos/public 계약대로 401) 통과 후 supersub-api.service disable + stop(유닛 파일은 남김 — 롤백 경로). 상세는 jin 31번 curl https://<API 호스트>/health → 200 · systemctl is-enabled supersub-api → disabled
6 ✅ CI — .github/workflows/backend-docker-build.yml 신설(main에 fastapi/** push 시 빌드+Docker Hub push) .github/workflows/backend-docker-build.yml 존재

🔴 2번(매니페스트를 저장소에 정본화)이 아직 안 된 채로 cutover가 먼저 됐습니다 — 지금 도는 배포(supersub-api-trial, default 네임스페이스)가 서버의 ~/k3s-trial/에만 있고 git에 없습니다. ✅ 해소 (2026.09.16) — jin 32번 회신대로 baecb75에서 정본화 완료.

🔴 Postgres 는 이 단계에서 안 옮깁니다 — 온박스 systemd Postgres 유지. DB 를 파드/PV 로 옮기는 건 별도 결정이고 백업·볼륨 계획이 선행입니다(min 11 (A) 그대로). min 14 정책도 “빌드·구동”이라 앱이 대상이지 DB 스토리지는 아니라고 읽습니다 — 아니면 알려 주세요.

처리: 1~6번 전 단계 완료 확인 — Dockerfile(d62803b·2781588), 매니페스트 정본화(baecb75), 레지스트리·태그 전략(Docker Hub latest + digest 폴링, deployment.md), CI 이미지 빌드(backend-docker-build.yml) 전부 실물 확인.

  • 담당: 전체(박민호·백성검·정어진·정상호) · 제기: 박민호 · 기한: 확인되는 대로

확인 — agent/ 는 치울 것이 없고, 동시에 k3s 위에도 없습니다 (2026.09.09, 정상호)

「지금 저장소엔 docker-compose* 가 없다」는 agent/ 에서도 맞습니다. 다만 확인해 보니 없는 것이 compose 만이 아닙니다 — agent/ 는 컨테이너를 아예 안 씁니다.

git ls-files | grep -iE 'docker-compose|compose\.ya?ml|dockerfile'          # 0건 (저장소 전체)
grep -rniE '\bdocker\b|podman|containerd|k3s|kubectl|kubernetes' agent/ \
  --include='*.sh' --include='*.service' --include='*.md' --include='*.py'  # 0건

그래서 정책을 어떻게 읽느냐에 따라 판정이 갈립니다.

정책을 이렇게 읽으면 agent/ 는
「docker-compose 를 쓰지 않는다」 ✅ 이미 준수 — 치울 것 없음
「빌드·구동은 k3s로만 한다」 🔴 미준수 — 호스트 systemd 유닛 3개(supersub-vllm · supersub-worker · supersub-autostop.timer)로 돕니다

🔴 어느 쪽인지 정해 주셔야 합니다. 제 쪽에서 정할 일이 아닙니다.

지금은 옮기지 않기를 권합니다 — 이유 셋 다 GPU에서 옵니다

「하면 안 된다」가 아니라 「이 셋을 먼저 풀어야 한다」는 뜻입니다.

   
1. GPU 입도가 안 맞습니다 지금은 vLLM 과 포즈 파이프라인(RT-DETR + ViTPose)이 T4 한 장을 나눠 씁니다 — vLLM 이 SUPERSUB_GPU_FRACTION=0.35 만 잡고 나머지를 포즈가 씁니다(deploy/serve_vllm.sh). 그런데 k8s 표준 NVIDIA 디바이스 플러그인의 nvidia.com/gpu: 1 은 장 단위라 두 파드가 한 장을 나눠 요청할 수 없습니다. time-slicing 이나 MPS 를 따로 켜야 하고, 그건 새 설정 축입니다
2. 모델 가중치가 호스트에 있습니다 ExecStartPre=sync_model.sh 가 없으면 S3 에서 받아 놓습니다(supersub-vllm.service). 파드로 가면 PVC 나 initContainer 로 옮겨야 하고, 적재에 TimeoutStartSec=900 이 걸려 있는 기동이라 프로브 설정이 같이 붙습니다
3. autostop 이 호스트 프로세스를 봅니다 pgrep -f "$BUSY_PATTERN" 으로 분석 중인지 판정합니다(deploy/autostop.sh). 여기가 틀리면 분석 도중에 인스턴스가 꺼지고 그 작업은 running 인 채로 남습니다 — 회수 규칙이 아직 없어 사실상 영구입니다. test_worker.py::test_the_analysis_child_is_seen_as_busy_by_autostop 이 지키고 있는 성질입니다

🔴 3번은 「깨진다」고 단정하지 않습니다 — 확인하지 않았습니다. 컨테이너 프로세스도 호스트 pgrep 에는 대개 보이므로 그대로 살 가능성이 큽니다. 다만 살아 있는지를 확인하지 않은 채 옮기면 안 되는 자리입니다. 틀렸을 때의 대가가 「분석이 조용히 죽는다」라서입니다.

비용은 GPU 노드를 k3s 에 붙이는 것(NVIDIA 컨테이너 툴킷 + 디바이스 플러그인) 자체보다 1번이 큽니다. 한 장을 둘이 나눠 쓰는 지금 구조가 실측 위에 서 있습니다 — 포즈 단독 피크 903MiB · 동시 6341MiB · 여유 9019MiB (ho 1번, eval/pending1_gpu_budget/).

판단해 주실 것

  1. 정책의 적용 범위 — 백엔드·웹(무상태 컨테이너)까지입니까, GPU 워커까지입니까
  2. GPU 워커까지라면 언제 — 위 셋을 푸는 회차가 필요하고, 지금 스프린트 2 남은 일감(ho 6·7번)과 겹칩니다
  • 관련: ho 1번(GPU 예산 실측) · jin 18번(워커 폴링 루프) · ho 26번(워커 정지)
  • 담당: 박민호(적용 범위 판단) ✅ 결정 (2026.09.11) · 제기: 정상호 · 기한: 스프린트 3 계획 전 완료

✅ 결정 (2026.09.11, 박민호)

권고 그대로 갑니다. 정책 범위는 백엔드·웹(무상태 컨테이너)까지입니다. GPU 워커 (agent/)는 지금 범위 밖이고, 호스트 systemd 유닛 3개(supersub-vllm· supersub-worker·supersub-autostop.timer) 그대로 유지합니다.

이유: 지적하신 셋(GPU 입도 문제·모델 가중치 로딩·autostop 프로세스 감지) 중 하나라도 확인 없이 옮기면 “분석이 조용히 죽는다”는 대가가 있는데, 지금 그걸 먼저 풀어야 할 급한 이유가 없습니다 — 백엔드 cutover(jin 31번, 09-11 완료)로 정책의 핵심 목적(운영 배포 자동화)은 이미 백엔드 쪽에서 달성됐습니다.

다시 볼 시점: 위 세 가지 중 하나라도 실측·해소되면(특히 GPU time-slicing/MPS 설정 방법이 확인되면) 재검토합니다. 그전까지는 손대지 않습니다 — 확인 안 된 채로 옮기지 않는다는 원칙 그대로입니다.

15. 영상 업로드 「요청 값이 올바르지 않습니다: filename」 — 원인 찾아 고쳤습니다 ✅ 해소 (2026.09.09)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

16. 용병 매칭 필드 마이그레이션 추가 — user에 DB·ORM만 올라간 상태입니다

fastapi/alembic/versions/28148877afc0_add_mercenary_matching_fields.py — 초안 파일(xxxx_add_mercenary_matching_fields.py)을 리비전 ID 생성해 옮기고 실행했습니다.

   
추가된 컬럼 user 테이블에 preferred_positions·available_slots·location·skill_summary·is_searchable·skill_embedding(pgvector 768차원) + HNSW 인덱스
초안에서 고친 것 테이블명 가정(users)이 틀려서 실제 user(단수)로 정정 · pgvector 파이썬 패키지가 없어서 설치 후 requirements.txt·requirements.lock.txt 반영 · raw SQL의 user 예약어 인용 누락 수정 · 마이그레이션 주석의 CREATE EXTENSION 문구가 TestMigrationPrivileges(글자 그대로 스캔)에 걸려서 문구 재작성
ORM app/user/adapter/outbound/orm/user_orm.py에 컬럼 6개 + Index(..., postgresql_using="hnsw") 매핑 추가 — 안 했으면 다음 alembic --autogenerate가 방금 추가한 컬럼을 지우려 들었을 것입니다
확인 alembic heads 단일(28148877afc0) · alembic check 클린 · 전체 pytest 693 passed, skipped 0
🔴 하지 않은 것 이 필드를 쓰는 애플리케이션 코드(라우터·서비스·도메인 로직)는 전혀 없습니다 — DB·ORM 스키마만 올라간 상태입니다. “용병 매칭” 기능 자체의 설계(정말 필요한 필드가 무엇인지, 임베딩을 누가 언제 채우는지 등)도 아직 없습니다

user는 정어진 소유 컨텍스트라 스키마 변경을 알립니다 — 검토 부탁드립니다.

🔴 급합니다 (2026-09-16 추가) — min 18·19·20번(팀 만들기 → 검색 가능 프로필 → 초대) 체인이 지금 진행 중이고, 19번이 바로 이 컬럼(preferred_positions· available_slots·skill_summary·is_searchable)을 쓰는 화면입니다. 이 검토가 늦어지면 스키마가 확정되지 않은 채로 화면을 만들게 됩니다.

  • 담당: 정어진(검토) ✅ 회신함 · 제기: 박민호 · 기한: 🔴 급함 — 확인되는 대로가 아니라 가능한 빨리

✅ 검토 회신 (2026.09.16, 정어진) — 구현은 끝나 있습니다. 그런데 다른 문제가 있습니다

먼저 — 이 항목의 “안 됐다”는 서술이 낡았습니다

“🔴 하지 않은 것: 애플리케이션 코드는 전혀 없습니다”라고 적혀 있는데, git log로 보니 이 마이그레이션과 애플리케이션 코드(라우터·리포지토리· 인터랙터·임베딩 어댑터까지) 전부가 같은 커밋(17b6749, 2026-09-10)에 이미 들어와 있었습니다 — 오늘 새로 올라온 게 아니라 6일 전부터 있었던 것입니다. 실제로 끝까지 확인했습니다:

확인 결과
alembic heads/alembic check 단일 head, 클린
app/main.py 배선 mercenary_router 등록됨(GET/PATCH /me/mercenary-profile, POST /matching/search-candidates)
테스트 test_mercenary_interactors.py·test_mercenary_pg_db.py 11건 전부 통과(DB 포함)
임베딩 skill_summary가 바뀔 때만 Gemini로 다시 계산(불필요한 API 호출 안 함), is_searchable=true는 포지션·시간·소개 셋 다 채워야만 허용(422로 막음) — 설계가 꼼꼼합니다

스키마·구현 자체는 문제없습니다. 이 부분으로는 18·19·20번을 막을 이유가 없습니다.

🔴 진짜 문제 — 같은 개념을 두 번 다르게 저장하고 있습니다

이 마이그레이션(09-10)과 paik 18~21·27번이 쓰는 member_match_* 테이블(09-15, 20260915_match_preferences.py)이 서로 모르고 같은 것을 두 가지 방식으로 저장합니다:

개념 이 마이그레이션(user 테이블, 09-10) match 컨텍스트(09-15)
뛸 수 있는 포지션 preferred_positions("sport:code" 문자열 배열) member_match_position(대리키 테이블, position.id FK)
가능 시간 available_slots(JSON 배열) member_match_slot(자식 테이블, 컬럼별)
지역 location(자유 문자열) member_match_region(region.id FK, 시/구 계층)

더 아이러니한 건, match 쪽 마이그레이션 자기 자신의 머리말이 이걸 반박하는 근거를 이미 적어 뒀다는 것입니다: “지역·요일시각은 여러 개라 jsonb/배열 대신 자식 테이블로 나눈다(제1정규형)” — 그런데 available_slots JSON 배열이 5일 먼저 정확히 그 방식으로 들어와 있었습니다. 두 세션이 서로 모르고 만든 결과입니다.

지금 뭘 해야 하나 — 지금 당장 고치자는 게 아닙니다

둘이 뜻이 다를 수도 있습니다 — preferred_positions/available_slots는 RAG 의미 검색(챗봇이 “비슷한 사람”을 코사인 유사도로 찾음)용이고, member_match_*는 정확한 일정 매칭(겹치는 시간을 계산)용이라 질의 방식 자체가 다릅니다. location(자유 문자열, 챗봇이 대화에 읽어 주는 용도로 보입니다)과 region(구조화된 FK, 계층 비교용)도 마찬가지로 쓰임이 다를 수 있습니다.

그래서 지금 당장 하나로 합치자는 제안이 아닙니다 — 20번(초대·수락 흐름)을 설계하실 때 이 중복을 모르고 세 번째 표현을 또 만들지만 않으면 됩니다. 포지션·시간·지역 조건이 다시 필요해지면, 새로 만들기 전에 이 둘 중 어느 것을 쓸지(또는 왜 둘 다 필요한지) 먼저 정하고 가시면 됩니다.

  • 관련: paik 18~21·27번(member_match_*) · jin 35번(is_nickname_searchable— 이 프로필의 is_searchable과는 다른 스위치입니다, 헷갈리기 쉬워 적어 둡니다)
  • 확인 명령: git log --oneline -- alembic/versions/28148877afc0_add_mercenary_matching_fields.py → 17b6749(2026-09-10) 하나만 나오면 이미 반영된 것

17. 용병 후보 검색 API 구현 — app/main.py 배선과 SFR-006·007 구분 확인 부탁드립니다

16번 위의 스키마를 실제로 쓰는 애플리케이션(라우터·유스케이스·저장소·Gemini 임베딩 어댑터)을 user 컨텍스트 안에 전부 만들었습니다. 사용자가 요청한 “AI 채팅창에 RAG 붙여서 용병 찾기·팀 매칭” 중 검색(Retrieval) 부분입니다 — 생성(챗봇 응답 구성)은 www/의 몫이었는데, 이 항목을 올린 뒤 이어서 붙였습니다 (아래 안 한 것 4 참고).

   
새 파일 app/user/domain/entities/{mercenary_profile_entity,candidate_entity}.py · application/ports/{output/mercenary_port.py,output/embedding_port.py,input/{get,update}_mercenary_profile_use_case.py,input/search_candidates_use_case.py} · application/dtos/mercenary_dto.py · application/use_cases/{get,update}_mercenary_profile_interactor.py·search_candidates_interactor.py · adapter/outbound/{pg/mercenary_pg_repository.py, stub/{mercenary_stub_repository,stub_embedding_adapter}.py, google/gemini_embedding_adapter.py} · adapter/inbound/api/v1/mercenary_router.py(+ schema) · dependencies/{mercenary_repository,embedding,get_mercenary_profile,update_mercenary_profile,search_candidates}_provider.py
엔드포인트 GET·PATCH /api/v1/me/mercenary-profile · POST /api/v1/matching/search-candidates. 규격은 docs/api-contract.md 3-11절
🔴 SFR-006·007과의 관계 대체 아님, 별개 기능입니다. 3-11절에 표로 구분해 뒀습니다 — 이 검색은 지원 전 팀이 능동적으로 하는 것이고, SFR-006(fitness_score, 지원 후 3축 채점)·SFR-007(recommendation, 경기별 추천+사유 저장)은 도메인 ④에 예약만 된 별도 테이블입니다. 나중에 SFR-007 구현 때 이 검색을 retrieval 단계로 재사용할지는 정어진 판단입니다
포지션 인코딩 preferred_positions는 "<sport_code>:<code>" 문자열(도메인은 PositionRef 쌍으로만 다루고, 인코딩은 PG 어댑터 안에서만) — 포지션 약칭이 종목 간 겹쳐서입니다(position 테이블 주석과 같은 이유). DB 테스트로 야구 C·농구 C가 안 섞이는 것 확인했습니다
임베딩 Gemini gemini-embedding-001, 768차원(기존 컬럼과 일치) — 처음엔 text-embedding-004로 적었으나 아래에서 은퇴된 모델임이 드러나 정정했습니다. skill_summary가 실제로 바뀔 때만 재계산 — 포지션만 바꾸는 요청은 임베딩 API를 안 탑니다(EmbeddingDep을 즉시 resolve하지 않고 팩토리로 넘겨서 처리)
확인 인터랙터 단위 테스트(tests/user/application/test_mercenary_interactors.py, 스텁만) · DB 통합 테스트(tests/user/adapter/test_mercenary_pg_db.py, @pytest.mark.db, 실제 pgvector 유사도 순위·종목 간 포지션 미충돌 확인) · alembic check 클린 · 전체 pytest 704 passed, skipped 0
🔴 안 한 것 1 — app/main.py 미배선 ✅ 했습니다 (2026.09.10, 박민호 — 공유 파일이지만 이 항목의 정어진 몫을 대신 처리하기로 함). mercenary_router import + 등록 추가, tests/user/adapter/test_auth_router.py의 openapi 경로 목록(공유 파일이지만 등록의 자연스러운 결과라 같이 고쳤습니다)에도 두 경로를 반영. 전체 pytest 507 passed(비-DB), 12 failed/28 errors는 전부 이 세션에 로컬 Postgres가 안 떠 있던 것뿐(기존 DB 테스트도 전부 같은 모양으로 실패해 새 코드 문제 아님)
🔴 안 한 것 2 — GEMINI_API_KEY 미배포 ✅ 했습니다 (2026.09.10, 박민호). 박민호가 aistudio.google.com/apikey에서 fastapi 전용 새 키를 발급해 서버 ~/supersub/app/fastapi/.env에 넣고(0600 유지) supersub-api 재시작·/health 200 확인. 실제 키로 호출해보니 text-embedding-004가 이미 은퇴돼 404(NOT_FOUND)였습니다 — client.models.list()로 embedContent 지원 모델을 뽑아 gemini-embedding-001로 정정, 768차원 벡터 반환까지 확인(위 표 「임베딩」 정정 참고). 가짜 키로는 이 오류가 안 잡혔을 것입니다
🔴 안 한 것 3 — 검색 권한 범위 ✅ 박민호 결정 (2026.09.10): 현행 유지 — 로그인만 하면 누구나 검색 가능. 스프린트 안엔 데모·시연이 우선이라 권한 세분화는 나중 스프린트로 미룹니다. 코드 변경 없음
🔴 안 한 것 4 — www/ 연동 ✅ 했습니다 (2026.09.10) — MatchBot.tsx가 흐름 D로 이 검색을 부르고, 결과를 다시 Gemini에 넣어 소개 문장으로 엮습니다. 상세: docs/client-contract-changes.md 30번

root/.env.example에는 안 넣었습니다 — fastapi/.env.example이 “정본은 루트 파일”이라 적어 두었지만 JWT_SECRET·GOOGLE_CLIENT_IDS 등 기존 키들도 실제로는 루트에 없어서, 이미 있던 관례(fastapi 쪽에만 유지)를 따랐습니다. 이 문서 자체의 드리프트는 별건이라 여기서 고치지 않았습니다.

이 항목은 담당(정어진) 대신 제기자(박민호)가 직접 처리했습니다 — SFR-006·007 구분은 위 표에 이미 근거를 남겨 뒀고, 정어진이 보시고 다른 판단이면 이 항목 아래에 남겨 주세요. app/main.py 배선처럼 원래 공유 파일 규칙(fastapi/CLAUDE.md) 상 정어진 몫으로 남겨뒀던 것을 이번엔 예외적으로 직접 했다는 점만 유의해 주시면 됩니다.

✅ 해소 (2026.09.10) — 안 한 것 네 가지 전부 닫혔습니다. www/가 챗봇에서 이 검색을 실제로 부르면 end-to-end로 동작합니다(로컬 코드 기준 — 서버 배포는 아직 이 브랜치가 병합되지 않아 별개입니다).

  • 담당: 정어진 박민호가 대신 처리 (2026.09.10) · 제기: 박민호 · 기한: 확인되는 대로

18. 팀 만들기 화면이 없습니다 — 신규 계정이 주장 전용 기능을 하나도 못 씁니다 (2026-09-10 신설)

min 7번에서 이미 한 번 짚었던 것(“발견한 것 — 팀을 만드는 화면이 아직 없습니다… 담당은 아직 안 정했습니다”)을 정식 항목으로 올립니다. 오늘 챗봇 검색을 실제 계정으로 테스트하다가 막혀서 원인을 다시 확인했습니다.

POST /teams는 백엔드에 있는데(fastapi/docs/api-contract.md 937절, 만든 사람이 자동으로 owner) 그걸 부르는 화면이 www/에 없습니다 (git grep -n "POST /teams\|createTeam" www/src — app/api/chat/route.ts의 프롬프트 문구에서만 언급될 뿐, 실제 호출부는 0건). app/api/teams/ 아래도 [teamId]/... 하위 라우트만 있고 상위 route.ts가 없습니다.

🔴 테스트 편의 문제가 아닙니다. 챗봇 등록·검색, 경기 만들기, 스쿼드 관리가 전부 “주장인 팀”을 전제로 이미 만들어져 있는데, 그 팀을 처음 만드는 길이 없어서 신규 가입 계정은 이 기능들 중 아무것도 못 씁니다. 2026-09-03부터 배포가 실제 DB를 봐서 목업 데모 계정(demo@super-sub.example) 우회도 이제 안 먹힙니다(paik 10번).

만족해야 할 성질

로그인한 사용자가 화면에서 팀을 만들 수 있고, 그 팀의 주장(owner)이 된다.

화면 위치·형태는 예시일 뿐 규격이 아닙니다. 아래는 기존 관례 (createSquad·createTeamMatch)를 따라간 참고 구성입니다 — 제가 직접 진행하며 바뀔 수 있습니다.

계층 자리(예시)
백엔드 연동 3종 gateway.ts에 createTeam 추가 · fastapiBackend.ts(POST /api/v1/teams) · mock.ts
BFF 라우트 app/api/teams/route.ts(신규, POST 하나)
화면 app/(app)/teams/new/page.tsx(이름·지역·종목 폼)
진입점 app/page.tsx가 이미 user.teams[0]?.team_id로 “팀 없음”을 알고 있음(51행 근처) — SquadPanel이 sportCode === null일 때 빈 스쿼드 판 대신 “팀 만들기” 안내로 갈아 끼우면 됨

확인

   
확인 팀이 없는 신규 계정으로 로그인 → 화면에서 팀 생성 → GET /me의 teams에 role: owner로 뜨는지. 그 뒤 챗봇에 검색 요청이 “주장인 팀이 없다” 안내 없이 실제로 진행되는지
하지 말 것 🔴 팀 생성을 챗봇 대화로 대신 처리하지 않기 — 챗봇은 이미 있는 팀을 전제로 한 두 흐름(등록·검색)만 다룬다(min 7번 범위 정정과 같은 방향)
  • 관련: min 7번(원래 이 갭을 처음 적어 둔 자리) · paik 10번(데모 계정 우회 불가) · api-contract.md 937절
  • 담당: 박민호(직접 진행) · 제기: 박민호 · 기한: 확인되는 대로

19. 내 프로필을 “검색 가능”으로 켜는 화면이 없습니다 — 용병 검색이 항상 빈 결과입니다 (2026-09-10 신설)

18번(팀 만들기)과 짝입니다 — 팀을 만들어도 검색당할 사람이 DB에 없으면 search_candidates는 매번 빈 배열을 돌려줍니다(에러 아님, 정상 동작).

GET·PATCH /api/v1/me/mercenary-profile(api-contract.md 2246절)가 preferred_positions·available_slots·skill_summary·is_searchable을 다루는데, 이걸 부르는 화면이 www/에 없습니다 — grep -rln "mercenary-profile\|is_searchable\|skill_summary" www/src로 찾히는 건 gateway.ts·types.ts·mock.ts·MatchBot.test.tsx뿐이고, MatchBot.tsx는 검색 결과의 skill_summary를 보여주기만 합니다(213행) — 내 프로필을 채우는 자리가 아닙니다.

🔴 is_searchable을 켜려면 셋을 한꺼번에 채워야 합니다 (preferred_positions·available_slots·skill_summary 중 하나라도 비면 422 MERCENARY_PROFILE_INCOMPLETE). skill_summary가 바뀌는 요청만 Gemini 임베딩을 다시 계산합니다(포지션·시간만 바꾸면 임베딩 API를 안 탑니다).

만족해야 할 성질

로그인한 사용자가 화면에서 자기 선호 포지션·가능 시간·소개 문장을 채우고 “용병으로 찾아지기”를 켤 수 있다.

화면 위치·형태는 예시일 뿐 규격이 아닙니다. 자연스러운 자리는 /me (프로필 화면) 안 — 카드 꾸미기(paik 3번)와 같은 성격의 “내 정보” 섹션입니다.

확인

   
확인 화면에서 포지션·가능 시간·소개·검색 가능 스위치를 채운 뒤, 다른 계정의 팀장이 챗봇으로 같은 종목·포지션을 검색하면 그 사람이 후보로 뜨는지
하지 말 것 🔴 is_searchable만 켜고 나머지를 비워두는 UI를 만들지 않기 — 서버가 422로 막아도, 화면에서 셋을 같이 입력받게 해야 사용자가 “왜 안 켜지지”에서 막히지 않는다
  • 관련: 18번(팀 만들기 — 같은 세션에서 발견) · api-contract.md 2246·2310절(GET/PATCH /me/mercenary-profile·POST /matching/search-candidates)
  • 담당: 박민호(직접 진행) · 제기: 박민호 · 기한: 확인되는 대로

20. 용병 검색 결과에서 후보를 고를 수가 없습니다 — 목록만 보여주고 끝입니다 (2026-09-10 신설)

18·19번과 같은 세션에서 발견했습니다. search_candidates가 후보를 찾아 주는 데까지는 되는데, 그다음이 없습니다.

MatchBot.tsx(180행 근처)를 보면 등록 흐름은 확인 카드에 [등록] 버튼이 있어 실제로 처리되는데, 검색 결과(candidates)는 <li>로 닉네임·지역· 소개 문장만 나열할 뿐 클릭·선택·초대 동작이 전혀 없습니다. “찾아주는” 기능은 끝났고 “찾은 사람을 실제로 데려오는” 기능이 빠져 있습니다.

✅ 열린 질문 1번 — 결정됨 (2026.09.10, 박민호)

초대를 보내고, 상대방이 수락해야 확정되는 쪽으로 갑니다. 주장이 동의 없이 바로 팀원으로 꽂는 방식은 안 씁니다 — 검색 대상자 본인이 모르는 채로 어딘가에 등록되는 것을 막습니다.

🔴 그런데 이 방식엔 없는 것이 하나 더 있습니다 — 초대·수락 개념 자체가 계약에 없습니다

api-contract.md 933절(3-3. 팀)에 이렇게 못박혀 있습니다:

가입 | 본인이 가입하거나 주장이 넣는다. 초대·승인 테이블이 부록 D에 없어 신청-승인 흐름은 넣지 않았다

즉 지금 있는 것은 정반대 방향뿐입니다 — 경기 지원(흐름 A, GET /matches → POST .../applications → 팀 쪽이 .../applications/{id}/accept)은 선수가 스스로 지원하고 팀이 수락하는 구조입니다. 이번에 필요한 건 팀이 먼저 콕 집어 초대를 보내고 선수가 수락하는, 방향이 반대인 흐름이라 그대로 재사용할 수 없습니다 — 다만 “제안 → 수락으로 확정” 이라는 상태 전이 모양은 닮았으니 참고는 될 수 있습니다.

🔴 그래서 이건 www 혼자 끝낼 수 있는 일이 아닙니다. 새 테이블(초대· 알림)과 새 엔드포인트가 필요해 보여서 정어진과 스키마부터 맞춰야 합니다 — 루트 CLAUDE.md의 “스키마·API 계약을 바꾸는 변경은 미리 알려 주세요”가 그대로 적용됩니다.

만족해야 할 성질

팀장이 검색 결과에서 후보를 고르면 그 사람에게 초대가 가고, 그 사람이 수락해야 팀(또는 그 경기)에 실제로 반영된다.

테이블·엔드포인트 이름은 예시일 뿐 규격이 아닙니다.

확인

   
확인 검색 결과에서 “초대” 동작 → 상대방 계정에 초대 알림이 뜨는지 → 수락 시 GET /teams/{id}의 members[](또는 그 경기의 needs)에 실제로 반영되는지. 거절·무시 시엔 아무것도 안 바뀌는지
하지 말 것 🔴 동의 없이 POST /teams/{id}/members로 바로 추가하지 않기 — 오늘 결정과 반대 방향입니다

✅ 정어진 조각 — 백엔드는 붙었습니다 (2026.09.16)

초안대로 구현했습니다. 아래 초안 절의 설계가 그대로 실물이 됐고, 바뀐 것은 경로 셋뿐입니다(/invitations/{id}/accept → /me/invitations/{id}/accept, 거절도 같은 자리, 무르기는 DELETE /teams/{id}/invitations/{id}) — 받는 사람 쪽 동작은 팀 경로 밑이 아니라 자기 자리(/me)에 두는 것이 맞아서 /me/contacts 와 같은 모양으로 맞췄습니다.

무엇  
테이블 team_invitation(user 컨텍스트, team_member 옆). 마이그레이션 b3e7c92f5a14
보내기 POST /teams/{team_id}/invitations — 주장만. 받은 사람에게 알림
내가 받은 것 GET /me/invitations — 아직 답 안 한 것만
수락·거절 POST /me/invitations/{id}/accept · /reject — 받은 사람 본인만
무르기 DELETE /teams/{team_id}/invitations/{id} — 주장만, pending일 때만. 무른 초대를 본문으로 돌려줍니다
알림 team_invitation_sent(받은 사람) · team_invitation_accepted·team_invitation_rejected(팀 주장). 무르기는 알림 없음

🔴 수락은 새 가입 로직을 안 만들었습니다 — 기존 JoinTeamInteractor를 자기-가입으로 그대로 부릅니다(can_add_member가 “자기 자신은 아무나”를 이미 허용). 그래서 중복 가입 방지·재가입 이력 같은 기존 규칙이 그대로 적용됩니다.

계약 변경은 docs/client-contract-changes.md 49번으로 백성검 님께 전달했습니다. api-contract.md 3-3절의 「초대·승인 테이블이 없어 신청-승인 흐름은 넣지 않았다」는 문장도 정정 표시와 함께 고쳤습니다. 계약·DB 2층 테스트 25건 추가, 전체 pytest -q 977 passed, alembic check 클린.

남은 것: 박민호 님 — 제품·화면(초대 버튼·초대함). 아래 열린 질문 2번은 제가 “팀 가입”으로 판단하고 진행했으니, 다르게 보시면 알려 주세요(테이블이 작아서 되돌리기 쌉니다).

🔴 근거 하나가 틀렸던 것을 같은 날 저녁에 찾았고, 다시 따라가 보니 결론은 그대로였습니다 — 아래 초안 절의 「정정」과 그 아래 「열린 질문 2번 — 팀 가입이 맞습니다」를 함께 봐 주세요. 요점은 스쿼드 등재가 팀 구성원만 받는다는 것이라, 팀 가입 초대가 챗봇 검색과 paik 27번 AI 추천 판 둘 다의 공통 다리입니다. 그쪽 판에 「초대」 단추가 붙기 전까지 AI 추천 판은 눌러도 다음이 없는 화면입니다.

정어진 초안 (2026.09.16) — 위 구현의 근거

열린 질문 2번 — 팀 가입인가, 이 경기 로스터인가? 본문이 “팀(또는 그 경기)”로 열어 뒀는데, 실제로 걸린 자리(MatchBot.tsx의 챗봇 검색 → 새 용병을 데려오는 흐름, api-contract.md 933절 “가입” 절)를 보면 팀 가입입니다. paik 27번(빈 자리 추천)은 별개로 이미 그 팀 소속인 사람 중에서만 고르는 기능이라(그 라우터 docstring에 “그 팀 소속만”이라고 못박아 뒀습니다) 이번 것과 안 겹칩니다. 팀 가입이 맞다고 보고 아래로 갔습니다 — 아니면 알려 주세요.

🔴 정정 (2026.09.16 저녁) — 위 취소선 문장이 틀렸습니다. paik 27번의 후보는 그 팀 소속이 아닙니다. 오히려 소속을 제외합니다. 제가 라우터 docstring 의 「그 팀 소속만」을 후보의 범위로 읽었는데, 그건 부르는 사람에 대한 제한이었습니다(팀원만 후보 목록을 볼 수 있다 — 403 "그 팀 소속만 후보를 볼 수 있습니다"). 실제 후보는 member_match_position(매칭 선호에 그 포지션을 등록한 사람)에서 가져오고, 거기서 현재 팀원과 이미 스쿼드에 앉은 사람을 뺍니다 (squad_recruitment_facts 의 「하드 필터 2」).

그래서 27번도 「팀 밖 사람을 데려오는」 흐름이 맞고, 이 항목과 겹치는 면이 있습니다. 안 겹친다는 것을 팀 가입 쪽으로 정한 근거 하나로 썼으니, 그 근거는 물러납니다.

✅ 열린 질문 2번 — 팀 가입이 맞습니다 (2026.09.16 저녁, 근거 교체)

위 정정으로 근거 하나가 물러난 뒤 다시 따라가 봤더니, 더 나은 근거가 나왔습니다. 겹치는 것이 문제가 아니라 겹치는 두 흐름이 같은 조각을 기다리고 있었습니다.

🔴 스쿼드 등재는 팀 구성원의 카드만 받습니다 — POST /teams/{id}/squad/members 가 422 NOT_TEAM_MEMBER 로 막습니다 (squad_interactors.py · 소속이기만 하면 통과). 그런데 위에서 본 대로 paik 27번 추천 후보는 팀원을 제외한 사람들입니다.

즉 지금 AI 추천 판은 「등재할 수 없는 사람」을 추천하고 있습니다. 추천을 받아도 그 사람을 앉히려면 먼저 팀원이 되어야 하는데, 팀원이 되는 유일한 경로(POST /teams/{id}/members)는 이 항목이 2026-09-10에 “동의 없이 꽂지 않는다”로 막은 그 방향이었습니다.

흐름 찾는 것 데려오려면
이 항목(챗봇 용병 검색) 용병 프로필로 찾은 사람 팀 가입 초대
paik 27번(AI 추천 판) 매칭 선호로 찾은 사람 팀 가입 초대 → 그다음 스쿼드 등재

그래서 team_invitation 은 두 흐름의 공통 다리이고, 「팀 가입」이 맞습니다. 경기 로스터(스쿼드) 쪽으로 갔다면 등재의 소속 전제와 어긋나 같은 벽을 한 번 더 만들었을 것입니다.

🔴 박민호 님께 — 이건 paik 27번 쪽 소식이기도 합니다. AI 추천 판에 「초대」 단추가 붙기 전까지 그 판은 눌러도 다음이 없는 화면 입니다. 화면 작업을 잡으실 때 두 판(챗봇 검색 결과·AI 추천 판)이 같은 초대 경로를 쓰면 됩니다.

스쿼드 등재의 소속 전제를 푸는 선택지도 있습니다 — squad_interactors.py 주석이 “용병을 스쿼드에 넣어야 할 일이 생기면 여기를 고치면 된다(외래키는 그대로 둔다)” 고 미리 적어 뒀습니다. 다만 그건 제품 판단이라 제가 안 건드렸습니다.

설계: team_member(user 컨텍스트)와 나란히 team_invitation 테이블을 새로 둡니다. team_match_request(paik 17번)와 모양이 닮았다고 앞서 말씀드렸는데, 그건 상태 전이(pending→accepted/rejected/cancelled, 문자열이라 DB 제약 없음 — analysis_job.status와 같은 판단)만 본뜨는 것이고, 테이블 자체는 match가 아니라 user 컨텍스트에 둡니다 — team_member가 거기 있어서 나란히 둬야 남의 컨텍스트를 안 건드립니다.

컬럼   엔드포인트  
id   POST /teams/{id}/invitations 주장이 보낸다(body.user_id). 이미 소속·이미 대기 중인 초대가 있으면 409
team_id → team.id   GET /me/invitations 내가 받은 대기 중 초대 목록(알림 화면이 부른다)
invited_user_id → user.id, CASCADE   POST /invitations/{id}/accept 받은 사람만. JoinTeamInteractor를 자기-가입(actor_id == user_id)으로 그대로 부릅니다 — can_add_member가 “자기 자신은 아무나” 이미 허용하므로 새 가입 로직을 안 만들어도 됩니다
status   POST /invitations/{id}/reject 받은 사람만
created_at·responded_at   POST /invitations/{id}/cancel 보낸 주장만, pending일 때만

수락·보낼 때 알림은 team_match_request처럼 포트 없이 원시 SQL INSERT(notification 컨텍스트를 안 임포트) — user_contact가 이미 이 패턴입니다.

하지 말 것(제가 지킬 것): paik 27번(팀 소속 내 빈 자리 추천)의 후보 선정 로직은 안 건드립니다 — 이번 건 “팀에 없는 사람을 데려오는” 쪽이라 겹치지 않습니다.

  • 관련: 18·19번(같은 세션에서 발견, 이 항목의 선행 조건) · api-contract.md 933절(3-3. 팀) · 흐름 A의 applications(방향은 반대지만 상태 전이 참고용) · paik 17번(team_match_request, 상태 전이 모양의 정본) · paik 27번(빈 자리 추천 — 🔴 2026.09.16 정정: 팀 소속 내부가 아니라 팀 밖에서 찾습니다. 위 초안 절의 정정 참고) · paik 22번(팀 매칭 mock 표시 — 초대함 UI가 생기면 거기 mock도 같이 걷어야 함)
  • 담당: 박민호(제품·화면 — 초대 버튼·초대함) · 정어진(스키마·API) ✅ 백엔드 완료 (2026.09.16) · 제기: 박민호 · 기한: 확인되는 대로

21. fastapi/CLAUDE.md의 로컬 DB 안내가 실물과 다릅니다 — Docker인데 pg_ctlcluster로 적혀 있습니다 (2026-09-11 신설) ✅ 해소 (2026.09.16)

ho·jin·paik을 main에 합친 뒤 로컬에서 백엔드 테스트를 돌리려다 걸렸습니다. fastapi/CLAUDE.md엔 “WSL은 자동 기동이 아니다 — pg_ctlcluster 18 main start(root)” 라고 적혀 있는데, 실제로 이 문서 작업을 하던 WSL엔 PostgreSQL 서버 패키지 자체가 설치돼 있지 않았습니다(postgresql-client*만 있고 서버·postgresql-common은 없음, /var/lib/postgresql/ 디렉터리도 없음).

실제로 로컬 DB는 Docker 컨테이너로 떠 있었습니다 — docker ps -a에 supersub-postgres(이미지 pgvector/pgvector:pg18, 포트 5433:5432)가 있었고, 꺼져 있던 걸 docker start supersub-postgres로 켜니 .env의 DATABASE_URL(127.0.0.1:5433)과 맞물려 바로 붙었습니다. 마이그레이션 최신화 후 pytest -q → 746 passed, 0 skipped.

   
만족해야 할 성질 fastapi/CLAUDE.md(또는 이 저장소 어딘가)를 그대로 따라가면 로컬 DB가 뜬다 — 지금은 안 됩니다
확인 docker ps -a \| grep supersub-postgres · cat fastapi/.env \| grep DATABASE_URL — 포트가 pg_ctlcluster 안내와 다릅니다(5433 vs 기본 5432)
하지 말 것 제가 직접 fastapi/CLAUDE.md를 고치지 않았습니다 — 정어진 님 영역이고, 이 환경 하나만 보고 일반화하면 다른 사람 로컬(진짜 네이티브 설치일 수도 있음)에 안 맞을 수 있습니다
  • 관련: fastapi/CLAUDE.md 상단 「DB가 필요하다」 절 · fastapi/.env
  • 담당: 정어진(문서 확인·정정) · 제기: 박민호 · 기한: 확인되는 대로
  • 처리: fastapi/CLAUDE.md의 DB 기동 안내를 환경별 확인 순서(DATABASE_URL 확인 → Docker supersub-postgres 확인 → 네이티브 pg_ctlcluster)로 보강 — Docker인 환경도, 진짜 네이티브 설치인 환경도 모두 커버합니다. e8fd06a.

22. 분석한 영상 두 편을 비교해서 보고 싶습니다 (2026-09-11 신설) ⛔ 안 하기로 했습니다 (2026.09.17)

지금 「내 프로필」의 「분석 영상」 탭은 한 번에 한 편만 보여줍니다 (MyVideos.tsx) — 이전/다음으로 넘기거나 아래 목록에서 골라 한 편씩 봅니다. 사용자 요청으로, 분석한 두 영상의 결과를 나란히(또는 겹쳐) 비교할 수 있게 해 주세요.

필요한 데이터는 이미 있습니다 — 백엔드 변경은 필요 없어 보입니다. GET /videos/{id}/report가 영상별로 오버롤 등급·총점·항목별 radar(0~100 스탯)를 이미 주고 있고(CCC 32), ReportView.tsx의 ReportRadar가 한 영상의 다각형을 그리는 코드도 이미 있습니다.

   
만족해야 할 성질 분석 완료된 두 영상을 골라 오버롤 등급·총점·항목별 점수를 나란히 볼 수 있다. 어느 항목이 오르고 내렸는지 알 수 있다
확인 「분석 영상」 탭에서 두 편을 고르면 비교 화면(또는 패널)이 뜨고, 두 리포트의 radar 축 이름이 같으면 항목별 숫자 차이가 보인다
판단해 주실 것(구현하시면서 정하면 됩니다) 종목·동작(루브릭)이 달라 radar 축 이름이 서로 다른 두 영상을 골랐을 때 어떻게 보여줄지 — 비교 자체를 막을지, “비교할 항목 없음”으로 표시할지
하지 말 것 🔴 overallGrade·totalScore·radar가 null인 옛 리포트(이 필드 생기기 전 적재분)를 비교에 넣을 때 죽지 않기 — ReportView가 이미 그 경우를 건너뛰는 방식을 참고
  • 관련: www/src/components/analysis/ReportView.tsx(ReportRadar) · www/src/app/(app)/me/MyVideos.tsx · ho 28번(오버롤 등급 읽기 경로, CCC 32)
  • ⛔ 안 하기로 했습니다 (2026.09.17, 사용자 판단). 제기자가 원한 것은 paik 28번(「선수와 비교하기」) 이었고, 그것은 이미 되어 있습니다 — 요청이 잘못 적힌 것이었습니다.

    🔴 그래서 이 항목이 글자 그대로 말하는 「내 영상 두 편끼리 리포트 견주기」는 만들지 않습니다. 앞서 같은 날 이 자리에 「28번과 다른 것이다 · 미착수가 맞다」고 적고 실제로 만들다가(계산 · 화면 · 시험까지) 되돌렸습니다. 두 기능의 차이는 이렇습니다 — 다음에 또 헷갈리지 않게 남겨 둡니다:

      28번 (있음) 22번 (안 함)
    누구와 프로 선수 지난번의 나
    무엇을 자세(관절 각도 세 순간) 점수(등급·총점·항목)

    「저번보다 늘었나」를 보고 싶다는 요구가 나중에 다시 나오면 그때 새 항목으로 엽니다. 그때는 이 항목이 아니라 그 요구를 근거로 시작합니다.

  • 담당: 백성검 ⛔ 안 함 · 제기: 박민호 · 기한: 확인되는 대로

23. 폰 실제 설치 → 가입 테스트(retopia12@naver.com) — DB 반영 확인함 ✅ 확인 (2026.09.14)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

24. deployment.md의 k3s 배포 위치가 문서와 실제가 다릅니다 — 정어진 확인 부탁드립니다 ✅ 해소 (2026.09.15)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

25. 앱 로그인 세션이 재시작하면 사라집니다 — 영속 저장 필요 ✅ 해소 (2026.09.17)

QA 체크리스트(아카이브의 “QA 체크리스트 페이지를 만들었습니다” 항목) 채우던 중 flutter/lib/features/auth/data/auth_repository_api.dart를 읽다가 발견했다. 파일 자체 주석에 이미 적혀 있다.

세션은… 메모리에만 둔다 — 앱을 새로 켜면 다시 로그인해야 한다. 기기 재시작 후에도 로그인 상태를 유지하려면 토큰을 영속 저장소(shared_preferences 등)에 옮겨야 한다.

토큰·세션을 로컬에 저장하는 패키지(shared_preferences·flutter_secure_storage 등)가 pubspec.yaml에 아예 없다 — 껐다 켜면 무조건 로그아웃이다. 웹(www/)은 같은 문제를 httpOnly 쿠키로 이미 풀어 뒀다(www/src/server/session.ts).

   
만족해야 할 성질 앱을 완전히 종료했다가 다시 열어도 로그인 상태가 유지된다
확인 로그인 → 앱 강제 종료 → 재실행 → 로그인 화면 대신 홈으로 바로 들어가는지
참고 토큰을 그대로 평문 저장소(shared_preferences)에 두면 기기 탈취 시 노출된다 — flutter_secure_storage(iOS Keychain·Android Keystore) 쪽이 더 안전하다. 다만 최종 선택은 백성검님 판단
하지 말 것 토큰을 로그에 남기거나(루트 CLAUDE.md 공통 원칙) 평문 그대로 커밋되는 설정 파일에 두지 않기
  • 관련: jekyll/qa/qa-체크리스트.markdown “앱 → 로그인” 절(현재 미구현으로 표시해 둠)
  • 담당: 백성검 ✅ 붙였습니다 (2026.09.17) · 제기: 박민호 · 기한: 확인되는 대로

무엇을 했나: flutter_secure_storage(안드로이드 Keystore · iOS Keychain)에 토큰만 남깁니다 — 평문 shared_preferences 는 쓰지 않았습니다(사용자 판단). 사용자 정보는 안 남기고 켤 때마다 /me 로 다시 받습니다: 낡은 닉네임을 안 보여 주고, 토큰이 만료·폐기됐으면 그 자리에서 드러납니다.

  • 🔴 토큰을 버리는 것은 서버가 UNAUTHORIZED·INVALID_TOKEN 이라고 답했을 때뿐입니다. 네트워크가 끊긴 것으로 버리면 지하철에서 앱을 켰다는 이유로 로그아웃됩니다 — 그때는 이번 실행만 로그인 안 된 상태로 두고 토큰은 남깁니다
  • 🔴 덤으로 고친 것: 응답을 response.body 로 읽고 있었는데, http 는 charset 헤더가 없으면 latin1 로 풉니다. FastAPI 는 charset 을 안 붙이므로 한글 닉네임·오류 문구가 깨져서 들어오고 있었습니다. bodyBytes 를 UTF-8 로 풀도록 고쳤습니다(시험으로 잠금)
  • ⚠️ 위젯 시험에서는 진짜 저장소를 못 씁니다 — 플랫폼 채널 응답이 가짜 시계에서는 영영 안 옵니다(예외가 아니라 안 오는 것이라 try/catch 로 못 풉니다). tokenStoreProvider 로 빼서 시험이 갈아 끼웁니다
  • ⚠️ 플러그인이라 다시 빌드해야 합니다 — 핫 리로드로는 안 붙습니다
  • 확인: flutter analyze 0건 · flutter test 124개 통과(새 시험 7). 🔴 실기기 확인은 아직입니다 — 항목의 「로그인 → 앱 강제 종료 → 재실행 → 홈으로 바로」는 빌드해서 눈으로 봐야 합니다

26. 부록 D ERD가 실제 DB 테이블과 다릅니다 — 정어진 확인 부탁드립니다 ✅ 해소 (2026.09.15)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

27. QA 체크리스트 페이지를 만들었습니다 ✅ 완료 (2026.09.14)

  • 위치: pending-archive.markdown의 ## min 구역으로 이동됨

paik (백성검)

1. 분석한 영상을 우리 서버에 저장하는 경로 ✅ 해소 (2026.09.03)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

2. 팀 구성원의 카드 식별자가 없어 스쿼드에 등재할 수 없습니다 (2026-09-04 신설) ✅ 해소 (2026.09.04)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

3. 카드를 꾸밀 수가 없습니다 — 별명 · 사진에 규격이 없습니다 (2026-09-04 신설) ✅ 해소 (2026.09.11) — 사진 빼고 전부

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨
  • 🔴 제목의 「사진 빼고」는 이제 옛말입니다 (2026.09.18) — 사진도 저장됩니다. 바이트는 S3(cards/photos/<user_id>/…), style 에는 키만 (POST /me/card/photo-upload-url 신설 · 마이그레이션 0건 · 계약 3-5절). 제목은 다른 문서가 grep 하는 키라 안 고칩니다(아카이브 규칙).

4. 분석을 걸지 않고 올리기만 할 방법이 없습니다 (2026-09-04 신설) ✅ 해소 (2026-09-08)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

5. 클립에 공개 여부가 없어 영상 모음을 서버로 못 옮깁니다 (2026-09-04 신설) ✅ 해소 (2026.09.10)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

6. 대상 지정 박스를 올릴 자리가 없습니다 — 계약에도, S3 에도 (2026-09-08 신설) ✅ 해소 (2026.09.09) — 백엔드 · ✅ 프론트도 반영 (2026.09.10)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

7. 분석 리포트를 읽는 경로가 없습니다 (2026-09-08 신설) ✅ 해소 (2026.09.10) — 백엔드 · ✅ 프론트도 반영 (2026.09.10)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

8. 어디를 집중해서 볼지를 고를 수 있게 했는데, 보낼 데가 없습니다 (2026-09-08 신설) ✅ 해소 (2026.09.09) — 백엔드 · ✅ 프론트도 반영 (2026.09.10)

프론트 반영 끝났습니다 (2026-09-10, 백성검). lib/uploadClip.ts 가 등록할 때 focus 를 함께 싣습니다(CCC 29).

  • 🔴 criteria[].id 를 보냅니다 — 팔로스루 같은 한글 표시 이름이 아닙니다. 화면이 고르는 목록 자체가 lib/rubricFocus.ts 의 id 라 그대로 갑니다.
  • 🔴 빈 목록은 아예 안 보냅니다 — 「전체적으로」가 기본이자 가장 흔한 경우라 실패로 만들지 않습니다(생략과 같은 뜻).

| 확인 | grep -n "focus" www/src/lib/uploadClip.ts → 걸립니다. 시험 uploadClip.test.ts 의 「집중해서 볼 항목」 둘 | |—|—|

POST /videos 본문에 focus 를 넣었습니다 (정어진, 커밋 a46d03e · 마이그레이션 9fc8835184c9). paik 6번(대상 박스)과 같은 축: POST /videos → analysis_job.focus(JSON) → claim 응답 → 워커 --focus.

  • focus: 루브릭 criteria[].id 리스트. 🔴 빈 목록·생략 = 「전체적으로」, 실패 아님. 서버가 공백·중복 정리(항목 40자·목록 24개 상한). 값 실재 여부는 서버가 못 봅니다(루브릭은 agent/).
  • ✅ 정상호 조각도 이미 돼 있습니다 — worker.py 의 analyze_command 가 전부터 job.get("focus") 를 읽어 --focus a,b,c 로 넘깁니다(미리 배선해 두신 것). subject_box 와 달리 agent 쪽 follow-on 이 없습니다.
  • A/B/C 판단(focus 가 채점을 바꾸나)은 그대로 정상호 몫 — 백엔드는 값만 나릅니다. A(근거 문장만)든 B(가중치 재정규화)든 흐르는 경로는 같습니다.
  • 확인: grep -n 'focus' www/src/lib/uploadClip.ts 걸리면 프론트가 붙인 것. pytest -q 693 passed. 반영 안내 client-contract-changes.md 29번.

⚠️ www 쪽(rubricFocus.ts → uploadClip.ts 에 실어 보내기)은 백성검 몫으로 남습니다.

분석 화면에서 올린 사람이 채점 항목을 고를 수 있게 했습니다(사용자 요청). 목록은 지어낸 것이 아니라 agent/rubrics/*.yaml 의 criteria[].name 을 그대로 옮긴 것입니다 — 예를 들어 농구 점프슛이면 슛하는 팔 신전 · 가이드 핸드 · 팔로스루 · 상체 정렬 · 하체 신전 다섯입니다. 아무것도 안 고르면 「전체적으로」 입니다.

그런데 그 값을 아무 데도 못 보냅니다. 두 군데가 다 비어 있습니다.

구간 상태 소유
화면에서 고르기 ✅ 했습니다 (www/src/lib/rubricFocus.ts) 백성검
POST /videos 본문 ❌ 자리가 없습니다 (동작조차 없습니다 — jin 17번) 정어진
에이전트가 받기 ❌ analyze_s3.py 에 “이 항목만 봐 달라”는 옵션이 없습니다 정상호

정상호 님께 — 먼저 여쭙습니다

이게 에이전트에서 뜻이 있는 값입니까? 루브릭은 criteria 를 전부 채점하고 가중합을 내는 구조라, 「집중해서 볼 항목」이 무엇을 바꿔야 하는지가 갈립니다.

안 뜻
A 채점은 그대로 하고 근거 문장을 그 항목 위주로 쓴다 (화면 표시 문제)
B 고른 항목만 채점하고 가중치를 재정규화한다 (도구 없는 항목을 빼는 것과 같은 방식)
C 뜻이 없다 — 화면에서 걷어내는 게 맞다

A 가 맞다고 보지만 판단은 그쪽 몫입니다. B 는 같은 영상의 점수가 고른 것에 따라 달라져 「같은 잣대로 잰다」가 흔들립니다.

정어진 님께 — jin 17번과 같은 자리 문제입니다

동작(motion)을 실을 자리를 정하실 때 이것도 같이 봐 주시면 좋겠습니다. 둘 다 「올린 사람이 무엇을 원하는지」라 성질이 같고, S3 로도 가야 하는 것까지 paik 6번과 같습니다.

   
만족해야 할 성질 고른 항목 id 목록이 워커가 읽는 자리까지 도달할 것. 빈 목록은 「전체」라는 뜻입니다
웹이 줄 수 있는 값 루브릭의 criteria[].id 그대로 (예: ["follow_through","guide_hand"])
확인 grep -n 'focus' www/src/lib/uploadClip.ts → 걸리면 화면 쪽은 붙인 것입니다
하지 말 것 🔴 빈 목록을 실패로 만들지 마세요 — 「전체적으로」가 기본이자 가장 흔한 경우입니다 · 자유 문자열로 받지 마세요(follow_through 와 팔로스루 가 섞입니다)

🔴 draft 루브릭을 승격시키면 화면을 먼저 고쳐야 합니다

지금 화면은 종목당 status: active 인 루브릭 하나만 싣습니다(루브릭 파일이 스스로 정한 규칙입니다 — “active만 오른다”). 야구 타격(ho 3번)이 열리면 baseball 이 둘이 되어 화면이 동작부터 물어야 합니다 — jin 17번이 그때 막힌다고 적어 둔 그 지점입니다.

그래서 사본이 조용히 갈리지 않도록 루브릭 파일을 실제로 읽어 대조하는 검사를 걸어 두었습니다(www/src/lib/rubricFocus.test.ts). 항목이 늘거나 draft 가 승격되면 www 시험이 먼저 빨개집니다.

✅ 정상호 회신 — A 안입니다. 받는 자리도 냈습니다 (2026.09.09)

A 가 맞다는 판단에 동의합니다. 그리고 B 를 안 되는 쪽으로 봅니다 — 「같은 잣대로 잰다가 흔들린다」고 적어 주신 것이 정확히 이유입니다. 덧붙이면, 스카우팅은 선수끼리 비교하는 것이 전부라 점수가 올린 사람의 선택에 따라 달라지면 그 점수는 카드에 못 싣습니다.

이미 있는 재정규화와 헷갈리기 쉬워서 적어 둡니다. 도구가 안 잡히면 그 항목을 빼고 가중치를 다시 맞추는 길이 있는데(applicable_criteria), 그건 촬영 조건이 강제한 것이고 「촬영 조건으로 선수를 감점하지 않는다」가 이유입니다. 이쪽은 사용자가 고른 것이라 성질이 반대입니다.

C 도 아닙니다. 사용자가 원한 것은 진짜입니다 — 다만 그것이 바꿔야 하는 것은 채점이 아니라 보여주는 방식입니다.

에이전트에 받는 자리를 냈습니다.

analyze_s3.py --focus follow_through,guide_hand
→ 리포트에  "focus": {"requested": [...], "applied": [...], "unknown": [...]}
  • 채점 경로는 한 비트도 안 건드렸습니다. aggregate 도 applicable_criteria 도 focus 를 모릅니다 — 그리고 모르는 채로 남는지를 검사가 지킵니다(test_focus_does_not_change_the_score). 누가 나중에 채점에 끌어들이면 그 검사가 빨개집니다
  • 모르는 id 를 조용히 버리지 않습니다. 화면이 낡은 id 를 보내거나 종목이 어긋나면 unknown 에 남고 로그에도 찍힙니다. 🔴 그렇다고 분석을 실패시키지도 않습니다 — 강조 힌트 하나 때문에 리포트가 통째로 없어지는 것이 더 나쁩니다
  • 빈 값은 「전체적으로」입니다. 실패가 아닙니다 — 말씀하신 「하지 말 것」 그대로이고, 백엔드에 칸이 없는 지금은 항상 이 갈래입니다

워커도 미리 읽습니다. job["focus"] 가 있으면 그대로 넘깁니다 — 정어진 님이 칸을 여시는 날 제 쪽 배포 없이 흘러갑니다.

안 한 것 — 근거 문장을 고른 항목 위주로 쓰는 것

A 안을 더 밀면 모델이 고른 항목에 더 자세히 쓰게 할 수 있습니다. 지금은 안 했습니다. 「더 나은 설명」은 정답이 없어서 좋아졌는지 판정할 방법이 없고, 이 폴더는 그런 변경을 검증 없이 넣지 않습니다. 화면에서 강조·정렬만으로 부족하다는 것이 확인되면 그때 별도 회차로 사전 등록을 걸고 하겠습니다.

🔴 화면 라벨이 이미 낡았을 수 있습니다. ho 29번에서 criteria[].name 을 쉬운 말로 바꿨습니다 — 농구 점프슛은 이제 「슛하는 팔 뻗기 · 공 받치는 손 · 던진 뒤 손목 마무리 · 상체 곧게 세우기 · 다리로 밀어 올리기」입니다. id 는 안 바꿨으니 보내는 값은 그대로 맞습니다. 걸어 두신 rubricFocus.test.ts 가 이미 빨간지 봐 주세요 — 그 항목이 그 얘기입니다.

   
확인 cd agent && uv run python scripts/analyze_s3.py --help \| grep -A2 focus · uv run pytest tests/test_worker.py -q — 49건, 그중 5건이 이 경로
하지 말 것 🔴 고른 항목만 채점하게 바꾸지 마세요(B 안) — 점수가 비교 불가가 됩니다 · unknown 을 실패로 올리지 마세요
  • 관련: jin 17번(동작 코드) · paik 6번(대상 박스) · ho 29번(항목 이름을 쉬운 말로) · agent/rubrics/*.yaml
  • 담당: 정상호(뜻 판단) ✅ A 안 · 받는 자리까지 냈습니다 (2026.09.09) → 정어진(POST /videos 본문에 실을 자리 — jin 17번과 같은 자리) · 제기: 백성검 · 기한: 스프린트 3

9. 스쿼드 판의 배치를 서버에 둘 자리가 없습니다 (2026-09-08 신설) ✅ 해소 (2026.09.09) — 백엔드 · ✅ 프론트도 반영 (2026.09.10)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

10. 남의 대표 영상을 읽을 경로가 없습니다 (2026-09-08 신설) ✅ 해소 (2026.09.09) — 백엔드 · ✅ 프론트도 반영 (2026.09.10)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

11. 분석은 끝나는데 리포트가 어디 있는지 아무도 모릅니다 (2026-09-08 신설) ✅ 해소 (2026.09.09)

양쪽 다 붙었습니다 — meet 는 다음 main 통합입니다.

  • 정어진(받는 칸): FinishJobSchema.report_key(선택, S3 키, 최대 1024) → FinishJobCommand → analysis_job.report_key 컬럼(마이그레이션 9d4e88f5b6c2). 🔴 succeeded 가 아니면 인터랙터가 버립니다(실패한 작업이 없는 리포트를 가리키지 않게). 계약 3-8·worker-interface.md 4절 갱신. 커밋 5cc83b2 (브랜치 jin).
  • 정상호(싣기): 워커가 --result-json 자리 파일에서 report_uri 를 읽어 s3://<버킷>/ 를 떼고 PATCH 에 report_key 로 싣습니다. origin/ho 30ea51c (jin 23 회신과 같은 커밋).
  • 확인: grep 'report_key' agent/scripts/worker.py fastapi/app/analysis/adapter/inbound/api/schemas/job_schema.py — 병합 후 양쪽에서 걸립니다. report_slug 규칙은 백엔드에 복사 안 함(워커가 값으로 전달).
  • paik 7(리포트 읽는 경로)이 이제 열렸습니다 — 읽을 대상이 정해졌습니다.

앞서 paik 7번에서 「읽는 경로만 내주시면 됩니다」라고 적은 것을 정정합니다. 오늘 코드를 따라가 보니 그 앞에 한 칸이 더 비어 있었습니다. 읽는 경로를 내는 것만으로는 백엔드가 무엇을 읽어야 할지 알 수 없습니다.

에이전트는 이미 진짜 리포트를 쓰고 있습니다. analyze_s3.py 가 S3 reports/<위폴더>/<파일이름>/<타임스탬프>.json 에 올리고, 그 안에는 항목별 등급 · 근거 문장 · 측정값 · 미리보기 · 누구를 분석했는지까지 들어 있습니다. 화면이 그리려는 것은 거의 다 거기 있습니다.

그런데 완료 보고에 그 자리가 안 실립니다.

PATCH /internal/analysis-jobs/{job_id}
{"status": "succeeded"}            ← 이게 전부입니다 (worker.py 의 report())

백엔드가 그 파일을 계산으로 찾을 수 없습니다. 자리를 정하는 규칙 (report_slug)이 에이전트 안에만 있고, 파일 이름에 분석을 끝낸 시각이 붙어서 같은 영상을 두 번 돌리면 파일이 둘이 됩니다 — 어느 것이 이번 결과인지 바깥에서는 정해지지 않습니다.

   
만족해야 할 성질 완료 보고에 그 작업이 만든 리포트 하나를 가리키는 값이 함께 실릴 것. 이름 · 형태는 자유입니다(report_key 든 report_uri 든)
무엇을 하면 되나 정상호 — 워커가 PATCH 본문에 그 값을 넣습니다 · 정어진 — FinishJobSchema 에 받는 칸을 하나 열고 작업에 남깁니다
확인 grep -n 'report_key\|report_uri' agent/scripts/worker.py fastapi/app/analysis/adapter/inbound/api/schemas/job_schema.py 가 양쪽에서 걸리면 된 것입니다
하지 말 것 🔴 report_slug 규칙을 백엔드에 복사해 넣지 마세요. 두 곳이 조용히 갈립니다 — 아는 쪽이 말해 주는 것이 맞습니다 · 실패(failed)한 작업에는 안 실어도 됩니다

이 값이 생기면 paik 7번(읽는 경로)이 비로소 만들 수 있는 것이 됩니다. 계약 3-1 절의 POST /analyses(DB 적재)는 이 화면에 필요 없습니다 — 그것은 지표 검색 · 카드용이라 metric_definition 합의를 기다려도 됩니다.

✅ 정상호 조각 — 실어 보냅니다 (2026.09.09)

완료 보고 본문에 report_key 를 넣었습니다. 버킷 상대 키라 claim 이 주는 storage_key 와 같은 모양입니다.

PATCH /internal/analysis-jobs/{job_id}
{"status": "succeeded", "report_key": "reports/<user_id>/<video_id>/report.json"}

🔴 규칙을 복사하지 않았습니다 — 말씀하신 그대로입니다. 자리를 아는 것은 analyze_s3.py 뿐이라, 그쪽이 파일에 적고 워커는 읽기만 합니다 (--result-json). 워커가 자리를 다시 계산하지 않으므로 두 곳이 갈릴 수 없습니다.

stdout 을 긁는 방법도 있었지만 안 썼습니다. 로그 문구(저장: …)가 바뀌면 조용히 깨지고, 그 사실이 아무 데도 안 남습니다.

하나 알려 드릴 것 — 항목이 쓰인 뒤에 자리가 바뀌었습니다. 「파일 이름에 분석을 끝낸 시각이 붙어서 같은 영상을 두 번 돌리면 파일이 둘이 된다」고 적어 주셨는데, jin 24번을 하면서 타임스탬프 없는 계약 자리 (reports/<user_id>/<video_id>/report.json)로 옮겼습니다. 재분석은 앞의 것을 덮고, 언제 낸 것인지는 리포트 안의 analyzed_at 이 싣습니다. 같은 영상의 최신 결과가 하나라는 뜻입니다.

그래도 싣는 것은 그대로 해야 맞습니다 — 계약 자리는 --video-id 를 받았을 때만이고(옛 백엔드는 안 줍니다), 무엇보다 규칙을 바깥에서 계산하게 두면 지금 말씀하신 문제가 그대로 돌아옵니다.

안 싣는 경우 셋 — 셋 다 보고 자체는 정상으로 갑니다(작업이 running 으로 남는 것이 더 나쁩니다). 안 실었다는 사실은 저널에 남습니다.

   
실패한 작업 가리킬 리포트가 없습니다
리포트가 다른 버킷 버킷 상대 키로는 못 가리킵니다 (SUPERSUB_REPORTS_URI 를 딴 데로 돌린 경우)
자리 파일이 없거나 깨짐 화면이 리포트를 못 찾을 뿐, 분석은 성공한 것입니다

받는 칸이 없어도 지금 배포해도 됩니다 — 확인했습니다. 처음에 「거부 설정이면 422 로 막힌다」고 물으려다 직접 봤습니다. FinishJobSchema 에 model_config 가 없어서 Pydantic v2 기본값 extra="ignore" 이고, 모르는 필드는 조용히 버려집니다. 그래서 순서에 제약이 없습니다 — 칸을 여시면 그날부터 값이 들어옵니다.

🔴 다만 나중에 extra="forbid" 로 조이시면 그때 막힙니다. 완료 보고가 422 로 거부되면 작업이 전부 running 에 쌓이고, 워커는 그것을 「재시도 무의미」로 보고 넘어갑니다(404·409·422 는 같은 답이 올 뿐이라 재시도하지 않습니다). 조이실 거면 칸을 먼저 열어 주세요.

   
확인 grep -n 'report_key' agent/scripts/worker.py (싣는 자리) · cd agent && uv run pytest tests/test_worker.py -q — 44건 통과, 그중 10건이 이 경로
하지 말 것 🔴 report_key 를 URL 로 바꿔 보내 달라고 하지 마세요 — 사전 서명 URL 은 만료되는 값이라 작업 행에 남기면 낡습니다. 키를 두고 읽을 때 서명하는 편이 맞습니다
  • 관련: paik 7번 · agent/scripts/worker.py · agent/scripts/analyze_s3.py · 계약 3-8절 · min 9번(1~3번을 스프린트 3으로 묶은 자리) · jin 24번(계약 자리로 옮긴 회차)
  • 담당: 정상호(싣기) ✅ 실었습니다 (2026.09.09) → 정어진(FinishJobSchema 에 받는 칸 + 작업에 남기기) · 제기: 백성검 · 기한: 스프린트 3

12. 올린 영상이 배포에서 안 보입니다 — 재생용 주소가 없어서입니다 (2026-09-08 신설) ✅ 해소 (2026.09.08)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

13. 올린 영상을 지울 경로가 없습니다 — S3 의 영상도 리포트도 안 지워집니다 (2026-09-08 신설) ✅ 해소 (2026.09.09)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

14. coach 에 종목 컬럼이 없습니다 — market/coaches 를 실제 데이터로 못 채웁니다 (2026-09-08 신설) ✅ 해소 (2026.09.16)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

15. 공개 클립 목록에 화면 비율이 없어 세로 영상이 잘못 그려집니다 (2026-09-10 신설) ✅ 해소 (2026.09.16)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

16. 공개 클립에 올린 사람이 없어 영상 모음이 남의 것을 내 이름으로 그립니다 (2026-09-10 신설) ✅ 해소 (2026.09.15)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

17. 팀 ↔ 팀 경기 신청 · 알림 · 수락이 계약에 없습니다 (2026-09-10 신설) ✅ 해소 (2026.09.15)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

18. 경기 조건(지역 · 요일 · 시각 · 포지션)을 둘 자리가 없습니다 (2026-09-10 신설) ✅ 해소 (2026.09.15)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

19. 지역 목록도 서버가 주셔야 합니다 (2026-09-10 신설) ✅ 해소 (2026.09.15)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

20. 「비슷한 팀」의 기준을 정해 주세요 — RAG (2026-09-10 신설) ✅ 해소 (2026.09.15)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

21. 팀 구성원의 경기 조건을 팀장이 볼 수 있어야 합니다 (2026-09-10 신설) ✅ 해소 (2026.09.15)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

22. 팀 매칭 화면이 다 mock 입니다 — 무엇이 가짜인지 한자리에 (2026-09-10 신설)

위 다섯(17~21)이 다 열릴 때까지 화면이 무엇을 지어내고 있는지 한 곳에 적어 둡니다. 이 항목은 요청이 아니라 표시입니다 — 닫는 것은 17~21이 다 닫힐 때입니다.

무엇 지금 어디
비슷한 팀 목록 붙박이 7팀(3:3·5:5·7:7 두셋씩) lib/teamMatch.ts
「왜 비슷한가」 조건과 대조해서 만듦 — 지어내지는 않음 whyMatches()
신청 · 수락 1.4초 뒤 무조건 수락 applyToTeam()
잡힌 경기 브라우저에만 lib/bookedMatches.ts
경기 조건 브라우저에만 lib/matchPrefs.ts
지역 목록 붙박이 60여 곳 lib/regions.ts

🔴 화면은 이것들을 숨기지 않습니다 — 명단 아래 · 대기 팝업 · 조건 판 · /me 의 「내 경기」 넷에 「데모입니다」를 적어 두었습니다. 진짜가 붙을 때 그 문구도 같이 걷어야 합니다.

  • 담당: 백성검(문구 걷기) · 제기: 백성검 · 기한: 17~21 이 닫힌 뒤

23. 「받은 호칭」의 기준을 정해 주세요 — grade 와 title 의 관계 (2026-09-10 신설) ✅ 해소 (2026.09.11)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

24. 리포트 경로가 배포 서버에 안 올라가 있습니다 — 이미지는 17시간 전에 나왔습니다 (2026-09-11 신설) ✅ 해소 (2026.09.11)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

25. 남의 등급을 읽을 경로가 없습니다 — 추천 판이 등급으로 좁혀야 합니다 (2026-09-11 신설) ✅ 해소 (2026.09.16)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

26. 표시 등급의 눈금과 계산 규칙을 정해 주세요 — S~F 여섯 (2026-09-11 신설)

지금 서버가 내는 overall_grade 는 A·B·C·D 넷입니다 (analysis_report.overall_grade, String(1)). 화면은 여섯이 필요합니다.

사용자가 정한 규칙 (2026-09-11) — 이것이 정본입니다

분석 등급(A~D)  ×  신뢰점수(경기 뒤 서로 남긴 리뷰의 집계)
   → D 인데 리뷰가 없거나 신뢰점수가 낮으면      F 로 내려간다
   → A 인데 신뢰점수도 좋으면                   S 로 올라간다

리뷰는 그 사람의 대표 영상에 달립니다. 그래서 「이 사람의 등급」이 대표 영상 하나에서 나옵니다 — 계약이 “overall_grade 는 영상 하나의 값, 선수 단위로 합친 오버롤은 없다” 고 적어 둔 자리에 이 규칙이 들어갑니다.

   
만족해야 할 성질 표시 등급이 S·A·B·C·D·F 여섯일 것. 「리뷰 없음」이 어느 칸으로 가는지가 규칙에 있을 것
정해야 할 것 ⑴ 신뢰점수의 눈금(무엇이 「좋다」·「낮다」인가) ⑵ 경계값 ⑶ 리뷰가 몇 건부터 세는가 — 한 건으로 S 가 갈리면 안 됩니다
확인 리뷰가 하나도 없는 A 등급 선수가 S 가 아닐 것, D 등급 선수가 F 로 내려갈 것
하지 말 것 🔴 화면이 경계를 긋지 않기 — 그으면 그게 곧 지어내는 것입니다(ho 23번과 같은 판단) · 🔴 신뢰도를 저장하지 않기(부록 D.4 — 집계로 나오는 파생값입니다)

⚠️ 계약이 “평가 조회 — 내가 받은 평가를 보는 경로. 신뢰도 표시 화면이 정해지면 낸다” 로 미뤄 둔 자리입니다. 그 화면이 지금 생겼습니다 — 분석 리포트가 같은 길을 밟았습니다(화면이 먼저, 규격이 그다음).

  • 관련: 25번 · 27번 · 계약 3-9(평가·신뢰) · 부록 D.4 · www/src/lib/playerGrade.ts
  • 담당: 정상호(눈금·경계) ✅ 회신함 · 정어진(내주는 경로) · 제기: 백성검 · 기한: 스프린트 3

✅ 눈금·경계 회신 (2026.09.14, 정상호) — 눈금이 서는 축은 하나뿐이고, 경계값은 고르지 않았습니다

물으신 셋(⑴ 눈금 ⑵ 경계값 ⑶ 최소 건수)에 답합니다. 🔴 ⑵와 ⑶을 따로 고르지 않았습니다 — 하나를 정하면 나머지가 따라 나오게 세웠습니다. 숫자를 둘 고르면 둘 다 근거를 대야 하고, 지금 그 근거가 없습니다.

🔴 먼저 — 사다리의 아랫단이 지금 대외 노출 금지 상태입니다

S~F 는 분석 등급 A~D 위에 얹힙니다. 그런데 그 A~D 를 내는 루브릭이 머리말에 이렇게 적어 두었습니다:

“검수 완료 전까지 이 루브릭으로 산출한 점수를 대외에 노출하지 않는다” (agent/rubrics/football_instep_shot.yaml, review_required: true)

26번은 정의상 대외 노출입니다 — 25번이 여는 것이 「남의 등급을 읽는 경로」 입니다. 제가 paik 20번에 「지금 등급은 provisional 이라 대외 노출 자체가 금지」라고 적은 것과 같은 자리입니다.

🔴 그리고 오늘 그 근거가 더 세졌습니다 (같은 날 닫은 ho 34번). 임계값에 문헌 출처를 붙여 봤더니 6개 항목 중 3개가 문헌과 어긋납니다 — 차는 다리는 엘리트가 우리 0등급에 떨어지고, 디딤발 위치는 엘리트가 우리 1등급이며, 팔로스루는 우리 문턱이 문헌의 1/3 입니다. threshold_validity 는 11/11 unverified 그대로입니다.

막자는 뜻이 아닙니다. 자물쇠는 이미 달려 있고 배선도 끝나 있습니다 — provisional 이 에이전트(scoring.py ← rubric.review_required) → 리포트 봉투 → analysis_report.provisional → www/src/server/backend/types.ts 까지 이미 이어져 있습니다.

   
그래서 부탁 25번이 등급을 내줄 때 provisional 을 같이 내려 주세요(정어진). 화면은 그 값이 true 인 동안 등급 옆에 「검수 전」을 답니다(백성검)
🔴 하지 말 것 등급 문자만 떼어 내보내지 마세요. 떼는 순간 받는 쪽에서 잠정인지 알 방법이 없어지고, 남의 화면에 박힌 D 는 회수가 안 됩니다
⑴ 눈금 — 재매칭 의사 한 축입니다. 나머지는 눈금이 안 섭니다

선택지 9개를 놓고 보면, 「낮다」를 정의할 수 있는 축이 하나뿐입니다.

카테고리 선택지 눈금이 서는가
재매칭 repeat_yes ↔ caution_would_not_repeat ✅ 둘이 같은 축의 반대편입니다. 0.5 라는 뜻 있는 원점이 생깁니다
매너 3 · 실력 3 전부 긍정형 단독 🔴 안 섭니다. 반대편이 없어 올라가기만 합니다
주의 1 caution_position_mismatch 🔴 신뢰가 아니라 적합도입니다 — 아래

🔴 매너·실력을 개수로 세면 안 됩니다. 그건 평가받은 사람이 아니라 평가한 사람이 체크박스를 몇 개 눌렀는지를 재는 양이 됩니다. 후하게 다 누르는 사람과 하나만 누르는 사람이 있는데, 그 차이가 그대로 남의 등급이 됩니다. 저희가 다른 회차에서 계속 덴 형태라(「이 양이 내가 묻는 것인가」) 여기서는 피했습니다.

🔴 caution_position_mismatch 는 신뢰 축에서 뺍니다. 「매너 좋았다 + 포지션이 안 맞았다」는 좋은 사람을 잘못된 자리에 넣은 것이지 그 사람을 낮출 근거가 아닙니다. 마이그레이션 자신이 두 caution 을 “사실 진술이 아니라 선호 표현” 이라고 적어 두었는데, 그 둘도 서로 다릅니다. 이 신호는 버리지 않고 27번(추천) 으로 넘깁니다 — 거기서는 가장 쓸모 있는 신호입니다.

🔴 정정 — 리뷰는 영상이 아니라 경기에 달립니다

항목이 “리뷰는 그 사람의 대표 영상에 달립니다” 라고 적으셨는데, 계약 3-9 는 POST /matches/{match_id}/reviews + reviewee_id 입니다 — 영상과 무관합니다.

축 단위 모이는 속도
분석 등급 A~D 대표 영상 하나 영상을 바꾸면 통째로 바뀝니다
신뢰(재매칭 의사) 그 사람이 받은 모든 경기 평가 경기를 뛸수록 쌓입니다

이 편이 규칙에 유리합니다 — 분모가 경기 수를 따라 커져서 아래 최소 건수에 실제로 닿습니다. 영상 단위였으면 대표 영상을 바꿀 때마다 분모가 0으로 돌아갑니다.

⑵ 경계값 — 0.5(과반) 하나만 씁니다
신뢰 우세 = (재매칭 긍정 건수) / (재매칭 의사를 표한 평가 건수)
            의 95% 신뢰구간 하한 > 0.5

🔴 0.5 를 고른 것이 아니라, 비율 축에서 임의적이지 않은 유일한 점이라 쓴 것입니다. 0.7 이나 0.8 을 쓰면 「왜 0.7 이냐」에 답할 자료가 없고, 그건 ho 34번이 기각한 「분포에 맞춰 긋기」와 같은 형태가 됩니다.

비율이 아니라 신뢰구간 하한으로 재는 이유: 1건 중 1건은 100%지만 아무것도 말해 주지 않습니다. 하한을 쓰면 표본이 작을수록 저절로 불리해져서 「한 건으로 S 가 갈리면 안 된다」가 규칙을 따로 안 써도 만족됩니다(1건 전원 긍정의 하한은 0.207 입니다).

⑶ 최소 건수 — 안 정했습니다. ⑵에서 따라 나옵니다
받은 평가 긍정 하한 신뢰 우세
1 1 0.207 ✗
3 3 0.439 ✗
4 4 0.510 ✅ 전원 긍정일 때 최소
7 6 0.487 ✗
8 7 0.529 ✅ 부정 1건이면 8건 필요
11 9 0.523 ✅ 부정 2건이면 11건

전원 긍정이어도 4건, 하나라도 부정이 섞이면 8건입니다. 숫자를 고르지 않았는데 「한 건으로 갈리지 않게」가 나옵니다.

규칙 전문 — S 와 F 는 같은 문턱의 양쪽입니다
표시등급(분석등급 A~D, 신뢰우세) =
    A 이고 신뢰 우세     → S
    D 이고 신뢰 우세 아님 → F
    그 밖                → 분석 등급 그대로

🔴 F 를 「리뷰가 없으니 나쁘다」로 읽지 않으려고 이렇게 뒤집었습니다. 사용자 규칙이 “D 인데 리뷰가 없거나 신뢰점수가 낮으면 F” 인데, 「없다」를 「낮다」와 같이 치면 안 잰 것이 나쁨의 근거가 됩니다 — ho 21번이 고친 결함(못 잰 것을 0.0 으로 지어내기)이 등급에서 되살아나는 형태이고, 백성검 님 averageGrade 주석이 이미 같은 판단을 적어 두셨습니다.

대신 S 와 대칭으로 읽으면 사용자 규칙과 결과가 같으면서 뜻이 성립합니다: S 는 「A 인데 올려 줄 근거가 있다」, F 는 「D 인데 구해 줄 근거가 없다」. 둘 다 문턱은 하나이고, 증거 없음은 어느 쪽으로도 밀지 않습니다 — D 가 F 가 되는 것은 강등이 아니라 D 에 머무는 것입니다.

항목의 「확인」 대조
   
리뷰가 하나도 없는 A 가 S 가 아닐 것 ✅ 분모 0 → 하한 0 → 우세 아님 → A 그대로
D 가 F 로 내려갈 것 ✅ 리뷰 없음 → 우세 아님 → F. 🔴 다만 4건 전원 긍정을 받으면 D 로 남습니다(구해 줄 근거가 생긴 것)
한 건으로 S 가 갈리지 않을 것 ✅ 1건 하한 0.207
「하지 말 것」 둘 다 지켜집니다
   
🔴 화면이 경계를 긋지 않기 경계는 서버에서 넘습니다. 25번 경로가 S~F 문자 하나(+ provisional)를 내주고, 화면은 받기만 합니다. playerGrade.ts 의 GRADES·averageGrade 는 그대로 쓰시면 되고 경계 계산만 안 하시면 됩니다
🔴 신뢰도를 저장하지 않기(D.4) 저장할 것이 없습니다. 위 식은 review_selection 위의 읽을 때 집계이고 새 컬럼이 0개입니다. 원자료(평가자·시점·선택)만으로 언제든 다시 계산됩니다
🔴 제가 안 정한 것 — 정할 근거가 없어서입니다
   
오래된 평가의 감쇠 반년 전 평가를 덜 세는 것이 맞아 보이지만 감쇠율을 고를 자료가 없습니다. 고르면 그게 임의값입니다. 지금은 전체를 셉니다 — 데이터가 쌓인 뒤 별도로
신뢰구간 95% 관례를 썼습니다. 바꾸면 위 표의 4·8·11 이 같이 움직입니다 — 표를 손으로 고치지 마시고 식에서 다시 뽑으세요
매너·실력 6개를 버리지는 않았습니다 등급에 안 넣을 뿐입니다. 「받은 평가」를 그대로 보여 주는 화면(계약이 미뤄 둔 「평가 조회」)에는 개수가 아니라 문구 그대로 나가는 것이 맞습니다
  • 근거: 계약 3-9 · fastapi/alembic/versions/20260903_review_trust_tables.py(선택지 9개와 그 설계 이유) · 부록 D.4 · ho 34번(임계값 출처) · ho 21번(「없다」와 「낮다」)
  • 남은 담당: 정어진(25번 경로에 등급 + provisional + 위 집계) ✅ 완료 (2026.09.16, 아래) · 백성검(「검수 전」 표시, 경계 계산 제거) ✅ 완료 (2026.09.16, 맨 아래 「화면 몫 완료」)

✅ 정어진 몫 완료 (2026.09.16) — 규칙 그대로 구현, 경계는 서버가 긋는다

위 회신의 식(Wilson 95% 신뢰구간 하한, z=1.96)을 app/analysis/domain/rules/grade_rules.py에 그대로 옮겼습니다. 손으로 만든 경계값은 없습니다 — is_trust_dominant가 매번 그 식으로 다시 계산합니다. 25번 경로(GET /cards/{slug}/grade)가 분석 등급 + 신뢰 집계를 합쳐 S~F와 provisional을 함께 냅니다. review·review_selection은 저장 없이 매 요청 원시 쿼리로 집계합니다(부록 D.4 지켜짐 — 신뢰도 컬럼 0개).

확인: .venv/bin/pytest -q tests/analysis/domain/test_grade_rules.py — 위 표의 0.207·0.439·0.510·0.487·0.529·0.523 여섯 값과 4·8·11건 경계를 그대로 회귀시킨 유닛 테스트 21건 전부 통과. 「하지 말 것」 둘 다 코드로 확인됨(경계 계산은 grade_rules.py에만 있고 라우터·리포지토리엔 없음 · 신뢰도 컬럼 신설 없음 — alembic check “No new upgrade operations detected”).

남은 것은 백성검 쪽 화면(provisional=true일 때 “검수 전” 표시, 화면에서 경계를 다시 긋는 코드가 있다면 제거)뿐입니다 — 43번에 적어 두었습니다.

✅ 화면 몫 완료 (2026.09.16, 백성검) — 25·26·27번의 프론트가 한 번에 붙었다

세 항목이 한 화면(SquadSuggest)에서 만나서 같이 처리했습니다.

무엇 어떻게
붙박이 명단 SUGGESTIONS 를 걷고 GET /teams/{id}/squad/candidates 로 바꿨습니다
지어낸 등급 gradeOfPlayer() 를 함수째 지웠습니다. 등급은 서버가 준 것만 그립니다
🔴 「검수 전」 provisional 이 true 면 등급 옆에 답니다 — 강조색을 안 써서 등급 칩과 한 덩어리로 안 읽힙니다
경계 계산 제거 첫 거르개 값이 averageGrade(seated) 였는데 ANY_GRADE 로 바꿨습니다. 서버가 팀 평균과 가까운 순으로 정렬해 주므로 화면이 다시 계산하면 두 곳이 갈립니다
등급 거르개 화면 필터링을 걷고 쿼리로 서버에 넘깁니다. 「상관없음」은 grade= 빈 값이 아니라 아예 안 싣습니다(빈 값은 없는 등급이라 422)
등급을 모르는 후보 등급 칸을 안 그립니다 — F 로 치면 「없다」가 「낮다」가 됩니다

🔴 playerGrade.ts 의 머리말을 고쳤습니다 — 「아직 아무것도 안 붙었다 · 화면에 보이는 등급은 전부 mock 이다」라고 적혀 있었는데 이제 사실이 아닙니다. gradeValue·averageGrade 는 남겨 두되 등급을 정하는 데 쓰지 않는다고 적었습니다(표시용 셈입니다).

  • 아직 안 한 것: 말로 적은 특징(title·notes)과 대표 장면(clip)은 여전히 화면의 mock 입니다 — 계약 44번이 「지금은 mock 클립·문구를 그대로 쓰고 이름·등급만」으로 범위를 그어서 그대로 뒀습니다. 후보 응답의 card_public_slug 로 GET /cards/{slug}/featured-video 를 부르면 남의 대표 영상도 붙지만, 그것도 그 범위 밖이라 안 했습니다
  • 확인: npx vitest run src/components/SquadPanel.test.tsx — 「서버 값이다」 describe 다섯(검수 전 배지 · 모르는 등급 · 거르개가 서버로 감 포함). 전체 712개 통과 · tsc 0건 · eslint 0건

27. AI 추천 목록 자체가 계약에 없습니다 — 「비슷한 선수」의 기준 (2026-09-11 신설)

스쿼드 빈 자리를 누르면 나오는 AI 추천 판이 아직 통째로 붙박이입니다 (SquadSuggest.tsx 의 SUGGESTIONS). 추천 엔드포인트가 계약에 없습니다.

사용자가 정한 기준 (2026-09-11)

팀에 넷이 찼고 하나를 더 구할 때, 그 넷의 등급 평균을 내서 비슷한 등급의 사람만 보여 준다. 대신 위에 설정을 둬서 S·A·B·C·D·F 와 「등급 상관없음」 까지 고를 수 있어야 한다.

   
만족해야 할 성질 포지션과 등급 조건을 받아 후보를 내주는 경로가 있을 것. 「상관없음」도 표현할 수 있을 것
정해야 할 것 🔴 「비슷하다」의 폭 — 평균이 B 일 때 A·C 까지인가 B 만인가. RAG 로 갈 자리입니다
확인 등급이 섞인 팀에 추천을 걸었을 때 평균 근처가 위로 오는가
하지 말 것 🔴 등급을 모르는 사람을 F 로 치지 않기 — 아직 분석을 안 한 것과 못하는 것은 다릅니다. 화면은 평균 셈에서 뺍니다(averageGrade)
지금 화면 거르기가 브라우저 안에서만 돕니다 — 자리 표시입니다. 첫 값은 앉은 사람들의 평균입니다

⚠️ RAG 는 지금 당장 쓰지 않습니다(사용자). 나중을 위해 기준만 적어 둡니다 — 팀 매칭의 「비슷한 팀」(20번)과 같은 종류의 판단이라 함께 정하는 편이 좋습니다.

  • 관련: 20번(비슷한 팀 · RAG) · 25번 · 26번
  • 담당: 정상호(기준) ✅ 회신함 · 정어진(내주는 경로) · 제기: 백성검 · 기한: 스프린트 3 이후 (급하지 않습니다)

✅ 기준 회신 (2026.09.14, 정상호) — 폭을 정하기 전에, 이 눈금이 자를 수 있는 자가 아닙니다

물으신 것은 「비슷하다」의 폭(평균이 B 일 때 A·C 까지인가)입니다. 답은 ±1칸인데, 🔴 그 「칸」을 S~F 여섯으로 세면 안 됩니다. 왜 안 되는지가 이 회신의 대부분입니다.

🔴 S~F 는 한 줄로 늘어선 눈금이 아닙니다 — 두 축이 섞여 있습니다

26번에서 정한 규칙이 그대로 말해 줍니다.

이웃한 두 칸 무엇이 다른가
S ↔ A 🔴 실력이 아니라 리뷰입니다. 둘 다 분석 등급 A 이고, 재매칭 의사가 우세한 쪽이 S 입니다
A ↔ B ↔ C ↔ D 분석 점수
D ↔ F 🔴 다시 리뷰입니다. 둘 다 분석 등급 D 입니다

그래서 「한 칸 차이」가 자리마다 다른 뜻입니다. S→A 한 칸은 실력 차가 0 이고, B→C 한 칸은 실력 차가 한 칸입니다.

🔴 그리고 실력 축 자체도 등간이 아닙니다

grade_bands 가 총점 0~100 을 이렇게 자릅니다.

등급 총점 구간 폭
A 85~100 15
B 70~85 15
C 50~70 20
D 0~50 🔴 50

D 한 칸이 A 세 칸보다 넓습니다. 두 D 는 49점 떨어져 있을 수 있고 두 A 는 아무리 멀어도 15점입니다. 「D 끼리는 비슷하다」가 성립하지 않습니다.

🔴 그래서 gradeValue() 의 평균이 뜻을 갖지 못합니다
gradeValue: S=5 · A=4 · B=3 · C=2 · D=1 · F=0   // 등간으로 놓았습니다

averageGrade([S, D]) 는 (5+1)/2 = 3 → B 를 냅니다. 그런데 S 는 「분석 A + 리뷰 좋음」이고 D 는 「분석 D」라, 둘의 평균이 B 라는 말에 대응하는 것이 없습니다. 리뷰 유무를 실력으로 환산한 셈입니다.

고치는 법은 작습니다 — 평균은 실력 축 네 칸에서 내고, S·F 는 그 위에 붙는 배지로 다룹니다.

실력값:  D=0 · C=1 · B=2 · A=3     (F 는 D 와 같음, S 는 A 와 같음)
⑴ 답 — 폭은 실력 축에서 ±1칸입니다
팀 평균 보여 주는 범위  
A S · A · B S 가 들어오는 것은 실력 축에서 A 와 같은 자리라서입니다
B A · B · C 물으신 예시입니다 — A·C 까지 맞습니다
C B · C · D · F F 는 D 와 같은 자리입니다
D C · D · F  

🔴 ±1 을 고른 이유는 반올림입니다. averageGrade 가 평균을 가장 가까운 칸에 넣으면서 최대 반 칸을 버립니다. 2.4 를 B 로 반올림한 뒤 정확히 B 만 보여 주면, 버린 반 칸이 그대로 손실이 됩니다 — 실제로는 C 에 가까운 팀인데 C 를 못 봅니다. ±1 은 그 반올림 오차를 덮는 최소 폭이지 「적당히 넓게」가 아닙니다. ±2 는 전부라 뜻이 없습니다.

⑵ 🔴 그런데 기본값은 거르지 말고 정렬하시길 권합니다

항목의 「확인」이 이미 그렇게 적혀 있습니다 — “평균 근처가 위로 오는가“. 「그것만 보이는가」가 아닙니다. 그런데 지금 화면은 거릅니다:

const list = grade === ANY_GRADE ? all : all.filter((s) => s.grade === grade)

useState(() => averageGrade(seated) ?? ANY_GRADE) 라 열자마자 한 칸으로 좁혀진 상태입니다. 이유 셋으로 반대합니다.

   
⑴ 🔴 분석 안 한 사람이 통째로 사라집니다 초기에는 등급 있는 사람이 소수입니다. 거르개가 기본으로 켜져 있으면 화면이 거의 빕니다
⑵ 빈 화면 위험 20번에서 백성검 님이 “조건을 조금 잘못 적은 사람에게 빈 화면만 남는다” 고 하신 판단과 같은 자리입니다. 그때 제가 「하드만 문턱, 나머지는 정렬」로 답했고 여기도 같습니다
⑶ 등급은 하드 조건이 아닙니다 판 크기(5:5 대 7:7)처럼 안 되면 안 되는 것이 아닙니다. 등급이 한 칸 다른 사람과도 경기는 됩니다

거르개 자체는 그대로 두세요 — 사용자가 직접 고른 칸은 추론이 아니라 요청이라 하드로 존중하는 것이 맞습니다. 바꾸실 것은 기본값뿐입니다 (ANY_GRADE 로 열고, 평균 근처를 위로).

⑶ 🔴 「등급을 모르는 사람」 — 거리 축에 올리지 않습니다

「하지 말 것」이 “등급을 모르는 사람을 F 로 치지 않기” 인데, F 로 치지 않는 것만으로는 부족합니다. 거리 1 이나 「중간」을 주는 것도 똑같이 지어내는 것입니다 — 모르는 값에 자리를 주는 순간 안 잰 것이 근거가 됩니다(ho 21번).

두 덩어리로 나눠 그리시길 권합니다.

[ 평균 근처 ]      등급이 있고 ±1칸 안
[ 그 밖 ]          등급이 있고 밖
[ 아직 분석 전 ]   등급이 없음 — 🔴 섞지 않고 따로

사라지지도 않고, 없는 값을 꾸며 내지도 않습니다. 화면이 이미 같은 판단을 averageGrade 에서 하고 계십니다(“등급을 모르는 사람은 셈에서 뺀다”) — 그리는 자리에서도 같게 하는 것입니다.

🔴 RAG·벡터는 여기 필요 없습니다. 다만 20번과 이유가 다릅니다

20번에서 제가 임베딩을 배제한 이유 셋(거리가 축 불일치를 보상한다 · 근거를 못 낸다 · 검증 못 한다)은 여기서도 그대로입니다. 그런데 한 가지가 다릅니다 — 20번은 “요구사항의 유사도는 SFR-005(선수 성향 player_vector)뿐이고 팀↔팀은 계약에도 요구사항에도 없다” 였는데, 27번은 선수↔선수라 그 근거가 있습니다.

그래도 필요 없습니다. 물으신 것이 「비슷한 등급」이기 때문입니다.

   
등급 이미 한 개의 순서값입니다. 스칼라를 닮게 하는 데 벡터가 필요 없습니다
player_vector 성향(스타일) 축입니다 — 「이 사람과 비슷하게 뛰는 사람」을 물을 때 쓸 것이고, 그건 다른 기능입니다

🔴 두 질문을 섞지 마세요. 「수준이 비슷한가」와 「스타일이 비슷한가」는 답이 다르고, 벡터로 한꺼번에 재면 ⑵(근거를 못 낸다)가 그대로 옵니다 — 「왜 이 사람을 추천했나」에 「코사인 0.87」 말고 할 말이 없어집니다.

그리고 지금 벡터를 붙여도 잘 안 섭니다. ho 32번에서 잰 것: 인스텝 18편에서 plant_foot_to_ball_offset 이 0/18(공 검출이 있어야 나옵니다) · hip_rotation_range_deg 가 15/18 — 같은 루브릭 안에서도 칸이 빕니다.

26번과 같은 자물쇠가 걸립니다

이 판은 남의 등급을 화면에 세우는 자리라 26번에 적은 것이 그대로 적용됩니다 — A~D 를 내는 루브릭이 review_required: true 이고, 같은 날 ho 34번이 6개 항목 중 3개가 문헌과 어긋난다를 냈습니다. provisional 을 함께 받아 「검수 전」을 달아 주세요.

정리 — 정어진 님께 필요한 것
   
하드(문턱) 포지션 · 자기 팀·이미 앉은 사람 제외 · 그 시간에 가능 · 사용자가 등급을 직접 골랐을 때만 그 칸
소프트(정렬) 실력 축 거리(위 4칸 환산) · 최근 활동
함께 내려야 후보별 overall_grade(없으면 🔴 null, 25번) · provisional
🔴 하지 말 것 서버가 유사도 점수를 매겨 내리지 마세요 — 20번에 같은 부탁을 적었습니다. 사실값만 주시면 순서는 화면이 정합니다
  • 근거: 26번(등급 눈금) · 20번(비슷한 팀 — 하드/소프트 분리) · ho 32번(player_vector 축) · ho 34번(임계값 출처) · ho 21번(「없다」와 「낮다」) · agent/rubrics/football_instep_shot.yaml(grade_bands)
  • 남은 담당: 정어진(내주는 경로) ✅ 완료 (2026.09.16, 아래) · 백성검(gradeValue 평균을 실력 축 네 칸으로 · 기본값을 ANY_GRADE 로 · 「아직 분석 전」 덩어리 분리) ✅ 완료 (2026.09.16 — 평균은 아예 안 낸다. 서버가 정렬해서 주므로 화면이 다시 계산하면 두 곳이 갈린다)

✅ 정어진 몫 완료 (2026.09.16) — GET /teams/{id}/squad/candidates

표에 적힌 하드/소프트/함께 내려야 할 것을 그대로 구현했습니다. 🔴 한 가지 배치 판단: 20번(팀 대 팀 후보)과 재료가 겹치지만 25·26번의 등급 계산까지 필요해서 match 컨텍스트 하나에 몰아넣었습니다 — 컨텍스트끼리 포트도 인터랙터도 못 넘겨서(경계 규칙), 등급 계산(analysis·review 원시 교차 읽기 + Wilson 식)을 복제했습니다(app/match/domain/rules/ candidate_grade_rules.py — grade_rules.py가 정본, 둘이 갈리면 그쪽을 따릅니다).

  • grade를 직접 고르면 그 칸만 하드 필터, 안 고르면 거르지 않고 이미 앉은 사람들의 등급 평균과의 실력 축 거리로 정렬(등급 모르는 후보는 뒤로 가되 null로 남습니다 — 안 사라집니다)
  • 거리·유사도 점수는 응답에 없습니다(하지 말 것 지켜짐)
  • 확인: .venv/bin/pytest -q tests/match/ tests/analysis/ tests/test_architecture.py 전부 통과, 신규 tests/match/adapter/test_match_preference_db.py:: TestSquadCandidates(실제 PostgreSQL로 포지션·등급·제외 다섯 테이블 조인 검증) · alembic check “No new upgrade operations detected”
  • 계약 docs/api-contract.md(3-16절)·docs/client-contract-changes.md (44번) 갱신

남은 것은 백성검 쪽 화면(등급 평균의 실력 축 환산·기본값·분석 전 덩어리 분리)뿐입니다 — 44번에 적어 두었습니다.

🔴 덧붙임 (2026.09.16 저녁) — 이 판은 지금 눌러도 다음이 없습니다

이 경로를 내준 뒤에 알아차린 것입니다. 여기 나오는 후보는 팀원이 아닌 사람인데(하드 필터가 현재 팀원을 뺍니다), 스쿼드 등재는 팀 구성원의 카드만 받습니다 — POST /teams/{id}/squad/members 가 422 NOT_TEAM_MEMBER 로 막습니다(squad_interactors.py). 그래서 추천을 받아도 그 사람을 앉힐 방법이 없었습니다.

빠진 조각은 「팀에 들이는 경로」였고, 그것을 min 20번으로 만들었습니다 (팀 초대·수락, POST /teams/{id}/invitations → 받은 사람이 수락). 이 판에도 같은 초대 경로를 쓰시면 됩니다 — 계약은 client-contract-changes.md 49번입니다.

스쿼드 등재의 소속 전제를 푸는 선택지도 있습니다(용병을 소속 없이 앉히기). squad_interactors.py 주석이 “용병을 스쿼드에 넣어야 할 일이 생기면 여기를 고치면 된다” 고 미리 적어 뒀는데, 제품 판단 이라 손대지 않았습니다 — 그 길로 가는 편이 낫다고 보시면 알려 주세요.

28. 「선수와 비교하기」는 데모 영상 + 브라우저 실측 세 순간 비교입니다 — 진짜 선수 영상 경로와 「비슷하다」 기준은 아직 없습니다 (2026-09-11 신설, 2026-09-15 제목 갱신) ✅ 경로는 확인됐습니다 (2026.09.16) — 「비슷하다」 기준·after 폴백만 남음

🔴 정정 (2026.09.15) — 이 제목은 처음(2026-09-11) 「전부 자리 표시입니다 — 영상도 기준도 없습니다」였습니다. 그 뒤(2026.09.14) 데모 클립 + 브라우저에서 잰 세 순간(직전 · 임팩트 · +1초) 겹침 비교 + 코드 규칙 문장 요약이 실제로 붙어서, “전부 자리 표시”는 더 이상 맞지 않습니다. 아직 안 된 것은 아래 표대로 ⑴ 진짜 선수 영상(비슷한 장면을 찾는 경로) · ⑵ 「비슷하다」의 기준 둘입니다 — 이 항목이 여전히 열려 있는 이유입니다.

분석 화면의 리포트 옆에 「선수와 비교하기」를 붙였습니다(사용자 요청). 누르면 선수 둘 중 하나를 고르고, 고르면 「찾고 있는 중입니다」가 돌다가 영상 칸이 반으로 갈려 왼쪽에 선수 영상이 섭니다.

🔴 정정 (2026.09.14) — 선수 이름을 가상 이름으로 바꿨습니다. 처음엔 실존 선수 둘(리오넬 메시 · 크리스티아누 호날두)을 걸었는데, 사이트가 공개라 실존 인물의 이름을 허락 없이 거는 것 자체가 퍼블리시티권 문제이고 제휴처럼 읽힙니다. 지금은 에스테반 로벨리 · 티아구 카스탄헤이라(지어낸 이름)입니다. 아래 1번 저작권 판단이 끝나기 전에는 실존 선수 이름·영상으로 되돌리지 않습니다.

🔴 화면은 끝까지 도는데 서버로 나가는 것이 하나도 없습니다.

무엇 지금
선수 영상 데모 클립입니다(2026.09.14~). 선수마다 Pexels 무료 영상 하나를 붙박이로 붙였고(15436954 · 15436958, www/public/compare/), 그 위에 브라우저 검출기로 가장 큰 사람을 하늘색 뼈대로 그립니다. 「비슷한 영상을 찾은」 것이 아닙니다 — RAG · LangGraph 검색이 붙으면 영상 주소만 갈아 끼웁니다
찾는 시간 붙박이 1.6초(COMPARE_MS) — 진짜 검색이 아닙니다
「비슷하다」 기준이 없습니다

화면보다 앞에서 막히는 것 셋 — 순서가 있습니다

  무엇 왜 먼저인가
1 선수 영상을 우리가 가질 수 있는가 🔴 저작권이 먼저입니다. 기술 문제가 아니라 이것이 막히면 아래 둘이 무의미합니다. 대안(직접 촬영한 코치 영상 · 공개 라이선스 자료)까지 함께 봐 주세요
2 그 영상들도 우리 지표로 분석돼 있을 것 「비슷하다」를 재려면 같은 값이 뽑혀 있어야 합니다. 영상만 있으면 비교할 수가 없습니다
3 「비슷하다」의 뜻 총점이 비슷한 것인가 · 특정 항목이 비슷한 것인가 · 아니면 정반대(내 약점을 잘하는 장면)인가

⚠️ 3번이 갈립니다. 화면에는 「비슷한 영상」이라 적었지만, 배우는 자리라면 나와 다른 장면을 보여 주는 편이 맞을 수도 있습니다. 그 판단에 따라 화면 문구도 같이 바뀝니다.

🔴 20번(비슷한 팀) · 27번(비슷한 선수)과 같은 종류의 판단입니다. 셋을 따로 정하면 제품 안에서 「비슷하다」가 세 가지 뜻이 됩니다 — 함께 정해 주세요.

   
만족해야 할 성질 영상 하나를 주면 견줄 만한 장면을 돌려주는 경로가 있을 것. 경로·필드 이름은 자유입니다
확인 분석이 끝난 영상으로 불러 장면이 오는가. 없으면 빈 목록이어야 합니다(지어내지 마세요)
하지 말 것 🔴 화면이 「비슷하다」를 정하지 않기 — 그으면 그게 곧 지어내는 것입니다 · 🔴 저작권을 확인하기 전에 영상을 모으지 않기
지금 화면 www/src/components/analysis/AnalysisStage.tsx 의 startCompare — 찾는 시간은 여전히 붙박이(COMPARE_MS)고 선수마다 정해 둔 데모 클립(COMPARE 의 src)을 씁니다. 그 위에 세 순간(직전 · 임팩트 · +1초) 비교가 붙었습니다(www/src/lib/motion/) — 진짜가 붙으면 걷는 자리는 lib/motion/source.ts(관절 값을 어디서 받는지) 와 COMPARE 의 src(선수 영상 주소) 입니다
  • 관련: 20번(비슷한 팀 · RAG) · 27번(비슷한 선수 · RAG)
  • 담당: 박민호(저작권 판단) · 정상호(기준 · 선수 영상 분석) · 제기: 백성검 · 기한: 스프린트 3 이후 (급하지 않습니다)

갱신 (2026.09.14) — 회색 칸이 Pexels 데모 영상으로 바뀌었고(위 표), 이 영상들은 무료 라이선스라 저작권 판단이 필요 없습니다(사용자 확인). 박민호 님 판단은 나중에 실존 선수 영상을 쓸 때만 남습니다. 비교 기능을 「세 순간 자세 비교 + Gemini 요약」으로 키우는 설계가 www/docs/2026-09-14-선수-비교-분석-design.md 에 있고, 서버 쪽 요청은 아래 29번(정어진) · 30번(정상호)입니다.

29. 선수 영상과 그 관절 결과를 읽는 경로가 필요합니다 — 비교 화면의 서버 몫 (2026-09-14 신설) ✅ 관절 읽기 해소 (2026.09.15) — 재생 주소는 범위 밖

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

30. 분석 결과에 관절 시계열과 세 순간을 실어 주세요 — 비교 화면의 에이전트 몫 (2026-09-14 신설) ✅ 에이전트 몫 완료 (2026.09.15)

29번과 짝입니다. 비교 화면은 임팩트를 에이전트와 같은 정의로 잡습니다 — segment_phases 의 extension_peak(차는 다리 무릎 신전 각속도 최대). 데모는 브라우저가 이 정의를 흉내 내고, 최종에는 에이전트가 계산한 값을 그대로 씁니다.

   
만족해야 할 성질 분석 결과에 ⑴ 프레임별 관절(COCO 17점) · fps ⑵ 차는 다리 ⑶ 임팩트(extension_peak) · 직전(🔴 정정, 2026-09-15 — 임팩트 0.3초 전. 그 프레임에 차는 다리 무릎각이 없으면 가장 가까운 잡힌 프레임(동률이면 이른 쪽)으로 대신. 앞서 「임팩트 앞 0.5초 안에서 차는 다리 무릎각 최소」로 적었던 것을 정정합니다 — 그 정의는 구조상 최소각 프레임이 임팩트 바로 한 프레임 전으로 쏠려 두 카드가 거의 같은 자세로 보였습니다) · +1초(임팩트 + 1초, 넘치면 마지막 유효 프레임) 프레임 번호가 실릴 것. 선수 영상 두 편(29번)도 같은 파이프라인으로 분석
확인 같은 영상에서 데모(브라우저)가 찾은 임팩트와 0.2초 안에서 만나는가 — 더 갈리면 데모 쪽 정의를 고칩니다(에이전트가 정본)
하지 말 것 🔴 세 순간을 언어 모델로 정하지 않기 — 재현성이 필요한 구간(segment_phases 주석과 같은 이유) · 「직전」 정의가 에이전트 관점에서 맞지 않으면 고치지 말고 이 항목에 이유를 적어 주세요 — 화면 쪽 정의를 따라 바꿉니다
  • 관련: 28번 · 29번 · ho 39번(축구 단일 종목)
  • 담당: 정상호(결과에 싣기 · 선수 영상 분석) ✅ 둘 다 끝났습니다 (2026.09.15) — 싣는 것도, 선수 영상 두 편 분석도(로컬 GPU 로 돌렸고 S3 에 올렸습니다. 🔴 이 줄이 2026.09.16 까지 「남은 것은 선수 영상 두 편 분석(GPU 필요)」로 낡아 있었습니다 — 본문 「✅ 선수 영상 두 편 분석했습니다」와 어긋나 다시 할 뻔했습니다) → 남은 것은 백성검·박민호 판단(본보기 영상이 D 를 받는 것 · 「비슷하다」 기준)과 정어진 확인(S3 되읽기 · reports/_probe.txt 삭제) · 제기: 백성검 · 기한: 스프린트 3 이후 (급하지 않습니다) 🔴 앞당겼습니다 — 아래 「왜 앞당겼나」

✅ 싣는 것은 했습니다 (2026.09.15, 정상호)

봉투에 skeleton 이 실립니다. schema_version 1.2 → 1.4(필드 추가라 minor). 받는 쪽이 이 키를 몰라도 적재는 안 깨집니다.

   
무엇이 실리나 joints(프레임당 COCO-17 × [x, y, confidence]) · fps · frames · frame_size · swing_leg(차는 다리) · keypoint_names · moments(before·impact·after) · moments_seconds
좌표 frame_size 로 나눈 값입니다 — subject 의 박스와 같은 규약이라 화면 크기를 몰라도 바로 겹쳐집니다
못 잡은 프레임 null 로 자리를 지킵니다 (29번의 확인 조건)
세 순간 요청하신 정의 그대로. impact 는 segment_phases 가 고른 값을 그대로 싣습니다 — 다시 찾지 않습니다
「직전」 2026-09-15 정정판(임팩트 0.3초 전 · 없으면 가장 가까운 잡힌 프레임 · 동률이면 이른 쪽)으로 넣었습니다
두 경로 HTTP 응답과 S3 리포트가 같은 함수(features.skeleton_envelope)를 씁니다 — subject 와 같은 이유입니다
🔴 판정 입력 features 는 한 키도 안 늘었습니다. 형제 블록이라 점수·등급이 한 자리도 안 바뀌고 B-6 재실행을 안 부릅니다 — 검사로 고정했습니다
검사 22건 추가, 437 통과(415 → 430 → 437). 「못 잡은 프레임이 배열에서 빠지는 것」·「임팩트를 여기서 다시 찾는 것」·「차는 다리가 채점과 갈리는 것」을 막는 셋은 지우면 안 되는 검사로 적어 뒀습니다
확인 cd agent && uv run pytest tests/test_skeleton_envelope.py -q · 계약은 agent/report-contract.md 의 「skeleton」 절 (정본은 agent/contracts/report_schema.yaml)

🔴 크기를 정정합니다 (2026.09.15, 선수 영상 2편 실측) — 앞서 「약 50KB」라 적었는데 틀렸습니다. 15fps·150프레임을 가정했는데 DEFAULT_TARGET_FPS 가 30, DEFAULT_MAX_FRAMES 가 300 이라 실제 기본 격자가 두 배입니다.

기본 격자(30fps · 300프레임 = 10초)  
skeleton 넣기 전 31 KB
넣은 뒤 398 KB (13배) — 그중 skeleton 이 316 KB · 79%

(storage.upload_json 이 indent=2 로 올리는 것도 곱해집니다 — compact 면 124KB.)

🔴 적재 설계가 달라지는 크기입니다. 컬럼 하나에 통째로 넣을지, S3 에 두고 키만 적재할지 정어진 님 판단이고, 정하시면 이 항목이나 jin 27번에 남겨 주세요. 줄이는 쪽이 필요하면 에이전트에서 할 수 있는 것도 있습니다(좌표 자릿수 · 업로드 형식 · fps).

🔴 판단해 주실 것 하나 — after(+1초)의 폴백 (백성검)

명세를 그대로 구현했습니다: after 는 넘칠 때만 마지막 유효 프레임으로 물러섭니다. 그런데 넘치지 않았는데 그 프레임에서 사람을 못 잡은 경우는 명세에 없어서, 지금은 joints[after] 가 null 이고 카드가 빈 채로 나갑니다.

before 에는 그 폴백이 있습니다. 🔴 제가 임의로 맞추지 않은 것은 30번이 「정의가 안 맞으면 고치지 말고 이유를 적어 달라」고 했기 때문입니다. before 와 같은 규칙(가장 가까운 잡힌 프레임 · 동률이면 이른 쪽)을 걸까요? 말씀 주시면 한 줄입니다.

🔴 motion/types.ts 와 대조했습니다 — 두 필드에 서버 쪽 짝이 없습니다 (2026.09.15)

병합으로 들어온 www/src/lib/motion/types.ts(백성검, 09bf18b)가 화면이 기대하는 모양을 적어 두고 있어 제 봉투와 한 줄씩 맞춰 봤습니다. 대부분 그대로 맞습니다 — 다만 source.ts 를 갈아끼울 때 막힐 자리가 둘입니다.

Motion·Moments 의 필드 제 봉투에서
fps ✅ skeleton.fps
frames(프레임마다 COCO 17점, 못 잡은 프레임 null) ✅ skeleton.joints — null 규약까지 같습니다
aspect ✅ frame_size 에서 나옵니다 (w / h). 좌표를 가로·세로 따로 눌러 둔 것도 같은 규약이라 각도 되돌리기가 그대로 통합니다
measuredBy ✅ 타입에 'agent' 가 이미 있습니다
kickingLeg ✅ skeleton.swing_leg (이름만 다릅니다)
before·impact·after ✅ skeleton.moments. 🔴 화면 이름이 백스윙·접촉·접촉 후로 바뀌었지만 키는 그대로라 안 어긋납니다
🔴 afterClipped ✅ 넣었습니다 (2026.09.15) — skeleton.after_clipped
🔴 direction(차는 방향 — 화면 오른쪽이 1) ✅ 넣었습니다 (2026.09.15) — skeleton.direction

✅ 둘 다 넣었습니다 (2026.09.15) — schema_version 1.4

skeleton.after_clipped(boolean) · skeleton.direction(1 = 화면 오른쪽, -1 = 왼쪽). 🔴 규칙을 새로 만들지 않고 www/src/lib/motion/moments.ts 의 것을 그대로 옮겼습니다 — 두 곳에서 각자 세는 것이 피하려던 것이라서입니다.

🔴 옮기면서 제 구현이 화면과 어긋난 데 셋을 찾았고, 화면 쪽으로 맞췄습니다. moments 값이 일부 클립에서 달라집니다(앞서 낸 리포트와 견주실 때 참고):

어긋났던 것 화면 규칙 = 지금
🔴 「+1초」가 넘치는 기준 frames 가 아니라 마지막 유효 프레임입니다. frames 로 재면 뒤가 미검출로 끝나는 클립에서 빈 스켈레톤이 「접촉 후」 카드로 나가고, after_clipped 도 안 서서 화면은 그게 진짜 +1초인 줄 압니다
🔴 「직전」의 탐색 범위 [첫 유효, impact-1] 안에서만 고릅니다. 안 가두면 목표 앞쪽이 전부 미검출인 클립에서 임팩트 뒤 프레임이 「직전」으로 나옵니다 — 그래도 그림은 멀쩡히 그려집니다
🔴 방향을 무엇과 재는가 impact - 1 과 잽니다. before 로 재지 않습니다 — 0.3초 앞은 되접는 도중일 수 있어 부호가 뒤집힙니다(백성검 님이 09-15에 겪으신 그것)
  • 검사 7건 추가, 430 → 437 통과. 🔴 조건부로 써서 공허하게 통과하던 검사 둘을 찾아 고쳤습니다 — 기본 픽스처가 늘 넘치는 클립이라 「안 넘침」 가지가 한 번도 안 돌았습니다. 규칙을 일부러 틀리게 바꿔 둘 다 빨개지는 것을 확인했습니다
  • 확인: cd agent && uv run pytest tests/test_skeleton_envelope.py -q

🔴 한쪽만 고치면 갈립니다. 세 순간·방향의 정본은 화면(moments.ts)이고 에이전트는 옮겨 적은 쪽입니다 — 계약 문서와 report_schema.yaml 에도 그렇게 적어 뒀습니다. 화면 쪽 정의를 바꾸실 때 이 항목에 한 줄 남겨 주세요.

곁가지: www/src/lib/skeleton.ts(신규)와 제 봉투의 skeleton 키는 이름만 같고 다른 것입니다 — 그쪽은 관절을 그리는 모양(SVG d), 이쪽은 서버가 내주는 값입니다. 충돌은 아니고 읽을 때 헷갈릴 수 있어 적어 둡니다.

✅ 선수 영상 두 편 분석했습니다 (2026.09.15) — EC2 없이 로컬 GPU로

www/public/compare/ 의 두 편을 같은 파이프라인으로 돌렸습니다. 산출은 계약 봉투 그대로(schema_version 1.4)입니다.

✅ S3 에 올라가 있습니다 (2026.09.15, EC2 에서 다시 돌려서) — 앞서 「제 기계에만 있어 두 분께 안 갑니다」라고 적었던 것이 풀렸습니다.

   
자리 s3://<S3 버킷>/reports/pro/pexels-15436954/report.json · .../pexels-15436958/report.json
크기 407,825 · 407,554 바이트
봉투 schema_version 1.4 · judge_backend vllm · skeleton.known true · joints 300프레임

🔴 읽는 것은 제가 확인 못 했습니다 — EC2 역할이 reports/ 에 쓰기 전용이라 되읽기가 403 입니다(설계대로입니다). 올린 바이트가 제 손의 사본과 정확히 일치하는 것까지는 확인했습니다. 정어진 님 쪽 자격증명으로 한 번 열어 봐 주세요.

✅ 정어진이 읽어봤습니다 (2026.09.16) — 끝까지 실제로 됩니다

ssh supersub 파드 안에서 boto3로 두 리포트를 직접 읽어 바이트 수까지 일치 확인(407,825 · 407,554바이트, 위 표와 동일)했고, 실제 계정을 만들어 배포된 API를 그대로 불러봤습니다:

호출 결과
GET /reference-players rovelli·castanheira 둘 다 옴
GET /reference-players/rovelli/skeleton known: true, moments: {before:110, impact:119, after:149} — 정상호 표와 동일
GET /reference-players/castanheira/skeleton known: true, moments: {before:207, impact:216, after:246} — 동일

결론: 이 항목이 필요로 한 “영상 하나를 주면 견줄 만한 장면을 돌려주는 경로”는 이미 paik 29번으로 구현·배포돼 있고, 방금 실제 배포 서버에서 끝까지 동작을 확인했습니다. 남은 것은 정어진 몫이 아닙니다 — 아래 「비슷하다」의 기준(정상호)과 after 폴백(백성검)뿐입니다.

⚠️ 검증하면서 트라이얼 DB에 테스트 계정 2개를 만들었습니다. 하나는 DELETE /me로 지웠는데, 두 번째는 삭제 호출이 세션 권한(원격 쓰기 분류기)에 막혀 아직 안 지워져 있습니다(verify2- 접두사 이메일, 검증2 접두사 닉네임 — 관리자 목록에서 걸러집니다). 해가 없는 더미 계정이라 급하지 않지만, 정리하시려면 /admin/users에서 지우시면 됩니다.

🔴 원본 영상은 videos/ 에 못 올렸습니다 — 역할이 그 접두사에 쓰기 권한이 없습니다(README.md 가 「AccessDenied 가 정상」이라고 적어 둔 그 자리입니다). 그래서 영상은 EC2 로컬(/tmp)에서 읽고 리포트만 S3 에 올렸습니다. 영상도 S3 에 있어야 하면 권한이 있는 분이 올려 주셔야 합니다.

곁가지: 권한 확인용 reports/_probe.txt 를 하나 남겼는데 DeleteObject 도 막혀 있어 못 지웠습니다. 지우실 수 있으면 지워 주세요.

  15436954 15436958
규격 1280×726 · 30fps · 12.9초 1280×726 · 30fps · 14.1초
분석 격자 300프레임(10초) 300프레임(10초)
키포인트 다리 100% · 공 검출 97% 다리 100% · 공 검출 98%
임팩트 119프레임 (3.97초) 216프레임 (7.20초)
세 순간 110 · 119 · 149 207 · 216 · 246
차는 다리 · 방향 right · +1(오른쪽) right · +1(오른쪽)
after_clipped false false
점수 70점 (B) 35점 (D)

✅ 둘 다 임팩트가 분석 창(10초) 안이라 잘린 구간이 판정을 안 건드립니다. 원본이 12.9·14.1초라 뒤가 잘리기는 합니다(truncated).

🔴 EC2가 필요 없었습니다 — 로컬 RTX 3050 8GB에서 포즈·판정이 다 돕니다 (판정 모델 적재 전에 포즈를 내리는 8GB 경로가 원래 설계입니다).

🔴 쓴 것은 www/public/compare/ 의 1280px 사본입니다 — 29번이 말한 원본 S3 업로드는 아직 안 됐습니다. 좌표를 frame_size 로 나눠 내보내므로 해상도가 달라도 겹치기는 같습니다. 원본으로 다시 뽑아야 하면 말씀 주세요.

🔴 15436958 이 D(35점)입니다. 「선수와 비교하기」의 본보기로 쓰는 영상인데 에이전트가 D를 매깁니다 — 상체가 26.9도 뒤로 젖혀져 있고 디딤발이 공에서 2.03배 떨어져 있어서입니다. 고장은 아니고 판단이 필요한 자리입니다: 본보기 영상으로 그대로 둘지, 다른 클립으로 바꿀지(백성검·박민호). 지금 화면은 선수 점수를 안 보여 주므로 당장 깨지는 것은 없습니다.

🔴 로컬(RTX 3050)과 EC2(T4)를 대조했습니다 — 측정값이 갈립니다 (2026.09.15)

같은 커밋(288be71) · 같은 영상으로 두 기계에서 돌려 봉투를 맞췄습니다. 미결 47번(기준선이 재현되지 않는다)과 같은 종류의 관찰이라 남깁니다.

  결과
점수 · 등급 ✅ 둘 다 동일 (70/B · 35/D), 항목별 등급도 전부 동일
skeleton.moments · swing_leg · direction · after_clipped ✅ 전부 동일
features 🔴 갈립니다 — hip_rotation_range_deg 59.5 → 58.5, 그 외 0.1도 차이 셋
joints 좌표 🔴 10,200개 중 약 10% 가 다름. 중앙 0.128px · 최대 2.56px(15436954)

✅ 대부분은 GPU 수치 잡음입니다 — Ampere(3050)와 Turing(T4)은 커널이 달라 마지막 자리가 흔들립니다. 중앙 0.13px 는 그리기에 보이지 않습니다.

🔴 한 곳만 103.8px 였고, 재 보니 무해했습니다 — 15436958 프레임 222의 왼쪽 손목이고 신뢰도가 0.19 / 0.18 로 양쪽 다 문턱(0.3) 미만입니다. 둘 다 「못 봤다」고 말한 관절이고 화면도 안 그립니다. 세 순간에도 안 닿습니다.

🔴 제가 중간에 잘못 짚은 것을 정정합니다 — 근거 문장의 숫자가 로컬(59.5)과 EC2(58.5)에서 달라서 모델이 숫자를 지어낸 줄 알았는데, 실제로는 측정값 자체가 그 기계에서 58.5 였습니다. 모델은 받은 값을 그대로 옮겼습니다.

뜻하는 것: 점수·등급·세 순간은 기계를 넘어 안정적이고, 소수점 지표와 관절 좌표는 안 그렇습니다. 47번이 「재현되지 않는다」고 한 것과 같은 방향이고, 여기서는 다른 기계라 원인이 설명됩니다(47번은 같은 기계라 아직 안 풀립니다).

  • 확인: cd agent && uv run python scripts/analyze.py <영상> --report out/x.json (🔴 --report 를 이번에 붙였습니다 — analyze_s3 와 같은 build_report 를 써서 S3 없이 계약 봉투를 냅니다. 봉투를 두 벌로 짓지 않으려고 그렇게 했습니다)

왜 앞당겼나 (2026.09.15)

기한이 「스프린트 3 이후 · 급하지 않습니다」였는데 화면에 붙고 나서 성격이 바뀌었습니다. 사용자가 「비교 기능을 켜니 분석 시간이 2배」 라고 했고, 원인이 이 항목입니다 — www/src/lib/motion/extract.ts 가 영상 두 편의 관절을 브라우저에서 뽑습니다(AnalysisStage.tsx 의 getMotion 두 곳). 서버가 안 주니 브라우저가 뽑는 것이라 뿌리가 여기입니다.

🔴 다만 제 몫만으로는 화면이 안 빨라집니다. 세 칸이 이어져야 합니다: 30번(정상호) ✅ → 29번(정어진, 읽는 경로) → source.ts 한 파일(백성검).

  • 이 때문에 ho 43번 ㉳(드리블)을 뒤로 미뤘습니다 — 그쪽은 어차피 지도자 검수(2번)와 ho 47번에 막혀 있습니다

31. 경기 신청 알림에 팀 이름이 없습니다 — 화면이 id 로는 「망원 유나이티드」를 못 씁니다 (2026-09-16 신설)

  • 담당: 정어진 ✅ 냈습니다 (2026.09.17, 아래) · 백성검(화면 배선·폴백 걷기) · 제기: 백성검 · 기한: 급하지 않음(지금 깨지는 것은 없습니다)

✅ 회신 (2026-09-17, 정어진) — 1번으로 냈습니다. 다만 2번은 이미 되어 있었습니다

TeamMatchRequestResponse 에 넷을 감싸지 않고 덧붙였습니다.

"requester_team_name": "번개FC",       "requester_team_region": "서울 강남",
"target_team_name": "망원 유나이티드",  "target_team_region": "서울 마포구"

생성·목록·수락·거절·무르기 다섯 경로가 전부 같은 모양이라 파서 하나로 읽힙니다. 기존 id 칸은 자리가 그대로입니다.

🔴 정정 — 「팀 하나를 읽는 경로가 계약에 없습니다」는 사실이 아니었습니다

GET /teams/{team_id} 가 있습니다. 계약 3-3절(1180줄)에 있고 소속이 아니어도 읽힙니다(인증만 필요 — 인터랙터 docstring 이 “가입하려면 먼저 봐야 한다”고 적어 두었습니다). 즉 2번은 그전부터 만족돼 있었습니다.

🔴 그리고 항목의 「확인」 명령이 오탐이었습니다
grep -n 'requester_team_name\|GET /api/v1/teams/{team_id}$' ...   # 0건

끝의 $ 가 문제입니다 — 실제 줄은 ### `GET /api/v1/teams/{team_id}` 로 백틱으로 끝나서 안 걸립니다. 「없다」가 아니라 「패턴이 안 맞았다」 였습니다. 저도 같은 실수를 한 적이 있어서 적어 둡니다 — $ 를 빼면 걸립니다.

그런데도 1번을 낸 이유

「경로가 없어서」가 아니라 목록에서 줄마다 부르지 않아도 되게 하려는 것입니다. 받은 신청이 N 건이면 GET /teams/{id} 를 N 번 부르게 됩니다. 같은 판단을 오늘 paik 37번(초대에 실을 값)에도 적용했고, MatchListingEntity 가 주최 팀 값을 얹는 것도 같은 자리입니다.

「하지 말 것」은 지켰습니다

말씀하신 「화면에서 캐시하지 마십시오」 가 성립하도록, 값을 신청 행에 복사하지 않고 매번 team 에서 읽습니다. 팀 이름을 고치면 (PATCH /teams/{id}) 다음 조회에 바로 반영됩니다 — DB 검사가 그것을 직접 봅니다(test_팀_이름을_고치면_다음_조회에_바로_반영된다).

teamMatch.ts 의 붙박이 폴백(teamById, 못 찾으면 「상대 팀」)은 이제 걷으셔도 됩니다.

  • 계약: client-contract-changes.md 55번 · 규격: api-contract.md 3-15절
  • 마이그레이션 없음 — 저장되는 값이 아니라 조회 결과입니다

계약 3-15절의 TeamMatchRequestResponse 에는 requester_team_id · target_team_id id 만 옵니다. 알림 판이 「망원 유나이티드가 경기를 걸었습니다」라고 쓰려면 id → 팀 이름이 필요한데, 팀 하나를 읽는 경로가 계약에 없습니다 (GET /teams/{id} 가 없고, GET /me 의 teams 는 내 팀만 줍니다).

만족해야 할 성질

받은 경기 신청에서 상대 팀의 이름을 알 수 있을 것. 방법은 둘 중 아무거나 좋습니다 — 어느 쪽이 스키마에 자연스러운지는 그쪽이 판단해 주세요.

  1. 신청 응답에 requester_team_name · target_team_name 을 얹거나
  2. GET /teams/{team_id} 로 팀 하나(이름 · 지역 · 종목)를 읽게 하거나

지역(region)도 같이 오면 줄에 한 번에 적습니다. 파일·필드 이름은 예시지 규격이 아닙니다.

지금은 어떻게 해 뒀나

www/src/lib/teamMatch.ts 의 붙박이 목록에서 찾습니다(teamById). 그 목록의 id 와 맞는 신청만 이름이 나오고, 못 찾으면 「상대 팀」으로 적습니다 — 🔴 이름을 지어내지 않습니다. 진짜 백엔드에 붙으면 거의 다 「상대 팀」이 됩니다.

확인

grep -n 'requester_team_name\|GET /api/v1/teams/{team_id}$' fastapi/docs/api-contract.md

걸리면 생긴 것이고, 이 항목을 닫고 www/src/components/NotifyPanel.tsx 가 그 값을 쓰게 바꿉니다(teamById 폴백은 그때 걷습니다).

🔴 하지 말 것

  • 화면에서 팀 이름을 캐시해 두고 쓰지 마십시오 — 이름이 바뀌면 조용히 옛 이름이 남습니다. 서버가 주는 값을 그때그때 씁니다.

32. 호칭이 실제로는 아무에게도 안 붙습니다 — 에이전트는 내는데 받는 자리가 없습니다 (2026-09-16 신설) ⛔ 안 하기로 했습니다 (2026.09.16) — 36번으로 바뀜

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

33. 추천 판에 쓸 「한 줄 특징」을 주세요 — 25번의 「리포트를 열지 말 것」을 한 칸만 엽니다 (2026-09-16 신설)

  • 담당: 정상호(무엇을 쓸지) ✅ 답하셨습니다 (2026.09.16, ho 50번) · 정어진(남의 것도 읽는 좁은 경로) ✅ 냈습니다 (2026.09.17, 아래) · 백성검(화면 배선·FLAVOR 걷기) · 제기: 백성검 · 기한: 스프린트 3 이후 (급하지 않습니다)

✅ 회신 (2026-09-17, 정어진) — 두 자리에서 같은 값을 받습니다

정상호 님이 「무엇을 쓸지」에 이미 답해 두셨습니다 — ho 50번의 result.card.notes(봉투 1.5, 가장 잘한 등급의 항목만 최대 두 줄)입니다. 그래서 summary 를 그대로 쓰지 않습니다 — 물어보신 우려 그대로, 남에게 보이는 줄이라는 것을 알고 쓴 문장이 따로 생겼습니다.

제 몫(적재 + 읽는 좁은 경로)을 냈습니다.

GET /teams/{team_id}/squad/candidates     // 추천 판 — 목록에 이미 실립니다
[ { "user_id": "...", "grade": "A", "provisional": false,
    "notes": ["차는 다리를 끝까지 뻗습니다", "디딤발을 공 옆에 붙입니다"] } ]

GET /cards/{card_public_slug}/grade       // 슬러그만 아는 자리
{ "grade": "S", "provisional": false, "notes": ["..."] }

🔴 추천 판은 후보마다 /grade 를 다시 부르지 않아도 됩니다 — 같은 리포트에서 온 값이라 목록 응답에 함께 실었습니다.

셋 다 정상값입니다
받는 값 뜻
두 줄 보통
한 줄 정상 — 두 줄을 채우려고 지어내지 않는 것이 봉투 쪽 규칙입니다
null card 칸이 없던 봉투(1.4 이하)로 적재됐거나 분석 전. 「분석이 없다」가 아닙니다
「하지 말 것」은 지켰습니다
  • 수치 없음 — 문장만 옵니다
  • 리포트 전체를 열지 않음 — 항목별 점수·근거(evidence)는 여전히 자기 것만입니다. 25번이 막아 둔 것 중 한 칸만 열었고, 그 선을 지키는 계약 검사(test_리포트_전체가_아니라_좁은_칸만_준다)에 왜 한 칸이 늘었는지 (제기자가 25번 판단을 스스로 정정한 사실) 적어 두었습니다
  • summary 재사용 안 함 — 위 참조
적재도 함께 했습니다

analysis_report.card_notes(마이그레이션 a7d5e0f34c19). 🔴 card.title 은 안 받았습니다 — ho 50번 결정대로 추천 카드가 안 쓰고(이름 아래 한 줄은 사람이 적은 호칭, paik 36번), 항목별 칭호는 이미 analysis_metric_criterion.title 에 있어 또 담으면 같은 값이 두 곳에 생깁니다. 필요해지면 그때 받습니다 — 봉투는 안 바뀝니다.

  • 계약: client-contract-changes.md 56번 · 규격: api-contract.md 3-6·3-14절

AI 추천 판이 후보마다 「반대편 빈 공간을 자주 찾습니다」 같은 한두 줄을 보입니다. 이름·등급·대표 영상은 진짜가 됐는데(43·44번) 이 문장만 아직 화면 붙박이입니다 — www/src/components/SquadSuggest.tsx 의 FLAVOR.

🔴 제가 전에 막아 둔 자리입니다 — 그 판단을 정정합니다

25번에 이렇게 적었습니다:

🔴 리포트 전체를 열지 않기 — 남의 근거 문장·수치까지 보이면 안 됩니다. 필요한 것은 등급 한 칸입니다

그때는 등급만 필요했습니다. 지금은 문장도 필요합니다 — 근거 없이 등급만 있으면 「왜 이 사람인가」에 답이 없고, 그게 이 판의 존재 이유입니다. 다만 리포트를 통째로 여는 것은 여전히 반대입니다.

만족해야 할 성질

  1. 후보의 「한 줄 특징」을 남도 읽을 수 있을 것 — 등급처럼 한 칸만.
  2. 그 문장이 분석에서 나온 것일 것 — 화면이 짓지 않습니다.

🔴 하지 말 것

  • 수치를 넣지 마십시오. 카드에 수치를 안 그리는 규칙 그대로입니다(계약 4장).
  • 리포트 전체를 열지 마십시오 — 항목별 점수·근거는 여전히 자기 것만입니다.
  • 🔴 summary 를 그대로 쓰지 마십시오(정상호 확인 요청). 그건 자기에게 보여 주려고 쓴 문장이라 남에게 보이기엔 톤이 다를 수 있습니다. 남에게 보이는 줄이라는 것을 알고 쓴 문장이 따로 있어야 할 것 같습니다 — 판단을 부탁드립니다.

✅ 회신 (2026.09.17, 정상호) — 그 문장은 이미 있습니다: result.card.notes

만들 것이 없습니다. 같은 날(09-16) 제 구역 50번(「추천 카드의 설명 칸 — 불릿은 분석이, 한 줄 소개는 사람이」)에서 이 칸을 채웠고, 위 「만족해야 할 성질」 둘과 「하지 말 것」 셋을 그대로 만족합니다.

"card": { "title": "끝까지 뻗은 다리",
          "notes": ["차는 다리를 끝까지 뻗습니다", "디딤발을 공 옆에 붙입니다"] }
부탁하신 것 어떻게 되어 있나
남도 읽을 수 있는 한 칸 result.card — 봉투 schema_version 1.5. 리포트 전체가 아니라 이 블록만입니다
분석에서 나온 문장일 것 화면이 짓지 않고 코드도 안 짓습니다 — 루브릭 agent/rubrics/*.yaml 의 card_lines(항목×등급마다 고정 문구)
수치를 넣지 말 것 고정 문장이라 숫자를 쓸 자리가 없고, 검사가 자릿수 0을 강제합니다(test_no_digits_reach_the_card)
리포트 전체를 열지 말 것 이 블록은 점수·근거 문장과 별개 필드입니다. breakdown[] 은 그대로 자기 것만

🔴 summary 판단 — 쓰지 마시는 것이 맞습니다. 이유가 톤보다 셉니다

톤 걱정이 맞습니다만, 실제로 더 센 이유가 둘 있습니다.

  1. 🔴 summary 는 「가장 아쉬웠던 항목」을 말합니다. 코드가 강점 한 문장 + 약점 한 문장으로 짓습니다(scoring.summarize — 「…가 가장 아쉬웠습니다」). 그런데 추천 카드에는 아쉬운 항목을 안 싣기로 했습니다(50번, 2026.09.16 사용자 판단) — 남이 보는 화면에서 사람 이름 옆의 약점 한 줄은 「이 선수를 부를까」에 보태기보다 사람을 규정하는 쪽으로 읽히기 때문입니다. summary 를 그대로 쓰면 그 결정이 조용히 깨집니다. card.notes 는 반대로 가장 잘한 등급의 항목만 담습니다 (test_the_card_names_only_the_best_grade_it_found 가 강제합니다)
  2. 🔴 summary 는 코드가 짓습니다 — 검수 대상이 아닙니다. card_lines 는 루브릭에 있어서 지도자 검수 대상입니다(미결 2번에 넣어 달라고 올려 뒀습니다). 남에게 보이는 줄일수록 검수된 문구여야 합니다

그래서 「남에게 보이는 줄이라는 것을 알고 쓴 문장이 따로 있어야 한다」는 말씀이 정확히 맞고, 그 문장이 card.notes 입니다. summary 는 본인 리포트에 그대로 둡니다 — 거기서는 아쉬운 항목이 낙인이 아니라 코칭입니다.

🔴 남은 주의 둘

  • 출처를 화면에서 갈라 주세요. 이제 한 카드에 사람이 적은 호칭(paik 36번)과 에이전트가 낸 불릿이 나란히 놓입니다. 안 가르면 팀장은 사람이 적은 호칭도 AI 판정으로 읽습니다
  • 이 문장을 화면에서 다시 쓰거나 기워 붙이지 말아 주세요. 문구를 고칠 일이 생기면 루브릭을 고치는 것이 맞고, 그래야 검수 이력이 한 곳에 남습니다
  • 🔴 두 줄이 다 강점일 수 있습니다 — 「강점 하나 + 아쉬움 하나」를 가정한 배치라면 그것만 확인해 주세요

  • 상세: 같은 날 제 구역 50번 · agent/report-contract.md 의 「card」 절 · 정본은 agent/contracts/report_schema.yaml
  • 확인: cd agent && uv run pytest tests/test_summary.py -q → 59 통과 (2026.09.17 실측. 카드 관련 검사 함수 13개가 여기 있습니다)
  • 🔴 이 항목을 닫지 않았습니다 — 정어진 님의 「남의 것도 읽는 좁은 경로」가 남아 있습니다. 제 몫(무엇을 쓸지)만 끝났습니다

32번과의 관계

32번(호칭)이 먼저입니다. 호칭이 붙으면 「시야가 넓은」 자리가 채워지고, 이 항목은 그 아래 두 줄입니다 — 32번만으로도 화면이 꽤 채워집니다.

🔴 위 32번은 그 뒤 ⛔ 로 닫혔습니다 (2026.09.16, 36번으로 바뀜) — 이름 아래 한 줄은 사람이 적는 호칭이 채웁니다. 그러니 이 항목은 32번을 기다리지 않아도 되고, 「시야가 넓은」 자리는 에이전트가 안 채웁니다 (50번의 같은 결론). (정상호, 2026.09.17)

34. 경기 취소가 화면에서 서버로 안 갑니다 — 대기 팝업의 「무르기」 (2026-09-16 신설) ✅ 해소 (2026.09.17)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

35. 마지막 주장이 팀을 버릴 방법이 없습니다 — 팀 해체 또는 소유권 이양 (2026-09-16 신설) ✅ 해소 (2026.09.18)

  • 담당: 정어진(경로) ✅ 냈습니다 (2026.09.17) · 백성검(화면) ✅ 붙였습니다 (2026.09.18) · 제기: 백성검

혼자인 팀을 버릴 수 있습니다. 나가기가 409 LAST_OWNER 로 막히면 그 자리에 「팀 해체하기」 가 나오고, 누르면 DELETE /teams/{id} 가 나갑니다 (de125d1).

결정된 것 — 화면이 미리 판단하지 않습니다. 주장이 몇인지는 서버만 알아서, 미리 가르면 주장이 둘인 팀에서 나갈 수 있는 사람에게 해체를 들이밉니다. 그래서 LAST_OWNER 를 받았을 때만 권합니다(status 가 아니라 code 로 가릅니다). TEAM_HAS_UPCOMING_MATCH 도 미리 안 가리고 서버 문구를 그대로 보여 주며, 단추는 남겨 둬서 경기를 정리한 뒤 다시 누를 수 있습니다.

⛔ 소유권 이양 화면은 안 붙였습니다. 경로(PATCH …/members/{id} 의 role)는 와 있지만, 사용자가 말한 것은 「혼자 만든 팀을 버리고 싶다」였고 해체가 그것을 풉니다. 필요해지면 그때 붙입니다.

⚠️ 덤으로 하나 더 고쳤습니다(37519c4) — 해체·나가기 뒤 팀이 목록에 그대로 남았습니다. router.refresh() 하나로는 모자랐고(개발 모드에서 Next 가 라우트 핸들러와 서버 컴포넌트를 다른 모듈 그래프로 묶습니다), 화면이 결과를 직접 반영하게 했습니다.

확인: www 전체 vitest 954 passed · tsc 통과. 시험 9건이 이 흐름을 붙듭니다(해체 5 · 목록에서 빠짐 4).

🗂 회신·요청 기록 (2026-09-16 ~ 09-17) — 무엇을 왜 그렇게 정했는지. 펼쳐 봅니다

✅ 회신 (2026-09-17, 정어진) — 둘 다 냈습니다. 해체는 행을 지우지 않습니다

DELETE /teams/{team_id}                       // 해체 (주장만) → 204
PATCH  /teams/{team_id}/members/{member_id}   // 주장 세우기 (주장만)
       { "role": "owner" }

둘 다 낸 이유: 혼자인 팀은 세울 상대가 없어 해체여야 하고, 여럿인 팀의 주장이 나가려면 세우기여야 합니다. 그리고 지금 LAST_OWNER 안내가 “다른 주장을 먼저 세워야 합니다” 라고 말하는데 세울 경로가 없어 실행 불가능한 안내였습니다 — 그것도 함께 풀었습니다.

🔴 「해체」를 행 삭제가 아니라 표시로 했습니다

말씀하신 예시는 DELETE /teams/{id} 였고 경로는 그대로지만, 행을 지우지는 않습니다(team.disbanded_at 을 찍습니다). 근거 셋입니다.

  1. team 을 참조하는 외래키가 아홉이고 그중 다섯이 NO ACTION 입니다 (match 둘 · squad · team_match_request 둘 · team_member). 부록 D.6 이 팀 삭제 연쇄를 정하지 않았습니다 — squad.team_id 가 RESTRICT 인 이유가 그것이고, 문서에 없는 규칙을 스키마로 만들지 않습니다
  2. 저장소의 기존 판단이 정반대입니다 — team_member 는 “탈퇴 후에도 경기·평가 이력이 남아야 하므로” left_at 소프트 삭제입니다(부록 D.6). 팀도 같습니다
  3. 지운 팀의 지난 경기·평가가 팀 이름을 가리킵니다

「하지 말 것」의 목적은 지킵니다: 해체하면 남은 구성원이 전부 나가고 (그래서 GET /me 의 teams 에서 사라집니다), 대기 중이던 초대·경기 신청은 cancelled 로 닫힙니다. 스쿼드·지난 경기는 이력이라 남습니다.

🔴 앞으로 있을 경기가 있으면 409 로 막았습니다

TEAM_HAS_UPCOMING_MATCH. 두 선택지(409 또는 상대에게 알림) 중 409 를 골랐습니다 — 경기 탐색(GET /matches)이 다가오는 경기만 보므로, 이 규칙 하나로 「없는 팀의 경기가 탐색에 뜬다」가 구조적으로 안 생깁니다. 지난 경기는 세지 않습니다.

화면이 알아야 할 것
  • GET /teams/{id} 는 해체 뒤에도 200 입니다(disbanded_at 이 차고 members 가 빈 배열). 🔴 404 로 가정하지 마십시오 — 경기 이력에서 팀 이름을 읽으려면 그래야 합니다
  • 해체된 팀은 가입·초대·수정이 409 TEAM_DISBANDED 입니다
  • 🔴 기존 주장은 강등되지 않습니다. 「넘기고 나가기」는 세우고 나가는 두 번의 호출입니다
  • 쿠키 우회(www/src/lib/homeTeam.ts)는 그대로 둬도 됩니다. 해체된 팀을 가리켜도 서버가 그 값을 안 믿어 안 깨집니다 — 다만 해체 직후 비우면 한 번 헛도는 것이 줄어듭니다

  • 계약: client-contract-changes.md 54번 · 규격: api-contract.md 3-3절
  • 마이그레이션 f1c83b0d59a4(team.disbanded_at, nullable)

DELETE /teams/{id}/members/{member_id} 가 마지막 주장을 409 LAST_OWNER 로 막습니다. 계약이 그 자리에 이렇게 적어 두었습니다:

🔴 마지막 주장이 나가면 아무도 남을 넣을 수 없는 팀이 된다. 소유권 이양 API 가 아직 없어 되돌릴 방법이 없으므로 미리 막는다. 팀 해체도 같은 이유로 아직 없다 — 필요해지면 이양과 함께 낸다.

필요해졌습니다. 사용자가 「마지막 주장이어도 그냥 아예 새로 만들고 싶을 수 있다」고 했고, 지금은 혼자 만든 팀을 영영 못 버립니다.

만족해야 할 성질

혼자인 팀을 정리할 수 있을 것. 방법은 둘 중 아무거나 좋습니다 — 어느 쪽이 스키마에 자연스러운지는 그쪽이 판단해 주세요.

  1. 팀 해체(DELETE /teams/{id}) — 구성원이 나뿐일 때만, 또는 주장이면 언제나
  2. 소유권 이양(PATCH /teams/{id}/members/{id} 의 role) — 넘기고 나감

🔴 하지 말 것

  • 스쿼드·경기를 남기지 마십시오. 팀이 사라지면 그 팀의 squad·match· team_match_request 가 함께 정리돼야 합니다 — 남으면 「없는 팀의 경기」가 경기 탐색에 뜹니다. 부록 D.6 의 삭제 연쇄와 같은 판단입니다.
  • 확정된 경기가 있는 팀을 조용히 지우지 마십시오. 상대 팀에는 약속이라, 거절할 근거(409)가 되거나 상대에게 알림이 가야 합니다.

지금은 어떻게 해 뒀나

팀을 하나 더 만들 수 있게 열어 두었습니다(2026-09-16). 나갈 수는 없지만 새 팀을 시작할 수는 있습니다. 홈은 한 팀만 그리므로 「홈에 보일 팀」을 고르는 자리를 프로필에 함께 두었습니다 — 그 선택은 계약에 자리가 없어 이 브라우저의 쿠키입니다(www/src/lib/homeTeam.ts). 🔴 서버는 그 값을 믿지 않습니다 — 내 소속이 아니면 무시하고 첫 팀으로 떨어집니다.

  • 이 항목이 닫히면: 쿠키 우회는 남겨도 되지만(주 소속 개념이 여전히 없음), 버려진 팀이 목록에 쌓이는 문제는 사라집니다.
  • 확인: grep -n 'LAST_OWNER' fastapi/app/ · 해체·이양 경로가 계약에 생겼는가

36. 사람이 직접 적는 호칭을 담을 칸을 주세요 — 32번의 방향이 뒤집혔습니다 (2026-09-16 신설) ✅ 해소 (2026.09.16)

  • 위치: pending-archive.markdown의 ## paik 구역으로 이동됨

37. 스쿼드 합류 요청 — 초대 경로·알림은 왔고 초대에 실릴 값이 남았습니다 (2026-09-16 신설) ✅ 해소 (2026.09.17)

  • 담당: 정어진(초대에 실을 값) ✅ 냈습니다 (2026.09.17, 아래) · 백성검(화면 배선) ✅ 붙였습니다 (2026.09.17, 아래) · 제기: 백성검 · 기한: 스프린트 3

✅ 받은 쪽 화면을 붙였습니다 (2026.09.17, 백성검)

받은 초대가 머리줄 알림 판에 뜨고, 그 자리에서 부르는 팀의 판을 보고 수락·거절합니다. 백엔드는 한 줄도 안 건드렸습니다 — 경로가 전부 와 있었습니다.

  • 새 접점 하나: GET /api/squads/[slug]. www 에 공개 슬러그로 남의 스쿼드를 읽는 길이 통째로 없었습니다(게이트웨이·BFF·mock 전부). 🔴 withAuth 를 안 씁니다 — 계약이 인증 없이 연 경로이고(SEC-005), 초대받은 사람은 아직 그 팀 소속이 아니라 소속을 요구하면 판을 못 봅니다
  • 🔴 초대는 teamId 밖에서 읽습니다. useNotifyInbox 는 role==='owner' 인 내 팀을 먼저 찾고 그 밑에서 읽는 구조라, 초대를 거기 얹으면 팀 없는 사람에게 초대가 안 뜹니다 — 초대를 받는 사람은 대개 아직 팀이 없습니다. 시험 하나를 「내 팀이 없어도 초대는 읽는다」로 박아 뒀습니다
  • 🔴 정정 — 판은 SquadPanel 이 아니라 MiniPitch 입니다. 이 항목과 계약 53번이 둘 다 「SquadPanel 재사용」이라고 적어 뒀지만, MiniPitch 머리말이 이미 그 판단을 뒤집어 놓았습니다 — SquadPanel 은 끌어 옮기기· 추천 판·등재·서버 저장까지 든 1,649줄이라 쓰려면 그 전부를 꺼야 하고, 끌 것이 많다는 것 자체가 재사용하면 안 된다는 뜻입니다. 읽기 전용 판이 MiniPitch(102줄)이고 둘은 CSS 와 격자 규칙을 나눠 씁니다
  • 계약의 「하지 말 것」 셋을 시험으로 잠갔습니다: position_* 가 null 인 것은 실패가 아님 · squad_public_slug 가 null 도 정상 · 약칭으로 자리 이름을 지어내지 않음
  • ⚠️ mock 에 씨앗 초대가 없어 로컬에서 볼 수가 없었습니다. 데모 계정이 제 팀 주장이라 mock 이 만들 수 있는 초대는 전부 내가 보내는 것뿐이었고, 받은 초대는 0건이었습니다. 다른 팀(망원 유나이티드)과 그 팀 판·씨앗 초대를 넣었습니다. listMyInvitations 가 팀 이름을 전부 번개FC 로 박아 두던 것도 함께 고쳤습니다(어느 팀이 불렀든 내 팀 이름이 찍혔습니다)

⛔ 팀↔팀 경기 신청에는 판을 안 붙입니다 (2026.09.17, 사용자 판단). 「신청이 왔을 때도 상대 팀 판을 봐야 하지 않나」가 나왔고 맞는 말이지만, squad_public_slug 가 초대 응답에만 실려서 값이 없습니다 — 경기 신청 줄에 있는 것은 두 팀의 id·이름·지역뿐이고, GET /teams/{id}/squad 는 소속이어야 읽힙니다. 🔴 하려면 두 칸을 요청해야 합니다 (requester_squad_public_slug·target_squad_public_slug — CCC 53 이 초대에 해 준 것과 같은 모양). 요청은 안 올렸습니다. 필요해지면 화면은 이번 것을 그대로 씁니다 — 경로도 InviteSquad 도 이미 있어 남는 일은 줄 하나입니다.

🔴 화면 다듬기는 사용자가 넷을 잡아 줬습니다 — 판 크기가 화면을 넘음 · 불러오는 중 표시가 줄에 끼어 「복사된 것처럼」 보임 · mock 씨앗 판의 DF 가 5:5 에 없는 칸(7:7 자리) · 격자가 왼쪽으로 몰림. 넷 다 눈으로 안 재고 계산만 믿어서 났습니다. 이제 판을 만지면 헤드리스 크롬으로 그려 봅니다. 자세한 것은 www/docs/2026-08-28-작업-현황.md 1.14 「이번에 데인 것」.

확인: npx vitest run · npx tsc --noEmit · eslint 기준선과 동일(손댄 파일 0건)

✅ 회신 (2026-09-17, 정어진) — 넷 다 실었습니다. 둘은 정정입니다

GET /me/invitations 한 줄에 감싸지 않고 덧붙였습니다 — 지금 row.team_id 를 읽는 코드는 그대로 둬도 됩니다.

{
  // 지금까지 주던 칸 그대로
  "id": "...", "team_id": "...", "invited_user_id": "...",
  "status": "pending", "created_at": "...", "responded_at": null,
  // 늘어난 칸
  "position_code": "GK", "position_label": "골키퍼",
  "team_name": "번개FC", "team_region": "서울 강남",
  "team_sport_code": "football", "squad_public_slug": "sq-abc123"
}

초대를 보낼 때 자리를 함께 정합니다 — POST /teams/{id}/invitations 에 position_code 를 선택으로 받습니다(그 팀 종목에 없는 약칭이면 422 UNKNOWN_POSITION). 안 주면 자리를 안 정한 초대이고 position_* 가 null 입니다.

요청하신 칸 어떻게
팀 이름 · 지역 team_name · team_region (team_sport_code 도 같이)
경기 시각 · 구장 🔴 안 줍니다 — 아래 정정 2
부르는 자리 position_code · position_label. 🔴 약칭만 보고 이름을 지어내지 마십시오 — 종목마다 같은 약칭이 다른 뜻입니다(축구 FW ≠ 농구 FW)
스쿼드 공개 슬러그 squad_public_slug. 스쿼드를 아직 안 만든 팀이면 null 입니다(생성이 멱등이라 늦게 생깁니다) — 그때는 판 대신 「아직 판이 없습니다」로

🔴 정정 1 — 「받는 사람은 소속이 아니라 GET /teams/{id} 가 403」은 사실이 아닙니다. 그 경로에는 소속 검사가 없습니다(ReadTeamInteractor — “소속이 아니어도 볼 수 있다. 가입하려면 먼저 봐야 하고…”). 인증만 있으면 누구나 읽습니다. 즉 팀 이름은 그전에도 얻을 수 있었습니다 — 이번에 실어 준 것은 목록에서 줄마다 부르지 않아도 되게 하려는 것입니다. 그 전제로 자체 우회를 만드셨다면 걷어내셔도 됩니다.

🔴 정정 2 — 경기 시각·구장은 안 줍니다. 물어보신 「어느 쪽인지」의 답은 초대가 경기와 안 묶이는 쪽입니다. team_invitation 에 경기를 가리키는 칸이 없습니다 — 「우리 팀에 오세요」이지 「이 경기에 와 달라」가 아닙니다. 경기 쪽은 team_match_request(팀 대 팀)가 따로 담습니다. 그 두 칸은 빼고 팀·지역·자리·슬러그만 그리시면 됩니다.

  • 계약: client-contract-changes.md 53번 · 규격: api-contract.md 3-3절
  • 마이그레이션 e4a9c6d21b70(team_invitation.position_id, nullable)

정해진 흐름 (사용자 설계)

  1. 처음 판은 비어 있습니다. 내 카드도 자동으로 안 섭니다
  2. 빈 자리를 누르면 그 자리에 「나」 핀이 떠서, 누르면 내 카드가 섭니다
  3. 나머지는 AI 추천(대표 영상을 올린 사람들)이나 지인 찾기(닉네임으로 아는 사람)로 채웁니다 — 둘은 사람을 찾는 출처가 다를 뿐입니다
  4. 채우면 카드가 바로 서되 그 위에 「수락 대기중」(빨강)이 깜빡입니다
  5. 그 사람에게 알림이 갑니다
  6. 수락하면 「준비 완료」(초록)로 바뀌고 깜빡임이 멎습니다

1~4·6의 화면은 넣었습니다(SquadPanel). 판이 다 차고 모두 수락해야 「팀 매칭」이 켜지는 것도 같이 넣었습니다.

✅ 초대·수락 경로는 이미 왔습니다 (계약 49번, aadda50)

처음엔 「길이 없다」고 올렸는데 정어진 님이 그날 오후에 내셨습니다. min 20번의 team_invitation 이 그것이고, 화면이 쓸 것은 이만큼입니다.

경로 누가
POST /teams/{id}/invitations 주장이 초대를 보낸다
GET /me/invitations 내가 받은, 아직 답 안 한 초대
POST /me/invitations/{id}/accept 수락 — team_member 가 member 로 생긴다
POST /me/invitations/{id}/reject 거절

🔴 남은 것 — 초대에 실릴 값

🔴 정정 (2026-09-16 저녁): 앞서 여기에 「알림 타입이 없다」고 적었던 것은 틀렸습니다. 병합 전 기준이었고, 정어진 님이 초대 경로와 함께 내셨습니다:

team_invitation_sent / team_invitation_accepted / team_invitation_rejected

남은 것은 하나뿐입니다 — 초대 한 줄에 실린 값이 id 밖에 없습니다.

class TeamInvitationResponse(BaseModel):
    id, team_id, invited_user_id, status, created_at, responded_at

받은 사람 화면은 team_id(UUID)만 받습니다. 그것으로는

  • 팀 이름을 모릅니다. GET /teams/{id} 를 따로 부르면 되지만 그 사람은 아직 그 팀 소속이 아니라 403 입니다(소속만 읽을 수 있는 경로)
  • 경기 시각·구장·부르는 자리는 얻을 길이 아예 없습니다
  • 스쿼드를 볼 길이 없습니다 — 공개 슬러그를 모르니까요
만족해야 할 성질

GET /me/invitations 한 줄이 알림만 보고 판단할 수 있을 만큼은 실어야 합니다. 지금 팀↔팀 알림이 「망원 유나이티드 · 서울 마포구 · 토 09:00 · 망원 실내구장 A」를 보여주는 것과 같은 수준입니다.

칸 쓰는 곳
팀 이름 · 지역 알림 줄
경기 시각 · 구장 알림 줄
부르는 자리(포지션) 「GK 로 부릅니다」 — 무엇을 해 달라는 것인지
🔴 그 팀 스쿼드의 공개 슬러그 아래 「작은 판」

🔴 슬러그 한 칸이 이 설계를 통째로 싸게 만듭니다. 알림 옆에 작은 스쿼드 판을 두고, 누르면 왼쪽으로 크게 펼쳐 그 팀이 어떻게 짜여 있는지 보고 정합니다(사용자 설계) — 이미 있는 GET /squads/{public_slug}(누구나 읽음)로 그리면 되므로 새 경로도 새 화면 부품도 필요 없습니다(판은 SquadPanel 재사용).

슬러그가 없으면 그 사람은 어느 자리가 비었고 누가 이미 서 있는지 모른 채 수락 여부를 정하게 됩니다.

⚠️ 경기 시각·구장은 초대에 안 붙을 수도 있습니다 — 초대가 경기와 묶이지 않는 설계라면(그냥 「우리 팀에 오세요」) 그 두 칸은 빼고 주셔도 됩니다. 그때는 화면도 팀·지역·자리·슬러그만 그립니다. 어느 쪽인지만 알려 주세요.

⛔ 닫힌 경로 — (나)안을 만들었다가 되돌렸습니다 (2026.09.16, 백성검)

같은 문제를 두 층에서 풀 수 있었습니다.

  (가) 팀 초대 (나) 스쿼드가 팀 밖 사람도 받기
동의를 받는 곳 팀 가입 스쿼드 등재
수락하면 팀원이 됨 → 그 다음 등재 그 판에만 섬

사용자가 처음에 (나)로 정해 주셔서 백엔드를 다 만들었습니다: squad_member.accepted_at 한 칸(NULL=대기중) · 422 NOT_TEAM_MEMBER 삭제 · 수락/거절 엔드포인트 둘 · 알림 타입 셋 · 시험 21건.

그런데 git fetch 로 보니 정어진 님이 그날 오후에 정반대 근거로 (가)를 확정해 두었고(fbbeb4e), team_invitation 이 이미 들어가 있었습니다. 사용자께 알렸고 되돌렸습니다(커밋 전이라 흔적 없음 · pytest -q 671 passed 로 원상 확인).

🔴 다시 (나)로 가자는 이야기가 나오면 이 줄을 먼저 보십시오. 못 만들어서 접은 것이 아니라 방향이 갈려서 접은 것입니다. team_invitation 이 있는 한 (나)는 같은 일을 두 곳에서 하는 것이 됩니다.

🔴 그리고 이 사고의 원인은 「먼저 git fetch 안 한 것」입니다. 같은 날 아침 회차에 그것으로 반나절을 아꼈다고 적어 놓고, 오후에 같은 것을 안 해서 반나절을 썼습니다.

하지 말 것

  • 🔴 「수락 대기중」을 화면이 지어내게 두지 마십시오. ①②가 없으면 그 표시는 새로고침 한 번에 거짓이 됩니다 — 그래서 화면 주석에도 「아직 서버로 안 나간다」고 적어 두었습니다
  • 알림에 문구를 담아 보내지 마십시오 — 계약 3-12절이 「문구를 저장하지 않는다」로 정했고 화면이 조립합니다. 위 표는 문장이 아니라 값입니다

확인

grep -n "squad_invite\|team_invitation" fastapi/app/notification/domain/rules/notification_rules.py
  • 관련: min 20번(초대 경로를 낸 항목) · paik 27번(추천 판에 초대 단추) · 같은 구역 35번
  • 지금 화면 상태: 내 카드는 addSeat·removeSeat 로 서버에 남습니다 (나는 팀원이라 기존 규칙에 안 걸립니다). 추천·지인으로 앉힌 사람만 화면 안에서 삽니다

38. 추천 카드의 불릿(card.notes)을 남이 읽을 경로가 없습니다 (2026-09-17 신설) ✅ 해소 (2026.09.17, 같은 날)

에이전트가 이미 만들고 있습니다 — result.card.notes(미결 ho 50번, 봉투 schema_version 1.5). 그런데 추천 판이 그것을 읽을 길이 없습니다.

지금 주는 것 안 주는 것
GET /teams/{id}/squad/candidates → nickname · card_public_slug · grade · provisional notes
GET /cards/{slug} → titles · tagline · style notes

만족해야 할 성질

추천 후보 한 명에 대해, 그 사람 분석이 낸 불릿 한두 줄을 읽을 수 있을 것. 경로 모양은 맡기겠습니다 — 후보 응답에 실어 주셔도 되고, GET /cards/{slug} 에 한 칸 늘려 주셔도 됩니다(그쪽이 대표 영상·호칭과 같은 자리라 자연스럽습니다).

🔴 하지 말 것

  • 리포트를 통째로 열지 마십시오 — paik 25번에서 「남의 리포트는 안 연다」로 이미 선을 그었습니다. 필요한 것은 그 두 줄뿐입니다
  • 없는 사람에게 빈 배열 말고 다른 것을 주지 마십시오 — 분석을 한 번도 안 한 사람이 정상입니다. 화면은 그때 아무것도 안 그립니다

왜 급하지 않은가

그 자리를 비워 두었습니다. 2026-09-17에 거기 있던 붙박이 문구를 걷어냈고 (「1대1에서 잘 밀리지 않습니다」류 — 경기 행동이라 재는 것이 아무것도 없습니다, 정상호 확인), 지금은 이름·등급·본인이 적은 호칭만 그립니다. 틀린 것이 보이는 상태는 아닙니다.

  • 확인: grep -n 'notes' www/src/components/SquadSuggest.tsx → 화면이 쓰는 곳이 없으면 아직입니다. CSS(.ss-suggest-notes)는 그대로 두었습니다 — 경로가 생기면 같은 모양으로 그립니다
  • 관련: ho 50번(에이전트 몫 · 출처를 화면에서 가르라는 요구) · paik 25번 (남의 리포트는 안 연다) · 계약 44번(추천 판의 범위) ✅ 같은 날 답이 왔습니다 (2026.09.17) — 정어진이 계약 56번으로 냈습니다 (fc3c559). 올린 지 얼마 안 돼 닫혔습니다.

  • 🔴 후보 목록에 이미 실려 옵니다 — GET /teams/{id}/squad/candidates 의 각 줄에 notes 가 붙습니다. 후보마다 /grade 를 다시 부를 필요가 없습니다
  • 슬러그만 아는 자리를 위해 GET /cards/{slug}/grade 에도 같이 실립니다
  • 🔴 한 줄·null 둘 다 정상입니다 — 두 줄을 채우려고 지어내지 않는 것이 에이전트 쪽 규칙이고, null 은 옛 봉투(1.4 이하)거나 분석 전입니다
  • 남은 것은 화면 배선(제 몫)입니다 — FLAVOR 붙박이는 오늘 이미 걷었으니 그 자리에 서버 값을 넣으면 됩니다

  • 담당: 정어진 ✅ 냈습니다 (2026.09.17, 계약 56번) → 남은 것은 백성검(화면 배선) · 제기: 백성검 · 기한: 급하지 않음 (지금 깨지는 것은 없습니다)

39. 지인 목록·닉네임 검색에 card_public_slug 를 실어 주세요 (2026-09-17 신설) ✅ 해소 (2026.09.17, 같은 날)

스쿼드 판이 앉은 사람의 진짜 카드를 그리게 됐습니다(오늘 붙였습니다). 등재된 팀원과 AI 추천으로 앉힌 사람은 카드가 뜨는데, 지인 찾기로 앉힌 사람만 이름표로 남습니다 — 그 사람의 카드를 찾아갈 값이 없어서입니다.

🔴 사용자 요청입니다 — 「저기서 선택하면 스쿼드판에 그 사람 카드는 당연히 똑같이 떠야지」. 지금은 같은 판 안에서 어디서 앉혔느냐에 따라 카드가 뜨기도 하고 안 뜨기도 합니다.

만족해야 할 성질

지인 목록과 닉네임 검색 결과로 그 사람의 공개 카드를 찾아갈 수 있을 것.

지금 주는 것 필요한 것
GET /me/contacts → contact_id · user_id · nickname · note card_public_slug
GET /users/search?q= → id · nickname card_public_slug

추천 후보(/squad/candidates)가 이미 같은 칸을 주고 있으니 그것과 같은 모양이면 됩니다 — 카드를 안 만든 사람은 null 이고, 그때는 지금처럼 이름표로 남습니다(화면이 이미 그 갈래를 그립니다).

🔴 하지 말 것

  • 내부 user_id 로 카드를 찾게 하지 마십시오 — 「내부 id 를 밖에 내보내지 않는다」가 카드·스쿼드가 지켜 온 원칙입니다. 슬러그를 직접 주시는 편이 맞습니다
  • 카드가 없는 사람을 목록에서 빼지 마십시오 — 지인은 지인입니다

딸린 질문 하나 (급하지 않음)

지인을 판에 앉히면 지금은 화면 안에서만 삽니다 — 계약이 「팀 구성원의 카드만 등재할 수 있다」로 그어 두었기 때문입니다. 팀 초대(계약 49·53)로 팀원이 된 뒤에야 등재가 되는 흐름이 맞습니까? 맞다면 화면이 그렇게 안내하겠습니다.

  • 확인: grep -n 'card_public_slug' fastapi/docs/api-contract.md 에서 지인·검색 절에 그 칸이 보이면 된 것입니다
  • 관련: 계약 37번(지인 검색·상호 신청) · 계약 56번(추천 카드 불릿 — 같은 자리에 슬러그가 이미 있습니다) · paik 37번(팀 초대) ✅ 제가 직접 고쳤습니다 (2026.09.17) — 정어진 승인. 계약 57번으로 적었습니다.

  • GET /me/contacts · GET /users/search 둘 다 card_public_slug 를 실어 보냅니다. 추천 후보가 주던 것과 같은 모양이고, 카드를 안 만든 사람은 null(칸이 빠지는 것이 아니라) 입니다
  • 🔴 마이그레이션 없습니다 — 응답 필드만 늘었습니다. player_card 는 기존 관례대로 원시 쿼리로 읽어 user 가 card 를 임포트하지 않습니다 (has_card 와 같은 방식, 경계 검사가 막는 자리)
  • 🔴 사람마다 따로 읽지 않습니다 — 한 번에 읽습니다(검색은 최대 20명이라 줄마다 읽으면 스무 번 나갑니다)
  • 화면도 같이 붙였습니다 — 지인으로 앉혀도 수락 전에 바로 카드가 뜹니다 (수락 여부는 카드 위 「수락 대기중」이 말합니다)
  • 확인: .venv/bin/pytest -q 766 passed(새 시험 3) · alembic heads 하나 · vitest run 804 passed(새 시험 1) · tsc 0건 · next build 통과

딸린 질문은 아직 열려 있습니다

지인은 팀원이 아니라 등재가 안 됩니다(계약이 「팀 구성원의 카드만 등재」로 그었습니다) — 그래서 지금 그 자리는 새로고침하면 사라집니다. 팀 초대 (계약 49·53)로 팀원이 된 뒤에야 등재되는 흐름이 맞습니까?

  • 담당: 정어진 ✅ 백성검이 직접 (2026.09.17, 승인받음) → 남은 것은 위 딸린 질문(정어진) · 제기: 백성검 · 기한: 급하지 않음

40. 리뷰를 저장할 길이 없습니다 — 팀↔팀 경기 참가자가 리뷰 참가자가 아닙니다 (2026-09-17 신설)

  • 담당: 정어진(참가자 판정·평가 대상 식별) · 박민호(선택형만 둘지) · 제기: 백성검 · 기한: 스프린트 3 이후 (급하지 않습니다 — 화면은 나와 있습니다)

경기가 끝나면 함께 뛴 사람에게 리뷰를 남기는 판을 붙였습니다(사용자 요청). 화면은 다 되어 있는데 저장만 못 합니다. 두 가지가 막습니다.

만족해야 할 성질

   
1 팀↔팀으로 잡힌 경기의 양 팀 사람이 서로 리뷰할 수 있을 것. 지금 is_confirmed_participant 는 match_application(용병이 경기에 지원 → 팀·본인 둘 다 수락)만 봅니다. 팀↔팀 수락(accept_team_match_request)은 match 만 만들고 그 행을 안 만들어서 422 NOT_A_PARTICIPANT 입니다
2 평가 대상을 가리킬 값이 화면에 있을 것. POST /matches/{id}/reviews 는 reviewee_id(사용자 id)를 받는데, 스쿼드 응답은 nickname·card_public_slug 만 줍니다 — 넣을 값이 없습니다

확인

grep -n 'match_application' fastapi/app/review/adapter/outbound/pg/review_pg_repository.py
grep -n 'user_id' fastapi/app/card/adapter/inbound/api/schemas/squad_schema.py

첫째가 걸리고 둘째가 안 걸리면 아직 그대로입니다.

🔴 하지 말 것

  • 스쿼드 응답에 user_id 를 통째로 싣지 마십시오 — 내부 id 를 밖에 내보내지 않는 것이 카드·스쿼드의 원칙입니다(슬러그를 쓰는 이유). 리뷰가 쓸 수 있는 다른 열쇠(슬러그로 받는다든지)여도 됩니다.
  • 팀↔팀 참가자를 「그 팀 소속 전원」으로 넓히지 마십시오 — 경기에 안 온 사람까지 평가 대상이 됩니다. 실제로 뛴 사람을 가릴 근거가 따로 필요합니다.

지금은 어떻게 해 뒀나

www/src/components/MatchReview.tsx 의 submit() 이 저장을 흉내만 냅니다 (사용자 판단: 「하드코딩으로만 되게, 나중에 바꾸면 되니까」). 🔴 위 둘이 열리면 그 함수 한 곳만 바꾸면 됩니다 — 고른 값은 이미 계약이 받는 모양(option_codes)으로 들고 있습니다.

41. 리뷰를 자유 글로도 쓸 수 있어야 합니까 — 지금은 선택형 아홉뿐입니다 (2026-09-17 신설)

  • 담당: 박민호(제품 판단) · 정어진(칸 신설 여부) · 제기: 백성검 · 기한: 40번과 함께

사용자가 물었습니다 — 「왜 자유방식으로 리뷰 못 달아? 9개 선택지 누가 하자고 한 거임?」

계약 3-9절이 선택형으로 못 박아 두었고 근거도 적혀 있습니다: 「review 에 총점·별점이 없다. 고른 것이 review_selection 에 행으로 남는다 — 나쁜 평가 하나가 줄 수 있는 피해에 상한을 두기 위해서다」(2026-09-04, 정어진). 문구 아홉은 마이그레이션 20260903_review_trust_tables.py 가 시드했습니다.

🔴 자유 글은 담을 칸이 아예 없습니다 — review 에 텍스트 컬럼이 없어 넣으려면 마이그레이션이 필요합니다.

정해 주실 것

   
(가) 지금대로 선택형만 계약의 근거를 그대로 받습니다. 화면은 이미 이렇게 되어 있습니다
(나) 선택형 + 자유 글 review 에 텍스트 칸 하나. ⚠️ 분쟁·신고 소지가 커집니다 — 계약이 「제재는 평가와 분리한다」고 따로 그어 둔 것과 같이 봐야 합니다
(다) 별점까지 계약이 명시적으로 뺀 것이라 근거를 뒤집는 결정입니다

답을 안 주시면 (가)로 둡니다.

42. 스쿼드가 없는 팀도 경기를 걸고 받을 수 있습니다 (2026-09-17 신설)

  • 담당: 정어진(신청 경로의 검사) · 박민호(어디까지 막을지) · 제기: 백성검 · 기한: 스프린트 3

사용자 지적입니다 — 「스쿼드를 안 만든 팀이면 아예 경기가 안 되어야 하는 게 맞지 않아?」 맞습니다. 그런데 지금은 안 막습니다.

🔴 후보 목록만 거르고 신청은 통과합니다. GET /teams/{id}/match-candidates 는 「판 크기가 같고 · 로스터가 찼고 · 조건을 등록한 팀만」으로 하드 필터를 겁니다(계약 3-13절). 그런데 POST /teams/{id}/match-requests 를 처리하는 team_match_request_interactors.py 는 팀 존재 · 주장 여부 · 지난 시각만 보고 스쿼드를 아예 안 봅니다.

그래서 판이 없는 팀도 걸 수 있고, 받은 쪽도 수락할 수 있습니다. 대기 화면에 빈 판이 뜨는 것이 그 증상입니다.

정해 주실 것 — 어디까지 막을지

  막는 것 비고
(가) 양쪽 중 스쿼드가 없는 팀 이견이 없을 것 같습니다
(나) 판 크기가 다른 팀끼리 후보 목록은 이미 이 기준입니다
(다) 인원이 안 찬 팀 ⚠️ 경기는 미래인데 신청 시점에 다 차야 하는지가 갈립니다 — 막는다면 수락 시점이 더 맞아 보입니다

제 의견은 (가)+(나)입니다.

🔴 하지 말 것

  • 화면에서 미리 막지 마십시오 — 상대 팀 스쿼드가 찼는지는 서버만 압니다. 화면이 짐작해서 막으면 서버와 다른 답이 나옵니다(계약이 후보 목록에서 이미 같은 판단을 했습니다).

확인

grep -n 'squad' fastapi/app/match/application/use_cases/team_match_request_interactors.py

아무것도 안 걸리면 그대로입니다.

43. GET /positions 에 id 를 실었습니다 — 포지션을 등록할 방법이 없었습니다 ✅ 해소 (2026.09.18)

  • 담당: 정어진(검토 · 배포) ✅ 둘 다 끝 (2026.09.18, 아래 회신) · 제기: 백성검 · 기한: 완료

남의 구역(fastapi/)을 직접 고쳤습니다 — 사용자 승인을 받았습니다. 계약이 바뀌는 변경이라 여기 남깁니다.

무엇이 막혀 있었나. PUT /me/match-preferences 는 포지션을 position_ids(UUID) 로 받는데, 그 UUID 를 내주는 경로가 없었습니다. GET /positions 는 {sport_code, code, label} 만 줬고, 계약 문서에는 position_ids 라는 말 자체가 없었습니다(grep 0건). 약칭으로는 못 보냅니다 — 종목 안에서만 유일해서 C 하나로는 포수인지 센터인지 안 가려집니다.

그래서 아무도 member_match_position 에 등록된 적이 없었고, 그것이 GET /teams/{id}/squad/candidates 의 첫 하드 필터라 AI 추천 판이 늘 0명이었습니다. 사용자가 실서버에서 그것을 보고 물어서 찾았습니다.

무엇을 했나. PositionResponse 에 id 를 더했습니다(엔티티 → DTO → pg/스텁 저장소까지). 지역이 GET /regions 로 id 를 받는 것과 같은 결이라 모양을 맞췄습니다. 계약 문서 3-3·3-13절도 함께 고쳤습니다 — 3-13절에는 /me/match-preferences 의 본문 모양이 아예 없어서 새로 적었습니다.

  • 스텁 저장소의 id 는 uuid5 로 지은 가짜입니다(실물 DB 값이 아닙니다). test_sport_position_db.py 가 실물 행과 id 까지 대조해서 그 가짜가 새어 나가는 것을 잡습니다.
  • 화면 쪽(www)은 이미 배선을 마쳤습니다 — 「사람을 찾는 팀」의 「내 자리」가 PUT /me/match-preferences 로 올라갑니다(전에는 localStorage 였습니다).

🔴 배포가 필요합니다. 백엔드가 배포되기 전까지 실서버 GET /positions 는 id 없이 오고, 그러면 화면이 포지션을 하나도 못 올립니다(약칭→id 가 안 풀리면 조용히 빈 값으로 저장하지 않고 「고른 자리를 서버 목록에서 찾지 못했습니다」로 막습니다 — 조용히 등록 안 되는 쪽이 더 나쁘다는 판단입니다).

확인 — 실서버에 반영됐는지:

curl -s "https://supersub-ai.com/api/positions?sport_code=football" -b <로그인 쿠키> | head -c 200

"id" 가 보이면 반영된 것입니다. 로컬에서는:

cd fastapi && .venv/bin/pytest -q tests/user/adapter/test_positions_router.py

하지 말 것: 스텁의 _fake_id() 값을 실서버에 보내지 마십시오 — 422 UNKNOWN_POSITION 입니다.

✅ 회신 (2026-09-18, 정어진) — 검토 통과 · 배포는 이미 되어 있었습니다

🔴 항목의 전제 하나를 정정합니다. 「지금 실서버는 아직 옛 응답입니다」는 더 이상 맞지 않습니다 — 이 항목이 main 에 들어가면서 이미지가 다시 빌드·배포됐고, 운영 파드는 새 코드로 돌고 있습니다. 따로 배포할 것이 없었습니다.

본 것 결과
운영 파드 안의 코드 position_schema.py 에 id: UUID 있음
main CI 백엔드 테스트 초록 (Merge branch 'jin' into main, 6분 39초)
로컬 전체 pytest -q 1085 passed

검토 결과 — 그대로 두면 됩니다. 따로 볼 만했던 것 셋:

  1. PositionEntity 를 만드는 자리가 둘뿐입니다(pg 저장소 · 스텁). 둘 다 고쳐져 있어서, 위치인자 순서가 바뀐 것(id 가 맨 앞)에 걸릴 제3의 자리가 없습니다. 확인: grep -rn 'PositionEntity(' fastapi/ --include=*.py → 2건
  2. PositionResponse 를 쓰는 라우트도 GET /positions 하나뿐이라 다른 응답이 같이 바뀌지 않았습니다
  3. test_sport_position_db.py 가 id 까지 실물 행과 대조하는 것 — 스텁의 가짜 id 가 새어 나가는 것을 잡는 자리가 거기 하나라 특히 잘 두셨습니다

진단도 확인했습니다. member_match_position 이 GET /teams/{id}/squad/candidates 의 후보 출처가 맞습니다(list_squad_candidates 의 주석이 그렇게 말하고, 후보를 팀 밖에서 그 표로 찾습니다). 아무도 등록한 적이 없었으니 추천 판이 늘 0명인 것이 설명됩니다.

다만 제 쪽에서 못 끝낸 것이 하나 있습니다. 항목의 「확인」 명령은 브라우저 로그인 쿠키가 필요해서, 같은 것을 운영 파드 안에서 토큰을 직접 발급해 호출하려다 제 도구의 권한 검사(Production Reads)에 막혔습니다. 그래서 응답 본문까지는 눈으로 못 봤고 파드 안 코드로 확인했습니다. 화면에서 「내 자리」가 실제로 저장되는 것을 한 번 보시면 그것이 제일 확실한 확인입니다.

44. 초대에 칸(grid_col·grid_row)을 실어 주세요 — 좌·우 자리가 수락까지 못 갑니다 (2026-09-18 신설)

  • 담당: 정어진(초대에 두 칸) · 제기: 백성검 · 기한: 급하지 않음 (임시 우회가 있습니다)

POST /teams/{id}/invitations 가 받는 것이 {invited_user_id, position_code} 까지라, 같은 포지션 자리가 둘인 판(5:5 의 MF 좌·우, 7:7 의 DF 셋)에서 팀장이 고른 좌·우가 서버 어디에도 안 남습니다.

사용자가 실제 도메인에서 잡았습니다(2026-09-18): 「지인 초대로 오른쪽 미드필더에 넣었는데, 새로고침하니까 왼쪽 미드필더로 자리를 옮겼고」. 판을 되살릴 때 아는 것이 MF 하나뿐이라 늘 첫 빈 자리로 앉기 때문입니다.

만족해야 할 성질

  • 초대를 보낼 때 실은 칸이 수락 시점까지 살아 있고, 수락으로 만들어지는 등재(squad_member, 계약 60)가 그 칸으로 생깁니다
  • 칸을 안 실은 초대는 지금과 같습니다 — 등재는 칸 null, 화면이 포지션으로 앉힙니다

확인

grep -n "grid_col" fastapi/docs/api-contract.md | grep -i invitation

하지 말 것

  • 🔴 칸을 필수로 만들지 마십시오 — 「우리 팀에 오세요」(자리 안 정한 초대)가 정상 경로입니다. 지금도 position_code 가 선택인 것과 같은 이유입니다
  • 🔴 position_code 를 칸에서 역산하지 마십시오 — 팀장이 이름표를 눌러 포지션을 따로 정할 수 있어서, 행이 정한 포지션과 다를 수 있습니다

그때까지의 우회 (이미 해 뒀습니다)

www 가 초대 id → 칸을 브라우저에 임시로 적어 두고(lib/inviteSeats.ts), 수락이 감지되면 PATCH …/squad/members/{id} 로 등재에 옮겨 적은 뒤 지웁니다 (64b73a6). ⚠️ 다른 기기·다른 브라우저에서 초대하면 안 살아납니다 — 그때는 전처럼 왼쪽으로 앉습니다. 두 칸이 오면 그 파일을 통째로 걷습니다.

45. 공개 문서에 IAM 역할 이름이 있었습니다 — 자리표시자로 바꿨습니다 ✅ 해소 (2026.09.18)

www/docs/2026-08-28-작업-현황.md 의 1.15 절에 EC2 역할 이름이 값 그대로 적혀 있었습니다(카드 사진이 S3 에 안 올라가던 원인을 적으면서 들어갔습니다). CLAUDE.md 의 「공개 사이트에 인프라 식별자를 쓰지 않습니다」 표가 IAM 역할·사용자·정책 이름을 자리표시자로 쓰라고 정한 항목입니다.

<EC2 역할> 로 바꿨습니다. 뜻은 안 죽습니다 — 그 줄의 요점은 이름이 아니라 「영상용 역할이라 cards/ 접두사를 안 열어 줬다」 이고, 교훈(새 접두사를 쓰면 IAM 을 같이 본다)은 그대로 남아 있습니다.

🔴 다만 git 히스토리와 GitHub 캐시에는 그대로 남습니다(커밋 eea9b2f3). 회수가 안 되는 종류라 알려 둡니다. 역할 이름 자체는 자격 증명이 아니라 교체할 것은 없다고 봤습니다 — 그 판단이 틀렸다고 보시면 말씀해 주세요.

⚠️ 같은 절에 videos/*·reports/*·cards/* 라는 S3 접두사는 남겼습니다. 버킷 이름이 없으면 그 자체로는 아무 데도 닿지 않아서입니다.

  • 확인: grep -rnE 'supersub-video' www/ 가 0건
  • 담당: 백성검 ✅ 고쳤습니다 (2026.09.18) · 제기: 백성검 · 기한: 완료

46. DELETE /me/card 를 만들었습니다 — 카드 「초기화」가 카드를 지웁니다 · 배포 부탁 (2026-09-19 신설)

사용자 요청으로 카드 편집기의 「초기화」가 카드를 안 만든 처음 상태로 돌아가야 해서, 서버에 카드를 지우는 길을 백성검이 직접 냈습니다(사용자 승인). 계약이 바뀌므로 알려 드립니다 — 규격은 fastapi/docs/api-contract.md 3장의 DELETE /api/v1/me/card, 반영 목록은 client-contract-changes.md 63번.

  • 204, 원래 없었어도 204(멱등). 공유 링크 404 · 스쿼드 자리는 외래키 CASCADE 로 같이 빠짐 · 호칭은 사람에 붙어 남음
  • 마이그레이션 없음(alembic check 변경 없음 · head 하나). 공유 파일 5곳 손 안 댐
  • 포트(CardPort.delete_by_owner) · 인터랙터 · 프로바이더 · pg/스텁 저장소 · 라우터. 시험: 계약 6 · DB 2 · 유스케이스 2. 전체 pytest 1138 passed / skipped 0 (pgvector 컨테이너에 CI 와 같은 순서로 올려 돌림)
  • ⚠️ 포트 파일에 create_for_owner 가, DTO 파일에 CreateMyCardCommand 가 이미 두 번씩 선언돼 있었습니다 — 동작엔 지장이 없어 손대지 않았습니다

봐 주실 것: 규격·설계(특히 「없어도 204」와 사진 S3 객체를 안 지우는 것)가 괜찮은지, 그리고 배포. 배포 전에는 실서버에서 「초기화」가 404 를 받고, 화면은 예전 동작(꾸밈만 기본값)으로 물러나며 그렇다고 알립니다 — 깨지지는 않습니다.

  • 확인: curl -s -o /dev/null -w '%{http_code}' -X DELETE https://<API 호스트>/api/v1/me/card -H "Authorization: Bearer <토큰>" 가 204
  • 담당: 정어진(검토 · 배포) · 제기: 백성검 · 기한: 급하지 않음(화면이 물러나는 길이 있음)

47. 스쿼드 등재의 accepted_at 이 계약 문서에 없습니다 — 문서 보강 부탁 (2026-09-21 신설)

  • 담당: 정어진(문서) · 제기: 백성검 · 기한: 급하지 않음 (지금 깨지는 것은 없습니다 — 값은 실제로 오고 있습니다)

GET /teams/{id}/squad 의 members[] 에 accepted_at 이 실려 오는데 계약 문서 3-7절의 응답 예시·필드 설명에는 그 칸이 없습니다. 계약 60(2026-09-17, 「수락하면 스쿼드에 앉는다」)으로 들어온 값인데 3-7절 반영이 덜 된 것으로 보입니다.

코드를 고쳐 달라는 요청이 아닙니다 — 문서에 한 줄 추가해 주시면 됩니다.

지금 웹(SquadPanel.tsx)과 앱(flutter/lib/features/team/data/models/squad.dart)이 둘 다 이 값을 읽고 있고, 없을 때의 처리도 같게 맞춰 두었습니다:

오는 모양 뜻
칸이 아예 없음 수락된 것으로 본다 — 옛 응답에는 그 칸이 없었고, 그 시절엔 팀원만 앉을 수 있어 앉은 것이 곧 온 것이었습니다
null 로 와 있음 대기중 — 판에서 흐리게 그립니다
시각이 참 수락됨

「확인」: grep -n 'accepted_at' fastapi/docs/api-contract.md 가 3-7절에서도 걸리면 해소입니다(지금은 3-3·3-12절에서만 걸립니다).

🔴 하지 말 것: 이 값을 응답에서 빼는 것. 빼면 대기중인 사람과 수락한 사람을 화면이 못 가릅니다.

48. 카드 mode: 'full' 이 카드 폭 전체를 안 덮습니다 — 주석과 CSS 가 어긋납니다 (2026-09-21 신설) ✅ 해소 (2026.09.22)

  • 제기: 백성검 · 기한: 완료

버그였습니다 — 양쪽 다 고쳤습니다. 「눈으로 보고 정할 일」이라 열어 뒀던 것인데, 앱에 카드 사진 올리기를 붙인 뒤 사용자가 실기기에서 잡아 줬습니다: 「사진을 키우거나 좌우·위아래로 옮겨도 카드 전체로 안 가고 정해진 작은 사각형 안에서만 움직인다」. 그 사각형이 바로 이 68% 띠입니다.

  • 웹 globals.css 의 .ss-pcard[data-photo='full'] .ss-pcard-figure 에 inset-inline: 0 을 더했습니다
  • 앱 player_card_view.dart 의 사진 칸도 full 일 때 좌우 0 으로 했습니다
  • cutout 은 좌우 16% 그대로 둡니다 — 일부러 좁습니다(글자가 위에 앉을 자리). 같이 밀면 사진이 별명·머리글을 덮습니다

확인: 시험을 양쪽에 달았습니다 — 웹 globals.test.ts 의 「사진을 통째로 까는 모드는 카드 전체를 덮는다」(20개 통과), 앱 player_card_style_test.dart 의 「full 은 카드 전체를 덮는다」. 앱 쪽은 고친 줄을 되돌려 left 가 60.8px(= 380의 16%)로 빨개지는 것까지 봤습니다.

49. GET /videos/{id}/poster 를 만들었습니다 — 홈 영상 줄의 썸네일 (2026-09-24 신설)

  • 담당: 정어진(검토 · 배포 확인) · 제기: 백성검 · 기한: 급하지 않음 (지금 깨지는 것은 없습니다)

직접 만들었습니다 — 사용자가 「누가 하든 상관없다, 우리가 할 수 있으면 하라」고 정해서입니다(1.13·1.14 와 같은 방식). 나중에 「이게 왜 있지」 하지 않으시도록 남깁니다.

무엇을 왜: 공개 목록(GET /videos/public)이 썸네일을 안 실어서, 앱이 카드마다 원본 MP4 를 열어 첫 프레임을 뽑고 있었습니다 — 실기기에서 한 장에 1.9초였고 홈의 영상 줄 다섯 장이 한참 까맸습니다. 작은 JPEG 한 장으로 바꿨습니다.

  • GET /api/v1/videos/{id}/poster → image/jpeg. 권한은 playback-url 과 같습니다
  • ffmpeg 에 사전 서명 GET 주소를 그대로 물립니다 — 서버가 원본을 안 받고, 🔴 새 S3 권한도 필요 없습니다(cards/ 때 겪은 IAM 벽을 피하려고 그렇게 했습니다)
  • fastapi/Dockerfile 에 ffmpeg 이 늘었습니다(이미지 ~100MB 증가)
  • 상세는 fastapi/docs/api-contract.md 의 그 절

봐 주셨으면 하는 것 셋:

  1. 🔴 캐시가 컨테이너 안(/tmp)이라 재배포하면 빕니다. DB 는 마이그레이션 체인이 공유 파일이라, S3 는 IAM 이 막혀서 피한 결과입니다. 영구 캐시로 옮기는 편이 낫다고 보시면 그쪽이 맞습니다 — 그때는 thumbs/* IAM 한 줄이 필요합니다
  2. 공유 파일 한 곳을 건드렸습니다 — tests/user/adapter/test_auth_router.py 의 경로 허용 목록에 한 줄. 경로를 늘리면 그 시험이 반드시 깨지는 구조라, 2026-09-18 photo-upload-url 때와 같은 방식으로 주석을 달아 더했습니다
  3. 업로드 때 -movflags +faststart 를 걸면 재생 여는 것도 빨라질 수 있습니다. 안 건드렸습니다 — 업로드 경로라 영향 범위가 다릅니다

확인:

cd fastapi && .venv/bin/pytest -q        # 830 passed

50. 서버가 이따금 30초를 멈췄습니다 — 커넥션 풀이 비어서입니다 · 배포 부탁 (2026-09-25 신설)

  • 담당: 정어진(검토 · 배포) · 제기: 백성검 · 기한: 스프린트 3 (사용자에게 그대로 보입니다)

증상: 앱 홈에서 영상 줄이 통째로 안 나왔습니다. 앞서 같은 자리에서 Cloudflare error code: 522(원본 응답 없음) 도 받았습니다.

실측(2026-09-25):

GET /videos/public -> 응답 없음 (30.00초, curl 28)
GET /regions       -> 401 (0.42초)        # 같은 순간
GET /videos/public -> 401 (3.86초)        # 잠시 뒤
GET /videos/public -> 401 (0.38초)
GET /videos        -> 401 (9.12초)

같은 경로가 0.4초 ~ 30초로 널뜁니다 — 죽은 것이 아니라 줄을 서 있습니다. 그리고 30초는 SQLAlchemy 의 기본 pool_timeout 과 정확히 같습니다.

원인: 요청당 세션 하나라 커넥션이 요청 내내 잡혀 있는데, GET /videos/{id}/poster 는 ffmpeg 이 원격 주소를 읽느라 최대 20초를 씁니다 (위 49번). 홈이 카드를 다섯 장 한 번에 부르고 웹·앱이 겹치면 기본 풀(5+10)이 금세 비고, 그때부터 상관없는 요청들이 커넥션을 기다립니다. 재배포하면 포스터 캐시가 비므로(49번의 1) 배포 직후마다 이 일이 납니다.

고친 것 둘 (직접 고쳤습니다 — 사용자가 「근본적인 서버가 답 안 하는 걸 잡으라」고 정했습니다):

  1. app/core/database.py — 풀을 명시했습니다: pool_size=10 · max_overflow=20 · pool_timeout=5 · pool_recycle=1800. 🔴 빨리 실패하는 쪽이 낫습니다 — 30초를 기다리면 클라이언트는 「서버가 죽었나」로 읽고, 그 사이 요청이 워커를 쥐고 있어 줄이 더 길어집니다
  2. VideoPort.release() 를 더하고, 포스터 인터랙터가 ffmpeg 을 부르기 직전에 부릅니다 — 느린 일 앞에서 커넥션을 풀에 돌려줍니다. Session.close() 라 세션은 그대로 쓸 수 있습니다

판단해 주셨으면 하는 것:

  • 🔴 pool_size + max_overflow 가 프로세스마다 열리는 상한입니다. 워커 수와 Postgres max_connections 를 아시는 쪽에서 값을 확정해 주십시오 — 제가 잡은 10/20 은 「기본값보다 넉넉하되 안전한 쪽」으로 고른 값입니다
  • 포스터 캐시를 영구 저장소로 옮기면 이 문제의 뿌리가 사라집니다(49번의 1). 재배포마다 캐시가 비는 한, 배포 직후에는 다시 줄이 섭니다

앱 쪽도 같이 고쳤습니다(flutter/): 실패를 감추지 않게 했습니다. 전에는 못 받으면 빈 자리만 그려서 「그냥 안 나온다」로 보였습니다 — 이제 「영상을 불러오지 못했습니다 · 눌러서 다시」가 뜹니다. 그리고 ApiClient 에 20초 제한 시간을 뒀습니다(전에는 없어서 서버가 멎으면 앱이 영원히 기다렸습니다).

확인:

cd fastapi && .venv/bin/pytest -q        # 833 passed
grep -n "pool_timeout" app/core/database.py
grep -n "release()" app/analysis/application/use_cases/video_interactors.py

배포 뒤에는 위 실측을 다시 돌려 주십시오 — /videos/public 이 30초로 멎는 일이 없어야 합니다.

회신 (2026-09-29, 정어진) — 값은 이대로 확정합니다 · 운영 실측은 아직입니다

  • 값: pool_size=10 · max_overflow=20 · pool_timeout=5 · pool_recycle=1800 그대로 확정합니다. 저도 09-23 에 운영 측정을 근거로 로컬에서 15 + 넘침 5 를 만들어 뒀는데 버립니다 — 차이가 작고, 이쪽은 30초 멎음을 5초 실패로 바꾸는 것까지 담았습니다
  • 상한: API 는 파드 하나(replicas: 1) · strategy: Recreate(배포 중 두 파드가 겹치지 않음) · uvicorn 프로세스 하나라 API 가 여는 연결은 최대 30 입니다. 쇼케이스 자동 수락 파드는 API 를 HTTP 로 부르므로 DB 를 직접 쓰지 않습니다. Postgres 기본 max_connections 100 안입니다 — 서버의 실제 값은 이번에 보지 못했습니다
  • 확인: main 을 들인 jin 에서 pytest -q 1162 passed · pool_timeout · release() 모두 있습니다
  • 배포: 이 수정이 든 3a576c2 의 이미지 빌드는 09-25 에 성공했습니다. 운영 파드가 새 이미지로 도는지 (engine_or_none().pool.size() 가 10)와 /videos/public 실측은 아직 못 봤습니다 — 확인하면 여기에 적고 닫겠습니다
  • 포스터 캐시 영구화는 49번에서 따로 봅니다

51. 목록 응답에 칸이 빠져 화면이 한 건씩 다시 묻습니다 — 네 곳 (2026-09-25 신설)

  • 담당: 정어진 · 제기: 백성검 · 기한: 급하지 않음 (지금 깨지는 것은 없습니다)

넷 다 한 줄씩 더 실어 주시면 되는 것이고, 성격이 같아 한 항목으로 묶습니다. 🔴 하나씩 닫으셔도 됩니다 — 줄 끝에 ✅ 를 달아 주십시오.

  1. GET /me/contacts 에 card_public_slug 가 없습니다. 지인 목록에서 그 사람의 선수 카드를 그릴 수가 없어 이름만 보여 주고 있습니다. 웹은 이 칸을 읽는 코드가 이미 있는데 서버가 안 줍니다 (계약 3-12절의 그 예시 JSON)
  2. GET /me/contacts/requests 에 신청자 닉네임이 없습니다. requester_user_id 만 와서 「누가 신청했는지」를 못 씁니다 — 화면이 사람마다 GET /users/{id} 를 또 부르거나, 지금처럼 id 를 그냥 감춥니다
  3. GET /users/search 가 q 없이는 못 부릅니다(Query(min_length=1)). 「지인 찾기」에서 아무것도 안 친 상태의 첫 화면을 채울 방법이 없어 빈 판으로 시작합니다. 최근 가입자든 같은 지역이든 기본 목록이 있으면 좋겠습니다
  4. GET /matches 에 my_application 이 없습니다. 「사람을 찾는 팀」에서 지원한 뒤 다시 열면 지원한 것을 잊습니다(2026-09-25 에 넣은 지원 단추, 커밋 ca3e57c2). 경기마다 GET …/applications 를 부르면 목록 하나에 왕복이 스무 번이라 그 길로 가지 않았습니다 — 한 줄에 {"id", "confirmed"} 만 있으면 화면이 들고 있는 상태를 걷어낼 수 있습니다

확인:

grep -n "card_public_slug" fastapi/docs/api-contract.md   # 3-12절 contacts 에 있는가
grep -n "min_length=1" fastapi/app/user/adapter/inbound/api/v1/user_search_router.py

⚠️ 알림 읽음 상태는 여기 안 넣었습니다 — GET /me/notifications 가 read_at 을 이미 주고 있습니다. 앱이 안 쓰는 것이라 서버 일이 아닙니다.