Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ public static VerificationGuideFacts of(Challenge c, RoutineTemplate template) {
}
case "WAKE" -> {
String t = clock(params.get("target_time"));
conditions.add((t != null ? t : "목표 시각") + " 전에 처음 휴대폰 잠금 해제하기");
conditions.add((t != null ? t : "목표 시각") + "쯤 일어나 걷기(걸음 기록 기준)");
if (t != null) must.add(t);
fallback = freq + " " + (t != null ? t : "목표 시각") + " 전에 처음 휴대폰 잠금을 해제하면 자동 인증됩니다.";
fallback = freq + " " + (t != null ? t : "목표 시각") + "에 일어나 조금 걸으면 걸음 기록으로 자동 인증됩니다.";
}
case "SLEEP" -> {
String bed = clock(params.get("bedtime_before"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
* @param syncMinIntervalSec 평상시 sync 최소 간격(초). 기기 스펙 기반 flushIntervalSec 보다 짧게 잡아
* 정상 주기 전송이 걸리지 않게 한다
* @param syncBacklogMinIntervalSec 복구 전송(backlog=true) 최소 간격(초). 밀린 구간을 나눠 올려야 해서 더 짧다
* @param wakeGraceMinutes 기상 판정에서 목표 시각 뒤로 걸음을 더 기다리는 시간(분). 일어나 바로 걷지 않거나
* 걸음 기록이 조금 늦게 잡혀도 인정한다. 기본 30
* @param wakeMinSteps 기상으로 인정할 걸음 수(창 안 합계). 휴대폰을 집어 드는 정도의 오탐 걸음과
* 실제로 일어나 움직인 것을 가르는 문턱이라 실기기 테스트로 찾는다. 기본 10
* @param signalRetentionDays 판정 원본을 유지할 <b>일수</b>. raw 는 현재 귀속일과 직전 유예
* 귀속일만 필요한 hot storage 이고, D일 신호는 D+2 00:00 KST 확정이
* 끝나면 목적이 끝난다. <b>정확히 이 일수</b>를 남긴다 — 기본 3이면
Expand Down Expand Up @@ -58,7 +62,8 @@ public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayload
Integer signalPartitionLookaheadDays,
Integer gpsRetentionDays, Integer anomalyRetentionDays,
Integer materializeCatchupDays,
Boolean requireActiveDevice, Boolean requireSignalOrigin) {
Boolean requireActiveDevice, Boolean requireSignalOrigin,
Integer wakeGraceMinutes, Integer wakeMinSteps) {

private static final int DEFAULT_GEOFENCE_RADIUS_M = 200;
private static final int DEFAULT_MAX_PAYLOAD_BYTES = 1_048_576;
Expand All @@ -69,6 +74,8 @@ public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayload
private static final int DEFAULT_GPS_RETENTION_DAYS = 0;
private static final int DEFAULT_ANOMALY_RETENTION_DAYS = 30;
private static final int DEFAULT_MATERIALIZE_CATCHUP_DAYS = 7;
private static final int DEFAULT_WAKE_GRACE_MINUTES = 30;
private static final int DEFAULT_WAKE_MIN_STEPS = 10;

public VerificationProperties {
geofenceRadiusM = positiveOrDefault(geofenceRadiusM, DEFAULT_GEOFENCE_RADIUS_M);
Expand All @@ -95,6 +102,9 @@ public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayload
// 관측으로 안전을 확인한 뒤 켜야 한다.
requireActiveDevice = requireActiveDevice != null && requireActiveDevice;
requireSignalOrigin = requireSignalOrigin != null && requireSignalOrigin;
// 0 은 「목표 시각까지만」이라는 유효한 값이다.
wakeGraceMinutes = nonNegativeOrDefault(wakeGraceMinutes, DEFAULT_WAKE_GRACE_MINUTES);
wakeMinSteps = positiveOrDefault(wakeMinSteps, DEFAULT_WAKE_MIN_STEPS);
}

private static int positiveOrDefault(Integer value, int fallback) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public record FailureEvidence(String summary,
* 숫자가 들어간다 — 기록하지 않는 타입은 기준값 없이 사유 코드만 남는다.
*/
private static final List<String> EXPECTED_KEYS = List.of(
"goal", "goalKm", "goalMinutes", "beforeTime", "bedtimeBefore", "graceMinutes",
"goal", "goalKm", "goalMinutes", "beforeTime", "deadline", "bedtimeBefore", "graceMinutes",
"unit", "metric", "mode");

/** 실제 측정값 키. 나머지(내부 이월 상태·중복 제거 키)는 설명에 쓰지 않는다. */
Expand Down Expand Up @@ -57,7 +57,7 @@ private static String summary(String reasonCode, Map<String, Object> ev) {
case "INSUFFICIENT_STEPS" -> pair(ev, "걸음", "value", "걸음", "goal", "걸음");
case "INSUFFICIENT_USAGE" -> pair(ev, "사용", "usageMinutes", "분", "goalMinutes", "분");
case "USAGE_EXCEEDED" -> limit(ev, "사용", "usageMinutes", "goalMinutes");
case "WOKE_UP_LATE" -> before(ev, "첫 잠금 해제", "firstUnlockAt", "beforeTime");
case "WOKE_UP_LATE" -> wake(ev);
case "SLEPT_LATE" -> before(ev, "취침", "bedtime", "bedtimeBefore");
case "INSUFFICIENT_SLEEP" -> pair(ev, "수면", "sleepHours", "시간", "goal", "시간");
case "PERMISSION_MISSING" -> "인증에 필요한 권한이 꺼져 있어 측정하지 못했어요.";
Expand Down Expand Up @@ -98,7 +98,16 @@ private static String limit(Map<String, Object> ev, String label, String actualK
return "%s %s분 / 상한 %s분".formatted(label, actual, goal);
}

/** 「첫 잠금 해제 07:20 / 목표 07:00 이전」. */
/** 「07:30까지 걸음 3걸음 / 목표 10걸음」 — 기상은 목표 시각 + 유예 안에 걸었는지로 본다. */
private static String wake(Map<String, Object> ev) {
Object deadline = value(ev, "deadline");
Object steps = value(ev, "value");
Object goal = value(ev, "goal");
if (deadline == null || steps == null || goal == null) return null;
return "%s까지 걸음 %s걸음 / 목표 %s걸음".formatted(deadline, steps, goal);
}

/** 「취침 01:20 / 목표 00:00 이전」. */
private static String before(Map<String, Object> ev, String label, String actualKey, String goalKey) {
Object actual = value(ev, actualKey);
Object goal = value(ev, goalKey);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,27 @@

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

import java.util.List;

/**
* 기상 인증 파라미터(§4.3). "beforeTime(예 07:00) 이전 첫 잠금해제"면 성공.
* 창은 [하루 시작, beforeTime] — beforeTime에 창이 닫힘(자정 아님, §2.11).
* 기상 인증 파라미터(§4.3). 「하루 시작부터 beforeTime + graceMinutes 까지 minSteps 걸음 이상」이면 성공.
*
* <p>예전에는 하루 첫 잠금 해제 시각으로 기상을 봤다. 잠금 해제는 누워서 알람만 끄고 다시 자도
* 잡히고, 반대로 일어나서 휴대폰을 안 보면 실패한다 — 「일어났다」가 아니라 「휴대폰을 봤다」를
* 쟀다. 2026-10-15 부터 <b>일어나서 움직인 흔적(걸음)</b>으로 본다.
*
* @param beforeTime 목표 기상 시각 "HH:mm"(루틴의 target_time)
* @param graceMinutes 목표 시각 뒤로 걸음을 더 기다리는 시간(분). 일어나 바로 걷지 않거나 걸음 기록이
* 조금 늦게 잡혀도 인정한다
* @param minSteps 창 안에서 필요한 걸음 수
* @param trustedOrigins 걸음 기록을 인정하는 출처(비면 게이트 미적용)
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record WakeConfig(
String beforeTime, // "HH:mm"
String beforeTime,
int graceMinutes,
int minSteps,
List<String> trustedOrigins,
Polarity polarity, // ACHIEVEMENT
int maxSignalLagHours
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public EvaluationOutcome evaluate(DayContext ctx) {
if (cfg.exerciseType() != null && r.exerciseType() != null
&& !cfg.exerciseType().equalsIgnoreCase(r.exerciseType())) continue; // 운동종류 불일치 제외

String gate = gateReason(r, cfg);
String gate = gateReason(r, cfg.rejectManualRecording(), cfg.trustedOrigins());
if (gate != null) {
anyRejected = true;
HealthOrigin o = r.origin();
Expand Down Expand Up @@ -105,8 +105,8 @@ public EvaluationOutcome evaluate(DayContext ctx) {
return EvaluationOutcome.pending(ev, windowClose(ctx));
}

/** 구간을 가진 측정값 1건. */
private record Measured(Instant from, Instant to, double value) {}
/** 구간을 가진 측정값 1건. 기상 판정도 같은 방식으로 겹침을 걷어낸다. */
record Measured(Instant from, Instant to, double value) {}

/**
* 겹치는 구간을 걷어낸 합계.
Expand All @@ -115,7 +115,7 @@ private record Measured(Instant from, Instant to, double value) {}
* 비례해 더한다. 값이 구간에 고르게 분포한다고 보는 근사지만, 겹친 만큼을 통째로 두 번 세거나
* 통째로 버리는 것보다 실제에 가깝다. Health Connect 는 같은 활동을 기기별로 따로 기록한다.
*/
private double dedupedSum(List<Measured> readings) {
static double dedupedSum(List<Measured> readings) {
if (readings.isEmpty()) return 0;
List<Measured> sorted = new ArrayList<>(readings);
sorted.sort(Comparator.comparing(Measured::from).thenComparing(Measured::to));
Expand All @@ -134,12 +134,11 @@ private double dedupedSum(List<Measured> readings) {
return sum;
}

/** 게이트 통과 여부. 통과면 null, 막히면 사유 문자열. */
private String gateReason(HealthReading r, HealthConfig cfg) {
/** 게이트 통과 여부. 통과면 null, 막히면 사유 문자열. 기상 판정의 걸음 기록에도 같은 게이트를 쓴다. */
static String gateReason(HealthReading r, boolean rejectManualRecording, List<String> trusted) {
HealthOrigin o = r.origin();
if (o == null) return "NO_ORIGIN"; // §6.3 메타데이터 누락
if (cfg.rejectManualRecording() && "MANUAL".equalsIgnoreCase(o.recordingMethod())) return "MANUAL";
List<String> trusted = cfg.trustedOrigins();
if (rejectManualRecording && "MANUAL".equalsIgnoreCase(o.recordingMethod())) return "MANUAL";
if (trusted != null && !trusted.isEmpty()) {
boolean ok = o.dataOrigin() != null && trusted.contains(o.dataOrigin());
if (!ok) return "UNTRUSTED_ORIGIN";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
package com.ruleup.ruleup_backend.verification.evaluator;

import com.ruleup.ruleup_backend.verification.domain.HealthMetric;
import com.ruleup.ruleup_backend.verification.domain.VerificationMethod;
import com.ruleup.ruleup_backend.verification.domain.WakeConfig;
import com.ruleup.ruleup_backend.verification.signal.ScreenEvent;
import com.ruleup.ruleup_backend.verification.signal.HealthReading;
import com.ruleup.ruleup_backend.verification.signal.SignalType;
import com.ruleup.ruleup_backend.verification.signal.SyncSignal;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.*;

/**
* WAKE(기상) 평가기 — 도달형(§2.6, §2.11).
* - 신호: WAKE·SCREEN_TIME 의 screenEvents(UNLOCK/SCREEN_ON) 중 당일 첫 발생 = firstUnlockAt.
* 두 타입을 모두 읽는다 — 기상만 보내는 기기는 WAKE 로, 앱 사용과 함께 보내는 기기는
* SCREEN_TIME 에 실어 보낸다. 한쪽만 읽으면 기기에 따라 기상이 영영 안 잡힌다.
* - 창: [하루 시작, beforeTime]. beforeTime에 창이 닫힌다(자정 아님).
* - 판정: firstUnlockAt ≤ beforeTime → SUCCESS / 창 닫힘·미발생 → FAILED(WOKE_UP_LATE) / 그 외 → PENDING.
* - 그날 원본 전부에서 가장 이른 잠금해제를 매번 다시 고른다 — 이월 상태가 없어 도착 순서와 무관하다.
* WAKE(기상) 평가기 — 도달형(§2.6, §2.11). <b>일어나서 걸었는가</b>로 본다.
* - 신호: HEALTH 의 STEPS 기록(Health Connect StepsRecord). 구간(startTime~endTime)이 있는 기록만 쓴다 —
* 「언제 걸었는지」가 판정의 전부라, 구간 없는 하루 누적값은 근거가 되지 못한다.
* - 창: [하루 시작, beforeTime + graceMinutes]. 창에 걸친 기록은 겹친 비율만큼만 센다.
* - 판정: 창 안 걸음 ≥ minSteps → SUCCESS / 창 닫힘·미달 → FAILED(WOKE_UP_LATE) / 그 외 → PENDING.
* - 출처 게이트는 움직임(HEALTH)과 같다 — 손입력·신뢰 밖 출처는 뺀다. 폰과 워치가 같은 걸음을 각자
* 기록하면 겹친 구간은 한 번만 센다.
* - 그날 원본 전부를 매번 다시 읽는다 — 이월 상태가 없어 도착 순서와 무관하다.
*/
@Component
public class WakeEvaluator implements MethodEvaluator {

private static final DateTimeFormatter HHMM = DateTimeFormatter.ofPattern("HH:mm");

@Override
public VerificationMethod method() { return VerificationMethod.WAKE; }

Expand All @@ -34,47 +38,60 @@ public EvaluationOutcome evaluate(DayContext ctx) {
return EvaluationOutcome.pending(null, null); // 잘못된 config는 무시(PENDING 유지)
}

Instant windowCloses = TimeWindows.atTime(ctx.targetDate(), cfg.beforeTime(), ctx.zone());
Instant dayStart = TimeWindows.startOfDay(ctx.targetDate(), ctx.zone());
Window window = new Window(dayStart, windowCloses);
Instant target = TimeWindows.atTime(ctx.targetDate(), cfg.beforeTime(), ctx.zone());
Instant windowCloses = target.plusSeconds(60L * Math.max(cfg.graceMinutes(), 0));
Window window = new Window(TimeWindows.startOfDay(ctx.targetDate(), ctx.zone()), windowCloses);

// 그날 원본 중 창 안에서 가장 이른 잠금해제
Instant firstUnlock = earliestUnlockInWindow(ctx.signals(), window);
Set<String> rejected = new LinkedHashSet<>();
double steps = stepsInWindow(ctx.signals(), window, cfg, rejected);

Map<String, Object> evidence = new HashMap<>();
evidence.put("beforeTime", cfg.beforeTime());
if (firstUnlock != null) evidence.put("firstUnlockAt", firstUnlock.toString());
evidence.put("graceMinutes", cfg.graceMinutes());
evidence.put("deadline", LocalTime.ofInstant(windowCloses, ctx.zone()).format(HHMM));
evidence.put("metric", HealthMetric.STEPS.name());
evidence.put("unit", "count");
evidence.put("value", Math.round(steps));
evidence.put("goal", cfg.minSteps());
if (!rejected.isEmpty()) evidence.put("rejectedOrigins", new ArrayList<>(rejected));

EvaluationOutcome outcome;
if (firstUnlock != null) {
outcome = EvaluationOutcome.success(evidence, windowCloses); // 창 내 기상 → 즉시 성공
} else if (window.isClosed(ctx.now())) {
outcome = EvaluationOutcome.violated("WOKE_UP_LATE", evidence, windowCloses); // 창 닫힘·미발생
} else {
outcome = EvaluationOutcome.pending(evidence, windowCloses); // 아직 대기
if (cfg.minSteps() > 0 && steps >= cfg.minSteps()) {
return EvaluationOutcome.success(evidence, windowCloses); // 창 안에서 움직였다 → 즉시 성공
}
return outcome;
}

/** 화면 이벤트를 실어 오는 입력 타입. 기상 전용(WAKE)과 앱 사용과 함께 오는 것(SCREEN_TIME) 둘 다다. */
private boolean carriesScreenEvents(String type) {
return SignalType.WAKE.name().equals(type) || SignalType.SCREEN_TIME.name().equals(type);
if (window.isClosed(ctx.now())) {
return EvaluationOutcome.violated("WOKE_UP_LATE", evidence, windowCloses);
}
return EvaluationOutcome.pending(evidence, windowCloses);
}

private Instant earliestUnlockInWindow(List<SyncSignal> signals, Window window) {
if (signals == null) return null;
Instant earliest = null;
/** 창 안 걸음 합 — 출처 게이트를 통과한 구간 기록만, 창에 겹친 비율만큼, 겹친 기록은 한 번만. */
private double stepsInWindow(List<SyncSignal> signals, Window window, WakeConfig cfg, Set<String> rejected) {
if (signals == null) return 0;
List<HealthEvaluator.Measured> clipped = new ArrayList<>();
Set<String> seenRecords = new HashSet<>();
for (SyncSignal s : signals) {
if (!carriesScreenEvents(s.type()) || s.screenEvents() == null) continue;
for (ScreenEvent e : s.screenEvents()) {
// 기상 판정은 "당일 첫 잠금 해제"다(인증 정책 §1.1). 화면만 켜지는 일은 알림 확인으로도
// 흔해서, SCREEN_ON 을 기상으로 세면 자는 사람이 깬 것으로 잡힌다.
if (!"UNLOCK".equalsIgnoreCase(e.event())) continue;
Instant at = TimeWindows.parseInstant(e.at());
if (at == null || !window.contains(at)) continue;
if (earliest == null || at.isBefore(earliest)) earliest = at;
if (!SignalType.HEALTH.name().equals(s.type()) || s.readings() == null) continue;
for (HealthReading r : s.readings()) {
if (r == null || !HealthMetric.STEPS.name().equalsIgnoreCase(r.metric())) continue;
String gate = HealthEvaluator.gateReason(r, true, cfg.trustedOrigins());
if (gate != null) {
String origin = (r.origin() != null && r.origin().dataOrigin() != null) ? r.origin().dataOrigin() : "?";
rejected.add(origin + ":" + gate);
continue;
}
Instant from = TimeWindows.parseInstant(r.startTime());
Instant to = TimeWindows.parseInstant(r.endTime());
if (from == null || to == null || !to.isAfter(from) || r.value() == null) continue;
if (r.recordId() != null && !seenRecords.add(r.recordId())) continue; // 재전송은 한 번만

Instant start = from.isBefore(window.start()) ? window.start() : from;
Instant end = to.isAfter(window.end()) ? window.end() : to;
if (!end.isAfter(start)) continue; // 창 밖 기록
double share = (double) (end.getEpochSecond() - start.getEpochSecond())
/ (to.getEpochSecond() - from.getEpochSecond());
clipped.add(new HealthEvaluator.Measured(start, end, r.value().doubleValue() * share));
}
}
return earliest;
return HealthEvaluator.dedupedSum(clipped);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ private static String signalTypeOf(VerificationMethod method) {
case GPS_PRESENCE, GPS_DISTANCE -> "LOCATION";
case HEALTH -> "HEALTH";
case SCREEN_TIME -> "SCREEN_TIME";
case WAKE -> "WAKE";
case WAKE -> "HEALTH";
case SLEEP -> "SLEEP";
default -> "UNKNOWN";
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public static Set<String> of(VerificationMethod method) {
Set.of("GEOFENCE", "GEOFENCE_TRANSITION", "LOCATION", "RUNNING_SESSION");
case HEALTH -> Set.of("HEALTH");
case SCREEN_TIME -> Set.of("SCREEN_TIME", "USAGE", "APP_USAGE");
case WAKE -> Set.of("SCREEN_TIME", "WAKE", "UNLOCK", "USAGE");
case WAKE -> Set.of("HEALTH"); // 기상은 일어나 걸은 걸음으로 본다(2026-10-15)
case SLEEP -> Set.of("SLEEP");
default -> Set.of();
};
Expand Down
Loading
Loading