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
@@ -119,8 +119,16 @@ fun AnchorAlignView(appState: AppState) {
* 그 경계를 남겨야 나중에 Python 으로 다시 판정할 때 같은 답이 나온다. * 그 경계를 남겨야 나중에 Python 으로 다시 판정할 때 같은 답이 나온다.
*/ */
var decidedAtCm by remember { mutableStateOf<Int?>(null) } var decidedAtCm by remember { mutableStateOf<Int?>(null) }
/** 수집 단계를 사람이 중단했는가. 중단하면 바로 부착 위치 안내로 넘어간다. */ /** 수집 단계를 사람이 중단했는가. 중단하면 바로 그 시점까지로 판정한다. */
var sweepStopped by remember { mutableStateOf(false) } var sweepStopped by remember { mutableStateOf(false) }
/**
* 부착 위치에서 **확인 측정까지 끝냈는가.** 이게 true 가 되면 좌우 단계로 넘어간다.
*
* 종전에는 `AnchorGuide` 의 STOP(`last.done`)이 이 역할을 했다. 이제 판정을 전 위치로
* 하므로 guide 의 STOP 과 앱의 부착 위치가 다를 수 있어(guide 는 단일 pass 답을 들고
* 있다) 화면이 직접 들고 있어야 한다.
*/
var confirmDone by remember { mutableStateOf(false) }
/** /**
* 방금 받은 6채널 원본. 아래쪽 파형이 이걸 그린다. * 방금 받은 6채널 원본. 아래쪽 파형이 이걸 그린다.
* *
@@ -188,7 +196,27 @@ fun AnchorAlignView(appState: AppState) {
} }
val saveName = appState.hospitalPatient.ifBlank { fallbackName } val saveName = appState.hospitalPatient.ifBlank { fallbackName }
val done = last?.done == true // ── 판정: **잰 위치 전부**로 고른다 ────────────────────────────────────
// 2026-09-10 결정. 종전에는 AnchorGuide 의 단일 pass 답(종료 시점까지의 후보)을 썼다.
// 단일 pass 는 지표가 cm 에 대해 단봉이라고 가정해 나빠지는 순간 멈추는데, 장내
// 가스·접촉 불량·호흡으로 **한 위치만** 일시적으로 나빠져도 멈춘다 — 더 위가 진짜
// 좋았는데 놓친다(알고리즘 팀 질의). 실측에도 징후가 있었다: 2026-09-09 `kai` 는
// 0cm ch3=X → 1cm ch3=O 91% 로 지표가 **올라갔다.**
//
// **선택 규칙은 그대로다.** [AnchorSelection.selectBest] 는 Python
// `select_supine_anchor` 와 1:1 이고 200 케이스로 대조돼 있다(AnchorSelectionParityTest).
// 바뀌는 것은 **후보 집합**뿐이고, Python 의 회고 분석 경로가 이미 전 위치로 판정하므로
// 같은 방식으로 재현·대조할 수 있다.
//
// 종료 조건(붕괴·ch3 소실·상한)은 **진단용**으로 남는다 — 지표가 어디서 무너졌는지는
// 여전히 알아야 한다. `decided_at_cm` 으로 기록에 남는다.
val selectable = records.filter { it.capFrac.isFinite() }
val finalBest = AnchorSelection.selectBest(selectable).bestCm
/** 단일 pass 가 냈을 답. **비교·감사용**이고 판정에는 쓰지 않는다. */
val singlePassBest = last?.bestCm
/** 잰 위치가 하나도 후보가 못 되면 재부착이다. */
val needReattach = records.isNotEmpty() && finalBest == null
val anchorTarget = finalBest?.let { it + AnchorConfig.FINAL_OFFSET_CM }
// ── 연결이 끊기면 기기에서 온 값을 비운다 ─────────────────────────────── // ── 연결이 끊기면 기기에서 온 값을 비운다 ───────────────────────────────
// 병원 모드와 같은 이유다 — 끊긴 프로브의 BV·파형·접촉이 새 프로브의 것으로 읽힌다. // 병원 모드와 같은 이유다 — 끊긴 프로브의 BV·파형·접촉이 새 프로브의 것으로 읽힌다.
@@ -346,22 +374,19 @@ fun AnchorAlignView(appState: AppState) {
decidedAtCm = decidedAtCm), decidedAtCm = decidedAtCm),
) )
// ── 다음 위치 ────────────────────────────────────────────────── // ── 다음 위치 ──────────────────────────────────────────────────
// **수집 단계에서는 판정과 무관하게 1cm 씩 올린다.** 병원 임상은 위치를 // **0~SEARCH_MAX_CM 를 무조건 다 잰다.** guide 의 안내(MOVE_UP/MOVE_DOWN)는
// 고르는 것만이 목적이 아니라 위치별 원신호를 모으는 것도 목적이다 // 여기서 보지 않는다 — 판정을 전 위치로 하므로 중간 안내가 의미가 없다.
// (2026-09-10 결정). 조기 종료하면 2~3 위치만 남아 재분석할 거리가 없다. // 다 재고 나면 판정된 위치로 내려가 확인 측정 한 번, 그 뒤 +1cm 에서 좌우.
//
// **판정은 이것 때문에 바뀌지 않는다.** AnchorGuide 는 `searching` 이 false
// 로 넘어가는 순간 bestCm 을 한 번만 계산하고, 그 뒤 step() 은 recs 에만
// 쌓는다 — 후보 집합이 늘어도 best 를 다시 고르지 않는다. 그 성질이 깨지면
// 전부 측정한 세션의 답이 레퍼런스와 달라지므로 `AnchorSweepTest` 가 고정한다.
val swept = guide.records.maxOfOrNull { it.cm } ?: cm val swept = guide.records.maxOfOrNull { it.cm } ?: cm
if (!sweepStopped && swept < AnchorConfig.SEARCH_MAX_CM) { if (!sweepStopped && swept < AnchorConfig.SEARCH_MAX_CM) {
cm = swept + 1 cm = swept + 1
} else { } else {
when (step.action) { // 수집 끝 — 전 위치로 판정한 자리로 간다.
AnchorAction.MOVE_UP -> step.bestCm?.let { cm = it } ?: run { cm += 1 } val target = AnchorSelection.selectBest(
AnchorAction.MOVE_DOWN -> step.bestCm?.let { cm = it } guide.records.filter { it.capFrac.isFinite() }).bestCm
else -> Unit if (target != null) {
// 방금 잰 자리가 곧 판정된 자리면 그 측정이 확인 측정이다.
if (cm == target) confirmDone = true else cm = target
} }
} }
} finally { } finally {
@@ -579,24 +604,11 @@ fun AnchorAlignView(appState: AppState) {
// 붙여야 하는지 매번 헷갈렸다(2026-09-09 현장 피드백). 실제로 필요한 것은 // 붙여야 하는지 매번 헷갈렸다(2026-09-09 현장 피드백). 실제로 필요한 것은
// **지금 할 동작 하나**다 — 1cm 올려서 재라, 2cm 내려서 재라, 1cm 올리고 // **지금 할 동작 하나**다 — 1cm 올려서 재라, 2cm 내려서 재라, 1cm 올리고
// 좌우로 넘어가라. 절대 위치는 기록의 몫이고 사람이 셀 일이 아니다. // 좌우로 넘어가라. 절대 위치는 기록의 몫이고 사람이 셀 일이 아니다.
// ── 전수 판정과 비교 ──────────────────────────────────────────── // ── 단일 pass 와 비교 (감사용) ──────────────────────────────────
// 단일 pass 는 **지표가 cm 에 대해 단봉**이라고 가정한다(Python 도 같다). // 앱이 쓰는 값은 전수 판정이다. 단일 pass 가 냈을 답을 같이 보여 주면
// 올라가다 나빠지면 봉우리를 지난 것으로 보고 멈춘다. // "단봉 가정이 얼마나 깨지는가"를 현장에서도 세어 볼 수 있다.
// val singlePassDisagrees = finalBest != null && singlePassBest != null &&
// 그 가정이 깨지는 경우가 있다 — 장내 가스·접촉 불량·호흡으로 **한 위치만** finalBest != singlePassBest
// 일시적으로 나빠지면 조기 종료하고, 더 위가 진짜 좋았는데 놓친다.
// 알고리즘 설계자가 제기한 우려이고(2026-09-10), 실측에도 징후가 있다:
// 2026-09-09 `kai` 는 0cm ch3=X → 1cm ch3=O 91% 로 **올라갔다**.
//
// **판정은 바꾸지 않는다.** 레퍼런스 대조가 단일 pass 기준이라, 규칙을 바꾸면
// 검증을 다시 해야 한다. 대신 전 위치로 고르면 어디가 뽑히는지 **같이 계산해
// 보여 주고 기록한다** — 몇십 세션 쌓이면 "실제로 갈리는가"에 답이 나온다.
// 규칙을 바꿀지는 그 숫자를 보고 알고리즘 팀이 정한다.
val fullSweepBest = remember(records) {
AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm
}
val sweepDisagrees = last?.bestCm != null && fullSweepBest != null &&
fullSweepBest != last?.bestCm
// 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지는 계속 올린다. // 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지는 계속 올린다.
val sweptTo = records.maxOfOrNull { it.cm } ?: -1 val sweptTo = records.maxOfOrNull { it.cm } ?: -1
@@ -604,6 +616,9 @@ fun AnchorAlignView(appState: AppState) {
val instr = instructionOf( val instr = instructionOf(
last, cm, AnchorConfig.FINAL_OFFSET_CM, last, cm, AnchorConfig.FINAL_OFFSET_CM,
sweeping = sweeping, decidedAtCm = decidedAtCm, sweeping = sweeping, decidedAtCm = decidedAtCm,
confirmTarget = if (sweeping) null else finalBest,
confirmDone = confirmDone,
needReattach = needReattach && !sweeping,
) )
Box( Box(
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
@@ -657,12 +672,11 @@ fun AnchorAlignView(appState: AppState) {
// 전수 판정이 다르면 사람에게 알린다. 앱은 단일 pass 답을 쓰지만, 그 // 전수 판정이 다르면 사람에게 알린다. 앱은 단일 pass 답을 쓰지만, 그
// 자리에서 "더 위가 좋아 보인다"를 알면 다시 정렬을 택할 수 있다. // 자리에서 "더 위가 좋아 보인다"를 알면 다시 정렬을 택할 수 있다.
if (sweepDisagrees) { if (singlePassDisagrees && !sweeping) {
Text( Text(
"참고 — 잰 위치 전부로 고르면 ${fullSweepBest}cm 이 뽑힙니다 " + "참고 — 중간에 지표가 나빠진 적이 있습니다. 거기서 멈추는 옛 방식이면 " +
"(앱 판정은 ${last?.bestCm}cm). 앱은 검증된 단일 pass 규칙을 " + "${singlePassBest}cm 이 뽑혔을 자리입니다. 지금은 잰 위치 전부로 " +
"그대로 씁니다. 이 차이가 계속 보이면 알고리즘 팀에 알려 주세요 — " + "골라 ${finalBest}cm 입니다. 기록에 둘 다 남습니다.",
"기록에도 남습니다.",
fontSize = 12.sp, color = MlPrimary, fontSize = 12.sp, color = MlPrimary,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
.background(MlPrimary.copy(alpha = 0.08f), RoundedCornerShape(8.dp)) .background(MlPrimary.copy(alpha = 0.08f), RoundedCornerShape(8.dp))
@@ -692,7 +706,7 @@ fun AnchorAlignView(appState: AppState) {
// (2026-09-10 피드백). 지시(카드) 바로 아래 누를 것을 둔다. // (2026-09-10 피드백). 지시(카드) 바로 아래 누를 것을 둔다.
// //
// 아래쪽은 **근거**다 — BV·접촉·파형은 "왜 이 값인가"를 볼 때만 내려다본다. // 아래쪽은 **근거**다 — BV·접촉·파형은 "왜 이 값인가"를 볼 때만 내려다본다.
if (!done) { if (!confirmDone) {
Button( Button(
onClick = { measuring = true }, onClick = { measuring = true },
enabled = isConnected && !measuring, enabled = isConnected && !measuring,
@@ -733,7 +747,7 @@ fun AnchorAlignView(appState: AppState) {
// ── 2단계: 좌우 정렬 ─────────────────────────────────────── // ── 2단계: 좌우 정렬 ───────────────────────────────────────
// 상하가 정해진 자리에서 프로브를 좌우로만 움직여 CH4·CH5 를 맞춘다. // 상하가 정해진 자리에서 프로브를 좌우로만 움직여 CH4·CH5 를 맞춘다.
LateralSection( LateralSection(
anchorCm = last?.anchorCm, anchorCm = anchorTarget,
running = lrRunning, running = lrRunning,
commit = lrCommit, commit = lrCommit,
accumulated = lrAccum, accumulated = lrAccum,
@@ -785,10 +799,15 @@ fun AnchorAlignView(appState: AppState) {
// 대신 **근거를 같이 들고 간다**([AnchorBasis]). 사람이 고른 위치와 알고리즘이 // 대신 **근거를 같이 들고 간다**([AnchorBasis]). 사람이 고른 위치와 알고리즘이
// 확정한 위치가 기록에서 구분되지 않으면, 정확도 검증의 모집단을 나중에 // 확정한 위치가 기록에서 구분되지 않으면, 정확도 검증의 모집단을 나중에
// 가려낼 수 없어 데이터 전체가 주장을 받치지 못한다. // 가려낼 수 없어 데이터 전체가 주장을 받치지 못한다.
val basis = AnchorBasis.of(last?.action) // 근거. 확인 측정까지 마쳤으면 앱 규칙이 정한 값이다.
val basis = when {
confirmDone -> AnchorBasis.ALGORITHM
needReattach -> AnchorBasis.REATTACH_OVERRIDE
else -> AnchorBasis.MANUAL
}
// 확정됐으면 그 값(best+offset), 아니면 지금 화면의 위치. 둘 다 "프로브가 // 확정됐으면 그 값(best+offset), 아니면 지금 화면의 위치. 둘 다 "프로브가
// 지금 있는 자리"라는 뜻이라 간호사가 보는 숫자와 기록이 어긋나지 않는다. // 지금 있는 자리"라는 뜻이라 간호사가 보는 숫자와 기록이 어긋나지 않는다.
val proceedCm = last?.anchorCm ?: cm val proceedCm = if (confirmDone) anchorTarget ?: cm else cm
Button( Button(
onClick = { onClick = {
appState.anchorCm = proceedCm appState.anchorCm = proceedCm
@@ -832,7 +851,7 @@ fun AnchorAlignView(appState: AppState) {
Text(note, fontSize = 12.sp, color = MlSecondaryText) Text(note, fontSize = 12.sp, color = MlSecondaryText)
} }
if (done) { if (confirmDone || needReattach) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
TextButton( TextButton(
onClick = { onClick = {
@@ -840,6 +859,7 @@ fun AnchorAlignView(appState: AppState) {
lateral.reset(); lrCommit = null; lrAccum = 0 lateral.reset(); lrCommit = null; lrAccum = 0
lrConfirmed = false; lrError = null; lrRunning = false lrConfirmed = false; lrError = null; lrRunning = false
disconnectedMidRun = false disconnectedMidRun = false
decidedAtCm = null; sweepStopped = false; confirmDone = false
}, },
enabled = !lrRunning, enabled = !lrRunning,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -1153,52 +1173,57 @@ internal fun instructionOf(
step: AnchorStep?, step: AnchorStep?,
nextCm: Int, nextCm: Int,
offsetCm: Int, offsetCm: Int,
/** 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지 올린다. */ /** 수집 단계인가 — 판정과 무관하게 SEARCH_MAX_CM 까지 올린다. */
sweeping: Boolean = false, sweeping: Boolean = false,
/** 판정이 내려진 위치. 수집 단계에서 "이미 답은 나왔다"를 알려 주는 데 쓴다. */ /** 단일 pass 가 멈춘 위치. 수집 중 "옛 방식이면 여기서 끝났다"를 알려 주는 데 쓴다. */
decidedAtCm: Int? = null, decidedAtCm: Int? = null,
/** 수집이 끝나고 확인 측정할 위치(= 전 위치 판정 결과). null = 아직 수집 중이거나 후보 없음. */
confirmTarget: Int? = null,
/** 확인 측정을 마쳤는가. true 면 "+offset 올리고 좌우로". */
confirmDone: Boolean = false,
/** 후보가 하나도 없어 재부착이 필요한가. */
needReattach: Boolean = false,
): AlignInstruction = when { ): AlignInstruction = when {
step == null -> AlignInstruction( step == null -> AlignInstruction(
"●", "치골 바로 위에 붙이고 측정", "●", "치골 바로 위에 붙이고 측정",
"젤을 바르고, 프로브를 치골 바로 위에 밀착시킵니다.") "젤을 바르고, 프로브를 치골 바로 위에 밀착시킵니다.")
step.action == AnchorAction.REATTACH -> AlignInstruction( // 확인 측정까지 끝났다 — 올리고 좌우로. 이 자리는 재지 않는다.
"↓", "더 아래에 다시 붙이세요", confirmDone -> AlignInstruction(
"치골 쪽으로 내려 다시 부착한 뒤 처음부터 측정합니다. " +
(step.stopReason ?: "CH3 가 잡히는 위치가 없었습니다."))
// 수집 단계에서는 판정이 뭐라 하든 계속 올린다. 단 **이미 답이 나왔다는 사실은
// 말해 준다** — 안 그러면 "왜 계속 재라고 하지?"가 되고, 중단 버튼을 못 찾는다.
sweeping && step.searching -> AlignInstruction(
"↑", "1cm 올리고 측정",
"${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다.")
sweeping -> AlignInstruction(
"↑", "1cm 올리고 측정",
decidedAtCm?.let {
"부착 위치는 이미 정해졌습니다(${it}cm 에서 판정). " +
"${AnchorConfig.SEARCH_MAX_CM}cm 까지는 데이터 수집용으로 잽니다 — " +
"여기서 멈춰도 됩니다."
} ?: "${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다.")
step.action == AnchorAction.STOP -> AlignInstruction(
"↑", "${offsetCm}cm 올리세요", "↑", "${offsetCm}cm 올리세요",
"여기서는 측정하지 않습니다. 올린 뒤 좌우 맞추기로 넘어갑니다.", "여기서는 측정하지 않습니다. 올린 뒤 좌우 맞추기로 넘어갑니다.",
done = true) done = true)
else -> { // 수집 중에는 판정이 뭐라 하든 계속 올린다. 판정은 다 재고 나서 한다.
// 지금 있는 자리는 방금 잰 위치다. 다음 자리는 호출부가 이미 정해 두었다. sweeping -> AlignInstruction(
val from = step.record?.cm ?: nextCm "↑", "1cm 올리고 측정",
val delta = nextCm - from decidedAtCm?.let {
"${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다. " +
"(옛 방식이면 ${it}cm 에서 멈췄을 자리입니다 — 지금은 다 재고 판정합니다.)"
} ?: "${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다.")
// 다 쟀는데 후보가 하나도 없다.
needReattach -> AlignInstruction(
"↓", "더 아래에 다시 붙이세요",
"잰 위치 중 CH3 이 제대로 잡힌 곳이 없습니다. 치골 쪽으로 내려 다시 부착한 뒤 " +
"[처음부터 다시 정렬] 을 눌러 주세요.")
// 판정된 자리로 이동 → 확인 측정.
confirmTarget != null -> {
val from = step.record?.cm ?: confirmTarget
val delta = confirmTarget - from
when { when {
delta > 0 -> AlignInstruction("↑", "${delta}cm 올리고 측정", null) delta > 0 -> AlignInstruction("↑", "${delta}cm 올리고 측정",
delta < 0 -> AlignInstruction("↓", "${-delta}cm 내리고 측정", null) "잰 위치 전부로 고른 자리입니다. 여기서 확인 측정 한 번 합니다.")
// 이미 그 자리다 — 확인 측정 한 번이 남았다. 이 화면에서 제일 많이 나오는 delta < 0 -> AlignInstruction("↓", "${-delta}cm 내리고 측정",
// 질문이 "왜 방금 잰 자리를 또 재냐"라, 이유를 같이 적는다. "잰 위치 전부로 고른 자리입니다. 여기서 확인 측정 한 번 합니다.")
else -> AlignInstruction("●", "여기서 한 번 더 측정", else -> AlignInstruction("●", "여기서 한 번 더 측정",
"옮겨 온 자리의 상태를 확인하는 마지막 측정입니다.") "옮겨 온 자리의 상태를 확인하는 마지막 측정입니다.")
} }
} }
// 여기까지 오면 아직 한 위치도 못 쟀거나 상태가 어긋난 것이다.
else -> AlignInstruction("↑", "1cm 올리고 측정", null)
} }
/** /**
@@ -1254,12 +1279,18 @@ private fun buildAlignSummary(
*/ */
decidedAtCm: Int? = null, decidedAtCm: Int? = null,
): JSONObject = JSONObject().apply { ): JSONObject = JSONObject().apply {
// 잰 위치 **전부**로 고르면 어디가 뽑히는가. 앱이 쓰는 값은 `best_cm`(단일 pass)이고 // 앱이 쓴 값 = **잰 위치 전부**로 고른 것. 선택 규칙은 Python `select_supine_anchor`
// 이것은 **비교용**이다. 단일 pass 가 가정하는 단봉성이 실제로 성립하는지 세션마다 // 그대로이고(AnchorSelectionParityTest 200케이스), 바뀐 것은 후보 집합뿐이다.
// 세어 보려고 남긴다(2026-09-10 알고리즘 팀 질의).
val fullBest = AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm val fullBest = AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm
put("best_cm_full_sweep", fullBest ?: JSONObject.NULL)
put("sweep_disagrees", step.bestCm != null && fullBest != null && fullBest != step.bestCm) // ── 판정 범위 ──────────────────────────────────────────────────────────
// 2026-09-10 부터 앱은 **잰 위치 전부**로 판정한다. `best_cm`/`anchor_cm` 은 그 값이다.
// 단일 pass 가 냈을 답을 같이 남긴다 — 변경을 감사할 수 있어야 하고, 그 빈도가
// "단봉 가정이 얼마나 깨지는가"의 지표다.
put("selection_scope", "full_sweep")
put("best_cm_single_pass", step.bestCm ?: JSONObject.NULL)
put("single_pass_disagrees",
step.bestCm != null && fullBest != null && fullBest != step.bestCm)
put("patient", patient) put("patient", patient)
put("save_name", saveName) put("save_name", saveName)
// 이름 없이 잰 데이터인지 파일만 봐도 알 수 있어야 나중에 짝을 맞출 수 있다. // 이름 없이 잰 데이터인지 파일만 봐도 알 수 있어야 나중에 짝을 맞출 수 있다.
@@ -1276,8 +1307,8 @@ private fun buildAlignSummary(
put("samples", HospitalFixedParams.SAMPLES) put("samples", HospitalFixedParams.SAMPLES)
put("ch3_hit_min", AnchorConfig.CH3_HIT_MIN) put("ch3_hit_min", AnchorConfig.CH3_HIT_MIN)
put("final_offset_cm", AnchorConfig.FINAL_OFFSET_CM) put("final_offset_cm", AnchorConfig.FINAL_OFFSET_CM)
put("best_cm", step.bestCm ?: JSONObject.NULL) put("best_cm", fullBest ?: JSONObject.NULL)
put("anchor_cm", step.anchorCm ?: JSONObject.NULL) put("anchor_cm", fullBest?.let { it + AnchorConfig.FINAL_OFFSET_CM } ?: JSONObject.NULL)
put("action", step.action.name) put("action", step.action.name)
// 판정 경계. positions[].in_decision 과 같은 사실을 세션 수준에서도 적는다. // 판정 경계. positions[].in_decision 과 같은 사실을 세션 수준에서도 적는다.
put("decided_at_cm", decidedAtCm ?: JSONObject.NULL) put("decided_at_cm", decidedAtCm ?: JSONObject.NULL)
@@ -1,6 +1,7 @@
package com.medithings.vesiscan.ui.views.clinical package com.medithings.vesiscan.ui.views.clinical
import com.medithings.vesiscan.managers.AnchorAction import com.medithings.vesiscan.managers.AnchorAction
import com.medithings.vesiscan.managers.AnchorConfig
import com.medithings.vesiscan.managers.AnchorPosRecord import com.medithings.vesiscan.managers.AnchorPosRecord
import com.medithings.vesiscan.managers.AnchorStep import com.medithings.vesiscan.managers.AnchorStep
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -10,14 +11,21 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
/** /**
* 정렬 화면 지시문 — **위치 이름이 화면에 나오면 안 된다.** * 정렬 화면 지시문.
* *
* 시험하는 사람이 "최적 위치"와 "부착 위치"를 계속 헷갈렸다(2026-09-09 현장). 둘 다 * ## 두 가지를 고정한다
* "위치"라는 이름의 숫자이고, 화면이 단계마다 다른 쪽을 크게 보여 줬기 때문이다. * **① 위치 이름이 화면에 나오면 안 된다.** 시험하는 사람이 "최적 위치"와 "부착 위치"를
* 프로브를 잡은 사람에게 필요한 것은 지금 할 동작 하나다. * 계속 헷갈렸다(2026-09-09 현장) — 둘 다 "위치"라는 이름의 숫자이고 화면이 단계마다 다른
* 쪽을 크게 보여 줬기 때문이다. 여기서는 **헷갈림 자체가 고치려던 결함**이라 문구가
* 요구사항이다.
* *
* 그래서 이 파일은 문구를 고정한다. 화면 문구 시험은 보통 과하지만, 여기서는 **헷갈림 * **② 단계 순서.** 2026-09-10 부터 흐름이 바뀌었다:
* 자체가 고치려던 결함**이라 문구가 요구사항이다. *
* 0~4cm 전부 측정 → 전 위치로 판정 → 그 자리로 이동해 확인 측정 → +1cm → 좌우
*
* 종전에는 `AnchorGuide` 의 STOP 이 확정 신호였다. 이제 판정을 전 위치로 하므로 guide 의
* STOP 과 앱의 부착 위치가 다를 수 있어, 화면이 `confirmTarget`/`confirmDone` 으로
* 직접 단계를 몰고 간다. 그 계약을 여기서 고정한다.
*/ */
class AlignInstructionTest { class AlignInstructionTest {
@@ -26,28 +34,23 @@ class AlignInstructionTest {
capFrac = 0.64, nchTrace = 2.0, nchSd = 0.0, capFrac = 0.64, nchTrace = 2.0, nchSd = 0.0,
) )
private fun step( private fun step(atCm: Int, searching: Boolean = true, bestCm: Int? = null) = AnchorStep(
action: AnchorAction, action = if (searching) AnchorAction.MOVE_UP else AnchorAction.MOVE_DOWN,
atCm: Int, message = "", record = rec(atCm), bestCm = bestCm,
bestCm: Int? = null,
searching: Boolean = true,
stopReason: String? = null,
) = AnchorStep(
action = action, message = "", record = rec(atCm), bestCm = bestCm,
anchorCm = bestCm?.let { it + 1 }, maxNch = 2.0, searching = searching, anchorCm = bestCm?.let { it + 1 }, maxNch = 2.0, searching = searching,
stopReason = stopReason, stopReason = if (searching) null else "ch3 소실 0%<80%",
) )
/** 화면에 절대 나오면 안 되는 말. */ /** 화면에 절대 나오면 안 되는 말. */
private val banned = listOf("최적", "부착 위치", "anchor")
private fun assertNoPositionWords(i: AlignInstruction) { private fun assertNoPositionWords(i: AlignInstruction) {
val all = listOf(i.headline, i.sub ?: "").joinToString(" ") val all = listOf(i.headline, i.sub ?: "").joinToString(" ")
banned.forEach { w -> listOf("최적", "부착 위치", "anchor").forEach { w ->
assertFalse("\"$w\" 가 지시문에 있으면 안 된다 — $all", all.contains(w)) assertFalse("\"$w\" 가 지시문에 있으면 안 된다 — $all", all.contains(w))
} }
} }
// ── 첫 측정 ─────────────────────────────────────────────────────────────
@Test fun `첫 측정 전에는 붙일 자리를 말한다`() { @Test fun `첫 측정 전에는 붙일 자리를 말한다`() {
val i = instructionOf(null, nextCm = 0, offsetCm = 1) val i = instructionOf(null, nextCm = 0, offsetCm = 1)
assertEquals("●", i.arrow) assertEquals("●", i.arrow)
@@ -56,83 +59,132 @@ class AlignInstructionTest {
assertNoPositionWords(i) assertNoPositionWords(i)
} }
@Test fun `탐색 중에는 올리고 측정하라고만 한다`() { // ── 수집 단계 ───────────────────────────────────────────────────────────
// 0cm 을 재고 다음은 1cm. 절대 숫자가 아니라 **차이**를 말해야 한다.
val i = instructionOf(step(AnchorAction.MOVE_UP, atCm = 0), nextCm = 1, offsetCm = 1) @Test fun `수집 중에는 계속 올리라고만 한다`() {
val i = instructionOf(step(0), nextCm = 1, offsetCm = 1, sweeping = true)
assertEquals("↑", i.arrow) assertEquals("↑", i.arrow)
assertEquals("1cm 올리고 측정", i.headline) assertEquals("1cm 올리고 측정", i.headline)
assertTrue("어디까지 재는지 말해야 멈추지 않는다",
i.sub!!.contains("${AnchorConfig.SEARCH_MAX_CM}cm"))
assertFalse(i.done) assertFalse(i.done)
assertNoPositionWords(i) assertNoPositionWords(i)
} }
@Test fun `지나쳤으면 내리고 측정하라고 한다`() { @Test fun `수집 중 옛 방식이면 멈췄을 자리를 알려 준다`() {
// 3cm 에서 종료가 걸리고 best=1 이면 2cm 내려야 한다. // 판정이 전 위치로 바뀌었다는 사실을 현장에서도 알 수 있어야, "왜 계속 재냐"가
val i = instructionOf( // 안 나온다.
step(AnchorAction.MOVE_DOWN, atCm = 3, bestCm = 1, searching = false), val i = instructionOf(step(2, searching = false, bestCm = 0),
nextCm = 1, offsetCm = 1, nextCm = 3, offsetCm = 1, sweeping = true, decidedAtCm = 1)
) assertEquals("1cm 올리고 측정", i.headline)
assertTrue(i.sub!!.contains("1cm"))
assertTrue(i.sub!!.contains("옛 방식"))
assertNoPositionWords(i)
}
@Test fun `수집은 판정보다 우선한다`() {
// guide 가 MOVE_DOWN 을 내도 수집 중이면 계속 올린다. 이게 뒤바뀌면 4cm 까지
// 못 가고 데이터가 비어 버린다.
val i = instructionOf(step(2, searching = false, bestCm = 0),
nextCm = 3, offsetCm = 1, sweeping = true, confirmTarget = 0)
assertEquals("↑", i.arrow)
assertEquals("1cm 올리고 측정", i.headline)
}
// ── 확인 측정 단계 ──────────────────────────────────────────────────────
@Test fun `판정된 자리로 내려가 확인 측정하라고 한다`() {
// 4cm 까지 다 재고, 전 위치 판정이 1cm 이면 3cm 내려야 한다.
val i = instructionOf(step(4, searching = false, bestCm = 0),
nextCm = 1, offsetCm = 1, confirmTarget = 1)
assertEquals("↓", i.arrow) assertEquals("↓", i.arrow)
assertEquals("2cm 내리고 측정", i.headline) assertEquals("3cm 내리고 측정", i.headline)
assertTrue("왜 그 자리인지 말해야 한다", i.sub!!.contains("전부"))
assertNoPositionWords(i)
}
@Test fun `판정된 자리가 위면 올리라고 한다`() {
val i = instructionOf(step(0, searching = false, bestCm = 0),
nextCm = 3, offsetCm = 1, confirmTarget = 3)
assertEquals("↑", i.arrow)
assertEquals("3cm 올리고 측정", i.headline)
assertNoPositionWords(i) assertNoPositionWords(i)
} }
@Test fun `같은 자리면 한 번 더 재라고 하고 이유를 댄다`() { @Test fun `같은 자리면 한 번 더 재라고 하고 이유를 댄다`() {
// 이 화면에서 제일 많이 나오는 질문이 "왜 방금 잰 자리를 또 재냐"다. // 이 화면에서 제일 많이 나오는 질문이 "왜 방금 잰 자리를 또 재냐"다.
val i = instructionOf( val i = instructionOf(step(2, searching = false, bestCm = 2),
step(AnchorAction.MOVE_DOWN, atCm = 2, bestCm = 2, searching = false), nextCm = 2, offsetCm = 1, confirmTarget = 2)
nextCm = 2, offsetCm = 1,
)
assertEquals("●", i.arrow) assertEquals("●", i.arrow)
assertTrue(i.headline.contains("한 번 더")) assertTrue(i.headline.contains("한 번 더"))
assertNotNull("이유가 없으면 또 물어본다", i.sub) assertNotNull("이유가 없으면 또 물어본다", i.sub)
assertNoPositionWords(i) assertNoPositionWords(i)
} }
@Test fun `확정되면 올리고 재지 말라고 한다`() { // ── 확정 ────────────────────────────────────────────────────────────────
@Test fun `확인 측정을 마치면 올리고 재지 말라고 한다`() {
// 여기가 제일 중요하다. 올린 자리를 측정하면 설계상 "미달"이 떠서 되돌아간다. // 여기가 제일 중요하다. 올린 자리를 측정하면 설계상 "미달"이 떠서 되돌아간다.
val i = instructionOf( val i = instructionOf(step(2, searching = false, bestCm = 2),
step(AnchorAction.STOP, atCm = 2, bestCm = 2, searching = false), nextCm = 2, offsetCm = 1, confirmTarget = 2, confirmDone = true)
nextCm = 2, offsetCm = 1,
)
assertEquals("↑", i.arrow) assertEquals("↑", i.arrow)
assertEquals("1cm 올리세요", i.headline) assertEquals("1cm 올리세요", i.headline)
assertTrue("측정하지 말라는 말이 빠지면 안 된다", assertTrue("측정하지 말라는 말이 빠지면 안 된다", i.sub!!.contains("측정하지 않습니다"))
i.sub!!.contains("측정하지 않습니다"))
assertTrue("다음 단계를 말해야 멈추지 않는다", i.sub!!.contains("좌우")) assertTrue("다음 단계를 말해야 멈추지 않는다", i.sub!!.contains("좌우"))
assertTrue("확정은 초록이다", i.done) assertTrue("확정은 초록이다", i.done)
assertNoPositionWords(i) assertNoPositionWords(i)
} }
@Test fun `확정은 수집이나 재부착보다 우선한다`() {
// 상태가 겹쳐도 확정이 먼저다 — 이미 끝난 단계를 되돌리면 안 된다.
val i = instructionOf(step(2, searching = false, bestCm = 2),
nextCm = 2, offsetCm = 1, sweeping = true, confirmTarget = 2,
confirmDone = true, needReattach = true)
assertTrue(i.done)
assertEquals("1cm 올리세요", i.headline)
}
@Test fun `오프셋이 바뀌면 문구도 따라간다`() { @Test fun `오프셋이 바뀌면 문구도 따라간다`() {
// FINAL_OFFSET_CM 을 2 로 바꾸면 화면도 2cm 이라고 말해야 한다. 박아 두면 거짓이 된다. // FINAL_OFFSET_CM 을 2 로 바꾸면 화면도 2cm 이라고 말해야 한다. 박아 두면 거짓이 된다.
val i = instructionOf( val i = instructionOf(step(1, searching = false, bestCm = 1),
step(AnchorAction.STOP, atCm = 1, bestCm = 1, searching = false), nextCm = 1, offsetCm = 2, confirmTarget = 1, confirmDone = true)
nextCm = 1, offsetCm = 2,
)
assertEquals("2cm 올리세요", i.headline) assertEquals("2cm 올리세요", i.headline)
} }
@Test fun `재부착은 아래로 내리라고 하고 사유를 붙인다`() { // ── 재부착 ──────────────────────────────────────────────────────────────
val i = instructionOf(
step(AnchorAction.REATTACH, atCm = 3, searching = false, @Test fun `후보가 없으면 아래로 내려 다시 붙이라고 한다`() {
stopReason = "탐색 상한 4cm"), val i = instructionOf(step(4, searching = false, bestCm = null),
nextCm = 3, offsetCm = 1, nextCm = 4, offsetCm = 1, needReattach = true)
)
assertEquals("↓", i.arrow) assertEquals("↓", i.arrow)
assertTrue(i.headline.contains("아래")) assertTrue(i.headline.contains("아래"))
assertTrue("사유가 없으면 같은 실수를 반복한다", i.sub!!.contains("탐색 상한")) assertTrue("무엇을 눌러야 하는지 말해야 한다", i.sub!!.contains("처음부터"))
assertFalse(i.done) assertFalse(i.done)
assertNoPositionWords(i) assertNoPositionWords(i)
} }
@Test fun `모든 동작에 지시문이 있다`() { // ── 전 조합 ─────────────────────────────────────────────────────────────
// action 이 늘어났는데 여기를 안 고치면 조용히 "올리고 측정"으로 떨어진다.
assertEquals(4, AnchorAction.entries.size) @Test fun `어떤 조합에서도 문구가 비지 않는다`() {
AnchorAction.entries.forEach { a -> val targets = listOf<Int?>(null, 0, 2, 4)
val i = instructionOf(step(a, atCm = 1, bestCm = 1, searching = false), for (sw in listOf(true, false)) {
nextCm = 2, offsetCm = 1) for (cd in listOf(true, false)) {
assertTrue("$a 에 문구가 없다", i.headline.isNotBlank()) for (nr in listOf(true, false)) {
for (t in targets) {
val i = instructionOf(
step(2, searching = false, bestCm = 0),
nextCm = 3, offsetCm = 1,
sweeping = sw, decidedAtCm = 1,
confirmTarget = t, confirmDone = cd, needReattach = nr,
)
assertTrue(
"sweeping=$sw confirmDone=$cd reattach=$nr target=$t 에 문구가 없다",
i.headline.isNotBlank(),
)
assertNoPositionWords(i) assertNoPositionWords(i)
} }
} }
} }
}
}
}
+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`) `AnchorSelection.selectBest()` (`AnchorGuide.kt:174`)
> **후보는 잰 위치 전부입니다** (2026-09-10 변경). 종전에는 "종료 조건이 걸릴 때까지"의
> 위치만 후보였습니다. 자세히는 [3.6](#36-실제-진행-순서).
``` ```
① 후보 자격 ch3 == "O" AND ch3_rate ≥ 0.8 ① 후보 자격 ch3 == "O" AND ch3_rate ≥ 0.8
→ 통과가 0개면 → REATTACH (재부착) → 통과가 0개면 → REATTACH (재부착)
@@ -369,81 +372,78 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 1
**오프셋 위치는 측정하지 않습니다.** 설계상 그 자리는 지표가 나쁜 쪽이라, 재서 문턱으로 **오프셋 위치는 측정하지 않습니다.** 설계상 그 자리는 지표가 나쁜 쪽이라, 재서 문턱으로
쓰면 "미달"이 떠서 도달 전에 멈춥니다. 쓰면 "미달"이 떠서 도달 전에 멈춥니다.
### 3.6 실제 진행 순서 (2026-09-10 변경) ### 3.6 실제 진행 순서
병원 임상은 위치를 고르는 것만이 목적이 아니라 **위치별 원신호를 모으는 것**도 목적입니다.
그래서 종료 조건이 걸려도 **4cm 까지 전부 잽니다.**
``` ```
0cm 측정 → ↑ 1cm 올리고 측정 0cm 측정 → ↑ 1cm 올리고 측정
1cm 측정 → 종료 조건 걸림 (판정 완료, best 확정) 1cm 측정 → ↑ 1cm 올리고 측정 (판정이 나빠져도 멈추지 않습니다)
↑ 1cm 올리고 측정 2·3·4cm 측정
"부착 위치는 이미 정해졌습니다(1cm 에서 판정). ↓
4cm 까지는 데이터 수집용으로 잽니다 — 여기서 멈춰도 됩니다." 잰 위치 전부로 판정 → best = N
2·3·4cm 측정 (수집) ↓
→ ↓ N cm 내리고 측정 (부착 위치로) ↓/↑ N cm 로 이동 → ● 여기서 한 번 더 측정 (확인 측정 = 확정 신호)
→ ● 여기서 한 번 더 측정 ← 확정 판정의 입력 ↓
→ ↑ 1cm 올리세요 (초록 · 여기서는 측정하지 않음) ↑ 1cm 올리세요 (초록 · 여기서는 측정하지 않음)
→ 좌우 정렬 ↓
좌우 정렬
``` ```
#### ⚠ 판정은 수집 때문에 바뀌지 않습니다 [여기까지만 재고 판정하기] 로 수집을 중단할 수 있습니다 — 그 시점까지의 위치로 판정합니다.
`selectBest` 는 **넘긴 레코드 전부**를 후보로 봅니다. 후보가 늘면 답이 뒤집힐 수 있습니다: #### 판정 범위가 바뀌었습니다 (2026-09-10)
``` | | 종전 | 현재 |
0cm eligible nch=2 · 1cm ch3 소실 · 2cm eligible nch=4 |---|---|---|
조기 종료 후보 {0,1} → best 0cm | 측정 | 종료 조건 걸리면 중단 | **0~4cm 무조건 전부** |
전부 넣으면 → best 2cm ← 다른 답 | 후보 | 종료까지의 위치 | **잰 위치 전부** |
``` | 선택 규칙 | 3단 규칙 | **그대로** |
| 부착 | best + 1cm 단순 덧셈 | **그대로** |
| 종료 조건 | 판정을 끝냄 | **진단용** (`decided_at_cm`) |
안전한 이유는 `AnchorGuide` 가 `searching` 이 꺾이는 **그 순간 한 번만** `bestCm` 을 **왜 바꿨나.** 단일 pass 는 지표가 cm 에 대해 **단봉(unimodal)** 이라고 가정해, 나빠지는
계산하고, 이후 `step()` 은 `recs` 에만 쌓기 때문입니다. `AnchorSweepTest` 가 이 성질을 순간 봉우리를 지난 것으로 보고 멈춥니다. 그런데 장내 가스·접촉 불량·호흡으로 **한 위치만**
고정합니다. 일시적으로 나빠져도 멈추므로, 더 위가 진짜 좋았는데 놓칠 수 있습니다(알고리즘 팀 질의).
#### 단일 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)** 이라고 가정합니다. 올라가다 나빠지면 **레퍼런스는 어떻게 되나.** 선택 규칙 `selectBest` 는 Python `select_supine_anchor` 와
봉우리를 지난 것으로 보고 멈춥니다. Python 도 같습니다 — *"수직 → 좌우 순서로 한 번만 1:1 이고 200 케이스로 대조돼 있습니다(`AnchorSelectionParityTest`) — **손대지 않았습니다.**
가고 되돌아가지 않는다."* 바뀐 것은 후보 집합뿐이고, **Python 의 회고 분석 경로가 이미 전 위치로 판정**하므로 같은
방식으로 재현·대조할 수 있습니다. 다만 최종 보고서의 "단일 pass" 기술은 갱신이 필요합니다.
물리적 근거는 있습니다. CH3 은 제일 기울어진 채널로 **방광 아래쪽**을 보므로, 위로 **감사 가능하게 둘 다 기록합니다:**
올라가면 점점 방광을 벗어납니다.
**깨질 수 있는 경우**는 *한 위치만* 일시적으로 나빠지는 것입니다 — 장내 가스가 CH3 을
가림, 그 위치에서 접촉이 떴음, 호흡으로 방광이 움직임. 그러면 더 위가 진짜 좋았는데
조기 종료합니다.
실측에 징후가 있습니다. 2026-09-09 `kai` 세션은 **0cm `ch3=X` → 1cm `ch3=O 91%`** 로
지표가 **올라갔습니다.** 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀
있지만, **중간에서는 한 칸만 나빠도 끝납니다.**
전수 측정이 이 질문에 답을 냅니다. 세션마다 두 값을 같이 기록합니다:
```json ```json
"best_cm": 0, // 앱이 쓰는 값 (단일 pass · 레퍼런스) "selection_scope": "full_sweep",
"best_cm_full_sweep": 3, // 잰 위치 전부로 고르면 "best_cm": 3, // 앱이 쓴 값 = 전 위치 판정
"sweep_disagrees": true // 갈렸다 "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` 은 이제 **판정 경계가 아니라 진단값**입니다 — 종료 조건이 어디서 걸렸는지
"decided_at_cm": 1, (= 지표가 어디서 무너졌는지)를 말합니다. `positions[].in_decision` 도 같은 기준이라,
"swept_to_cm": 4, **현재 판정은 `in_decision` 과 무관하게 전 위치를 씁니다.**
"positions": [{ "align_cm": 2, "in_decision": false, ... }]
```
### 3.7 좌우 정렬 — 부착 높이에서 ### 3.7 좌우 정렬 — 부착 높이에서