feat(clinical): 정렬 판정을 **잰 위치 전부**로 — 0~4cm 다 재고 판단

알고리즘 설계자가 물었다 — "0cm·1cm 만 쟀는데 0cm 가 최적으로 나왔다. 3cm 가 최적이면?"
앞 커밋은 전수 측정만 하고 판정은 단일 pass 로 뒀는데, 원래 의도는 **다 재고 판단**이었다.

## 무엇이 바뀌고 무엇이 그대로인가

    측정        종료 조건에서 중단      →  0~4cm 무조건 전부
    후보        종료까지의 위치         →  잰 위치 전부          ← 바뀜
    선택 규칙   자격→nch→cap→낮은 cm   →  그대로
    부착        best + 1cm 단순 덧셈   →  그대로
    종료 조건   판정을 끝냄            →  진단용 (decided_at_cm)

선택 규칙 `AnchorSelection.selectBest` 는 Python `select_supine_anchor` 와 1:1 이고 200
케이스로 대조돼 있다 — **손대지 않았다.** 바뀐 것은 후보 집합뿐이고, Python 의 회고 분석
경로가 이미 전 위치로 판정하므로 같은 방식으로 재현·대조할 수 있다. 다만 **최종 보고서의
"단일 pass" 기술은 갱신이 필요하다.**

## 왜

단일 pass 는 지표가 단봉이라고 가정해 나빠지는 순간 멈춘다. 장내 가스·접촉 불량·호흡으로
**한 위치만** 일시적으로 나빠져도 멈추므로 더 위를 놓친다. 실측에 징후가 있었다 —
2026-09-09 `kai` 는 0cm ch3=X → 1cm ch3=O 91% 로 지표가 **올라갔다.** 같은 날 `181128` 은
1cm 에서 nch 2→0, ch3 100%→0% 로 best=0cm 으로 끝나 2cm 이후를 몰랐다.

## 화면 단계를 화면이 직접 몰고 간다

종전에는 `AnchorGuide` 의 STOP(`last.done`)이 확정 신호였다. 이제 guide 의 단일 pass 답과
앱의 부착 위치가 다를 수 있어 그걸 쓸 수 없다. `confirmTarget`/`confirmDone` 으로 바꿨다:

    수집 중          → ↑ 1cm 올리고 측정 (판정보다 우선)
    수집 끝          → ↓/↑ N cm 로 이동해 확인 측정
    확인 측정 완료    → ↑ 1cm 올리세요 (초록) → 좌우
    후보 없음        → ↓ 더 아래에 다시 붙이세요

우선순위를 테스트로 고정했다 — 확정이 수집·재부착보다 먼저, 수집이 판정보다 먼저.
뒤바뀌면 4cm 까지 못 가거나 끝난 단계를 되돌린다.

## 감사 가능하게 둘 다 기록

    "selection_scope": "full_sweep"
    "best_cm": 3                    앱이 쓴 값 = 전 위치 판정
    "best_cm_single_pass": 0        옛 방식이 냈을 답
    "single_pass_disagrees": true
    "decided_at_cm": 1              종료 조건이 걸린 위치 (이제 진단값)

갈리면 화면에도 참고 문구가 뜬다. 이 빈도가 곧 "단봉 가정이 얼마나 깨지는가"의 지표다.

## 같이 정리

AnchorBasis 에 FULL_SWEEP 을 만들려다 **만들지 않았다** — 전 위치 판정이 이제 앱의 기본
규칙이므로 ALGORITHM 이 그 뜻이다. 항목을 늘리면 정확도 검증 모집단을 가르는 기준이
흐려진다.

