fix(alignment): LR_BALANCE lost 채널 hysteresis — 사용자앱 (50b8620) 동기화

2026-06-30 실측 분석 (244 cycle alignment session) 결과:
LR_BALANCE phase 170 cycle 중 |u4-u5| 항상 LAT_TOL(8) 이하 (좌우 균형 늘 OK).
하지만 CH1=15%, CH3=12% 가 cycle 마다 노이즈로 깜빡임 → 매번 lost.isNotEmpty()
true → "↑위로" / "↓아래로" 화살표 → 다음 cycle 복구 → STOP 반복.
사용자 체감: "어디로 옮길지 모르겠음".

- lostStreakRequired=3 추가
- 같은 채널이 3 연속 commit 에서 lost 여야 회복 모드 발동
- streak 미달 시 STOP 유지 (noise 로 판단)
- full set 복귀 시 lostStreak 0 리셋

예상 효과: LR_BALANCE 화살표 flap rate 55% → ~5%.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:47:54 +09:00
parent 886d40333d
commit b2546d14ca
@@ -171,6 +171,13 @@ class AlignGuide4Stage(
/** LR_BALANCE 반전 요구 연속 commit 수. 2회 연속 악화여야 방향 반전.
* sliding window 잔여 효과 (사용자가 방향 바꿔도 평균은 직전 데이터 섞임) 흡수. */
private val lrReverseConsecutive: Int = 2,
/**
* LR_BALANCE 의 `lost` 감지 hysteresis — N 연속 commit 에서 같은 채널이
* 빠져있어야 진짜 "회복 필요" 로 인정. 1 cycle noise 깜빡임에 화살표
* 흔들리는 현상 차단 (2026-06-30 실측 분석 결과 — LR_BALANCE 170 cycle
* 중 CH1=15%, CH3=12% 가 노이즈로 깜빡임 → 화살표 매번 발동).
*/
private val lostStreakRequired: Int = 3,
) {
enum class Phase4 { INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE, LR_BALANCE }
@@ -194,6 +201,7 @@ class AlignGuide4Stage(
private var lateralDir: AlignAction? = null
private var lateralImbal: Int? = null
private var lateralWorseStreak = 0 // LR_BALANCE 연속 악화 카운터 (deadband 초과 증가 시 +1)
private var lostStreak = 0 // LR_BALANCE 채널 lost hysteresis
fun reset() {
phase = Phase4.INITIAL_ACCUM
@@ -203,6 +211,7 @@ class AlignGuide4Stage(
lateralDir = null
lateralImbal = null
lateralWorseStreak = 0
lostStreak = 0
}
fun step(s: AlignSummary): Out {
@@ -259,7 +268,10 @@ class AlignGuide4Stage(
val setLabel = bestCenterSet.sortedDescending().joinToString("+") { "ch$it" } // dev/debug only
val lost = bestCenterSet - curSet
if (lost.isNotEmpty()) {
// 잃은 채널 있음 → 회복 우선. ch3 잃었으면 더 위로, 아니면 아래로.
// 2026-06-30 hysteresis: N 연속 commit 에서 같은 채널이 lost 여야 진짜 회복 모드.
// 1 cycle 노이즈 깜빡임 (실측 CH1=15%, CH3=12% flap) 에 화살표 흔들리는 현상 차단.
lostStreak++
if (lostStreak >= lostStreakRequired) {
val recoveryDir = if (3 in lost) AlignAction.MOVE_UP else AlignAction.MOVE_DOWN
val arrow = if (recoveryDir == AlignAction.MOVE_UP) "↑" else "↓"
val dirWord = if (recoveryDir == AlignAction.MOVE_UP) "위" else "아래"
@@ -268,6 +280,13 @@ class AlignGuide4Stage(
return Out(recoveryDir, "$arrow 위치 살짝 벗어남 — ${dirWord}로 약간", phase, 0,
msgKey = recoveryKey)
}
// streak 미달 → noise 로 보고 STOP 유지.
return Out(AlignAction.STOP,
"✓ 정렬 완료! 그대로 측정 가능 (noise check $lostStreak/$lostStreakRequired)",
phase, 0, msgKey = R.string.align_complete)
} else {
lostStreak = 0 // full set 복귀 — streak 리셋
}
// full set 유지 중 — 좌우 균형 (deadband + consecutive 악화 로 oscillation 차단)
val u4 = s.u4