feat(algo): 레퍼런스 경로를 dev 스위치로 · 기본은 현행 유지

신 저장소에 맞춘 레퍼런스(piezo-phantom-test/vesiscan_test) 단계들을 옮기되,
demo-final 의 기본 동작은 건드리지 않았다. 2026-09-03 임상이 현재 HEAD 위에서
돌았으므로 그 값을 움직이는 변경을 기본 경로에 넣을 수 없다.

`AlgoMode.reference` (계측 화면 dev 패널 → "알고리즘", 기본 꺼짐) 를 켜면:

  · CCC 3단계 → 7단계
    envelope(①.9) · ant_tiebreak(①) · Λ-dip(①.7) · edge_post(①.8) ·
    lumen_return(①.95) · NTV(② Rule A 만) · fp_gate(③). inward_post 는 빠진다.
  · 파이프라인 4개 추가
    scale_recovery(2) · missing_outside 산출(4) · channel_confidence→untrusted(5b) ·
    CONF_DEMOTE(6). missing_outside 는 CCC **앞**에서 계산한다 — CCC 가 복구한
    채널은 방광 밖이 아니다.
  · BV
    si_floor/missing_outside 로 극 cap clamp 의 방향(상한/하한)을 관측 증거로 가름,
    bottom cap 제한 신설, 중앙 채널 보간 은행가 반올림,
    adaptive_large_bladder_relax 제거(b_si 를 0.85·a_ap 로 바닥 처리해 cap 축 비를
    정확히 0.85 로 만들어 바로 다음 cap 축 폴백을 경계에서 막고 있었다).

검출 파라미터(d_max 15 · inward_walk_win 4) · ANT_FLOOR_REF · cap 축 폴백 ·
ELLIPSE_CAP_R_MAX · TOP_CAP_EDGE_K · cap_d_adaptive · 프리셋 유도 dps/delay 는
ebddc52/20a48ad 에서 이미 기본 경로에 들어가 있어 양쪽 공통이다. 되돌리지 않았다.

검증
  · 기존 경로 무변경 — LegacyEquivalenceDumpTest 가 44 cycle × CCC on/off 를
    검출→BV 로 돌려 덤프하고, HEAD 코드 덤프와 대조. 88 행 전부 일치.
    (이 테스트는 일부러 AlgoMode 를 참조하지 않는다 — HEAD 에 없는 타입이라
     참조하면 대조 자체가 불가능해진다.)
  · 토글 배선 — AlgoModeSwitchTest: 켜면 44 중 40 cycle 이 갈리고
    missing_outside 가 산출되며, 끄면 기존 값이 정확히 재현된다.
  · 전체 30개 통과.

함께 고친 것

저장소가 갈리며 없어진 픽스처 경로(medilightv2android/data123)를
vesiscan-design-archive/data123 로 옮겼다. 5개 테스트가 FileNotFoundException 으로
조용히 실패하고 있었다.

그래서 ebddc52 이후 한 번도 안 돌던 CenterAlignerValidationTest 가 다시 돌았고
hit 7→8 · 10→11 로 어긋났다. 현행 레퍼런스 AnchorGuide(ver='v1') 를 같은 세션에
직접 돌려 보니 8 · 11 이었다 — 코틀린이 맞고 기대값이 낡은 것이다(구
v3_python_reference.py 기준, 현재 저장소에 없음). 기대값을 갱신했다.
bv_cv 는 현행 레퍼런스가 더 이상 내지 않아 대조할 수단이 없다. "Python 과
bit-exact" 라던 주석은 근거가 없어졌으므로 걷어내고 회귀 고정으로 성격을 바꿨다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:31:58 +09:00
parent c7f9cc976e
commit ccb44f0ea6
16 changed files with 1268 additions and 88 deletions
@@ -0,0 +1,73 @@
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.walldetect.MethodDRunner
import org.junit.Test
import java.io.File
/**
* 기존(팬텀) 경로가 이번 이식으로 **한 글자도 안 바뀌었는지** 실증하는 덤프 하네스.
*
* 검출 → BV 전 구간을 `align_cm{0,1}.csv` 의 모든 cycle 에 대해 돌려 결과를 파일로
* 떨군다. HEAD 코드와 작업트리 코드에서 각각 돌린 덤프가 **바이트 단위로 같으면**
* 기존 경로 무변경이 증명된다.
*
* 이 파일은 [AlgoMode] 를 참조하지 않는다 — HEAD 에는 그 타입이 없기 때문이다.
* 기본값이 기존 경로이므로 기본 호출만으로 그 경로가 그대로 실행된다.
*/
class LegacyEquivalenceDumpTest {
private fun res(name: String) =
javaClass.classLoader!!.getResourceAsStream(name)!!.bufferedReader().use { it.readText() }
/** cycle → 6채널 신호 (`cycle,channel,s0..s99`). */
private fun loadCycles(name: String): Map<Int, List<DoubleArray>> {
val out = HashMap<Int, MutableList<Pair<Int, DoubleArray>>>()
for (line in res(name).lineSequence()) {
val t = line.trim()
if (t.isEmpty()) continue
val f = t.split(",")
if (f.size < 3) continue
val cyc = f[0].toIntOrNull() ?: continue
val ch = f[1].toIntOrNull() ?: continue
val sig = DoubleArray(f.size - 2) { f[it + 2].trim().toDouble() }
out.getOrPut(cyc) { ArrayList() }.add(ch to sig)
}
return out.mapValues { (_, v) ->
val byCh = v.toMap()
(0..5).map { byCh[it] ?: DoubleArray(0) }
}
}
@Test
fun dump() {
val sb = StringBuilder()
for (cm in 0..1) {
val cycles = loadCycles("align_cm$cm.csv")
for (cyc in cycles.keys.sorted()) {
val sig = cycles[cyc]!!
for (cross in listOf(false, true)) {
val dets = MethodDRunner.detectMultichannel(sig, applyCross = cross)
val walls = dets.map { r ->
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
}
val bv = estimateBv(walls)
sb.append("cm$cm cyc$cyc cross=$cross")
dets.forEachIndexed { i, r ->
sb.append(if (r == null) " CH$i=-" else
" CH$i=%d/%d/%.4f/%.4f/%d/%d".format(
r.ant, r.post, r.antRefined, r.postRefined, r.lowStart, r.lowEnd))
}
sb.append(if (bv == null) " BV=-" else
" BV=%.6f lr=%.6f nch=%d topH=%.4f botH=%.4f vT=%.4f vB=%.4f".format(
bv.volumeMl, bv.lrRatio, bv.validChannels.size,
bv.topHMm, bv.bottomHMm, bv.vTopMm3, bv.vBottomMm3))
sb.append('\n')
}
}
}
val out = File(System.getProperty("legacyDump") ?: "build/legacy_dump.txt")
out.parentFile?.mkdirs()
out.writeText(sb.toString())
println("[dump] ${out.absolutePath} lines=${sb.lines().size}")
}
}