테스트 143개 통과(AlignInstructionTest 13개 전면 재작성).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 12:21:59 +09:00
parent b71a04374f
commit c01da3512e
3 changed files with 276 additions and 193 deletions
+56 -56
View File
@@ -1,6 +1,6 @@
# 병원 임상 측정 — 알고리즘 상세
> 대상: `demo-final` 브랜치, 2026-09-10 (`af16541`) 기준.
> 대상: `demo-final` 브랜치, 2026-09-10 (`2e5a1f1` 이후) 기준.
> 이 문서는 **코드를 읽고 쓴 것**이고, 각 항목에 파일·라인을 달았습니다. 값이 의심되면
> 해당 파일을 보는 것이 이 문서보다 우선입니다.
@@ -345,6 +345,9 @@ eligible = ch3Mean && ch3Rate >= 0.80
`AnchorSelection.selectBest()` (`AnchorGuide.kt:174`)
> **후보는 잰 위치 전부입니다** (2026-09-10 변경). 종전에는 "종료 조건이 걸릴 때까지"의
> 위치만 후보였습니다. 자세히는 [3.6](#36-실제-진행-순서).
```
① 후보 자격 ch3 == "O" AND ch3_rate ≥ 0.8
→ 통과가 0개면 → REATTACH (재부착)
@@ -369,81 +372,78 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 1
**오프셋 위치는 측정하지 않습니다.** 설계상 그 자리는 지표가 나쁜 쪽이라, 재서 문턱으로
쓰면 "미달"이 떠서 도달 전에 멈춥니다.
### 3.6 실제 진행 순서 (2026-09-10 변경)
병원 임상은 위치를 고르는 것만이 목적이 아니라 **위치별 원신호를 모으는 것**도 목적입니다.
그래서 종료 조건이 걸려도 **4cm 까지 전부 잽니다.**
### 3.6 실제 진행 순서
```
0cm 측정 → ↑ 1cm 올리고 측정
1cm 측정 → 종료 조건 걸림 (판정 완료, best 확정)
↑ 1cm 올리고 측정
"부착 위치는 이미 정해졌습니다(1cm 에서 판정).
4cm 까지는 데이터 수집용으로 잽니다 — 여기서 멈춰도 됩니다."
2·3·4cm 측정 (수집)
→ ↓ N cm 내리고 측정 (부착 위치로)
→ ● 여기서 한 번 더 측정 ← 확정 판정의 입력
→ ↑ 1cm 올리세요 (초록 · 여기서는 측정하지 않음)
→ 좌우 정렬
1cm 측정 → ↑ 1cm 올리고 측정 (판정이 나빠져도 멈추지 않습니다)
2·3·4cm 측정
↓
잰 위치 전부로 판정 → best = N
↓
↓/↑ N cm 로 이동 → ● 여기서 한 번 더 측정 (확인 측정 = 확정 신호)
↓
↑ 1cm 올리세요 (초록 · 여기서는 측정하지 않음)
↓
좌우 정렬
```
#### ⚠ 판정은 수집 때문에 바뀌지 않습니다
[여기까지만 재고 판정하기] 로 수집을 중단할 수 있습니다 — 그 시점까지의 위치로 판정합니다.
`selectBest` 는 **넘긴 레코드 전부**를 후보로 봅니다. 후보가 늘면 답이 뒤집힐 수 있습니다:
#### 판정 범위가 바뀌었습니다 (2026-09-10)
```
0cm eligible nch=2 · 1cm ch3 소실 · 2cm eligible nch=4
조기 종료 후보 {0,1} → best 0cm
전부 넣으면 → best 2cm ← 다른 답
```
| | 종전 | 현재 |
|---|---|---|
| 측정 | 종료 조건 걸리면 중단 | **0~4cm 무조건 전부** |
| 후보 | 종료까지의 위치 | **잰 위치 전부** |
| 선택 규칙 | 3단 규칙 | **그대로** |
| 부착 | best + 1cm 단순 덧셈 | **그대로** |
| 종료 조건 | 판정을 끝냄 | **진단용** (`decided_at_cm`) |
안전한 이유는 `AnchorGuide` 가 `searching` 이 꺾이는 **그 순간 한 번만** `bestCm` 을
계산하고, 이후 `step()` 은 `recs` 에만 쌓기 때문입니다. `AnchorSweepTest` 가 이 성질을
고정합니다.
**왜 바꿨나.** 단일 pass 는 지표가 cm 에 대해 **단봉(unimodal)** 이라고 가정해, 나빠지는
순간 봉우리를 지난 것으로 보고 멈춥니다. 그런데 장내 가스·접촉 불량·호흡으로 **한 위치만**
일시적으로 나빠져도 멈추므로, 더 위가 진짜 좋았는데 놓칠 수 있습니다(알고리즘 팀 질의).
#### 단일 pass 가 가정하는 것 — 그리고 그 가정을 측정하는 방법
실측에 징후가 있었습니다. 2026-09-09 `kai` 는 **0cm `ch3=X` → 1cm `ch3=O 91%`** 로 지표가
**올라갔습니다.** 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀 있지만,
**중간에서는 한 칸만 나빠도 끝났습니다.** 같은 날 `181128` 이 정확히 그 경우로, 1cm 에서
`nch 2→0`·`ch3 100%→0%` 가 되어 `best=0cm` 으로 끝났고 2cm 이후는 알 수 없었습니다.
단일 pass 는 **지표가 cm 에 대해 단봉(unimodal)** 이라고 가정합니다. 올라가다 나빠지면
봉우리를 지난 것으로 보고 멈춥니다. Python 도 같습니다 — *"수직 → 좌우 순서로 한 번만
가고 되돌아가지 않는다."*
**레퍼런스는 어떻게 되나.** 선택 규칙 `selectBest` 는 Python `select_supine_anchor` 와
1:1 이고 200 케이스로 대조돼 있습니다(`AnchorSelectionParityTest`) — **손대지 않았습니다.**
바뀐 것은 후보 집합뿐이고, **Python 의 회고 분석 경로가 이미 전 위치로 판정**하므로 같은
방식으로 재현·대조할 수 있습니다. 다만 최종 보고서의 "단일 pass" 기술은 갱신이 필요합니다.
물리적 근거는 있습니다. CH3 은 제일 기울어진 채널로 **방광 아래쪽**을 보므로, 위로
올라가면 점점 방광을 벗어납니다.
**깨질 수 있는 경우**는 *한 위치만* 일시적으로 나빠지는 것입니다 — 장내 가스가 CH3 을
가림, 그 위치에서 접촉이 떴음, 호흡으로 방광이 움직임. 그러면 더 위가 진짜 좋았는데
조기 종료합니다.
실측에 징후가 있습니다. 2026-09-09 `kai` 세션은 **0cm `ch3=X` → 1cm `ch3=O 91%`** 로
지표가 **올라갔습니다.** 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀
있지만, **중간에서는 한 칸만 나빠도 끝납니다.**
전수 측정이 이 질문에 답을 냅니다. 세션마다 두 값을 같이 기록합니다:
**감사 가능하게 둘 다 기록합니다:**
```json
"best_cm": 0, // 앱이 쓰는 값 (단일 pass · 레퍼런스)
"best_cm_full_sweep": 3, // 잰 위치 전부로 고르면
"sweep_disagrees": true // 갈렸다
"selection_scope": "full_sweep",
"best_cm": 3, // 앱이 쓴 값 = 전 위치 판정
"anchor_cm": 4, // best + 1
"best_cm_single_pass": 0, // 옛 단일 pass 가 냈을 답
"single_pass_disagrees": true,
"decided_at_cm": 1, // 종료 조건이 걸린 위치 (진단용)
"swept_to_cm": 4
```
갈리면 화면에도 참고 문구가 뜹니다. **판정은 바꾸지 않습니다** — 레퍼런스 대조가 단일
pass 기준이라 규칙을 바꾸면 검증을 다시 해야 합니다. 몇십 세션의 `sweep_disagrees` 빈도를
보고 규칙을 바꿀지는 알고리즘 팀이 정합니다.
갈리면 화면에도 참고 문구가 뜹니다:
> 참고 — 중간에 지표가 나빠진 적이 있습니다. 거기서 멈추는 옛 방식이면 **0cm** 이 뽑혔을
> 자리입니다. 지금은 잰 위치 전부로 골라 **3cm** 입니다. 기록에 둘 다 남습니다.
`single_pass_disagrees` 빈도가 곧 **"단봉 가정이 얼마나 깨지는가"** 의 지표입니다.
#### 확인 측정은 판정 데이터를 덮지 않습니다
부착 위치로 내려가 한 번 더 재는 측정은 `align_2cm_confirm.csv` 로 따로 저장합니다.
같은 이름에 쓰면 **판정에 쓰인 데이터가 확인 측정으로 덮여**, 파일로 재판정하면 앱과
답이 갈릴 수 있습니다. 전수 측정으로 바뀐 뒤 이 재측정이 **항상** 일어나므로 중요합니다.
판정된 자리로 내려가 다시 재는 측정은 `align_2cm_confirm.csv` 로 따로 저장합니다. 같은
이름에 쓰면 **판정에 쓰인 데이터가 확인 측정으로 덮여**, 파일로 재판정하면 앱과 답이
갈릴 수 있습니다. 전수 측정으로 바뀐 뒤 이 재측정이 **항상** 일어나므로 중요합니다.
**기록에 경계를 남깁니다** — 안 남기면 재분석하는 쪽이 전 위치를 후보로 넣어 다시 판정하고,
다른 best 가 나와 앱이 틀린 것으로 읽힙니다:
#### 기록에 후보 경계를 남깁니다
```json
"decided_at_cm": 1,
"swept_to_cm": 4,
"positions": [{ "align_cm": 2, "in_decision": false, ... }]
```
`decided_at_cm` 은 이제 **판정 경계가 아니라 진단값**입니다 — 종료 조건이 어디서 걸렸는지
(= 지표가 어디서 무너졌는지)를 말합니다. `positions[].in_decision` 도 같은 기준이라,
**현재 판정은 `in_decision` 과 무관하게 전 위치를 씁니다.**
### 3.7 좌우 정렬 — 부착 높이에서