통계 투영 정책·세대 계약 — 정책 버전·반올림·null/0·순서·세대·비교 제외 열 (이슈 #1286, D01)
2026-09-10 D06 #1416: 수치 정책은 유지하고 기간·달력 계산 작성자를 통합했다. 입력의 완료/세대 검증, 원시 합·분모·최대값, full/증분 동치는 기간·달력 계약과 작업 기록을 따른다. 앱 #1518로 release/v0.18.0에 반영됐으며 운영 반영 완료가 아니다.
한 문장 — 통계 숫자를 만드는 규칙(정책)은 값 그대로 두고, 그 규칙이 어디에 적혀 있고 어느 표가 어느 버전으로 계산됐는지, 소수를 어디서 몇 자리로 자르는지, 비어 있는 값과 0 을 어떻게 구분하는지, 같은 날 여러 세트·세션이 있을 때 무엇이 먼저인지, 세대 번호가 무엇을 약속하는지를 한 곳에 적은 계약이다. D02·D04~D11 은 계산 구조를 바꾸면서 이 문장들이 그대로 참인지 를 §9 의 불변식과 oracle 코퍼스로 증명한다. 짝 문서 = 계산 DAG·작성자 계약.
- 이슈: #1286 (D01). 보존 정책 P2(ADR §2) 의 통계 부분을 문장으로 옮긴 것이며 숫자를 바꾸지 않는다 — 값을 바꾸는 것은 오너 결정과 정책 버전 올림(e1RM CHANGELOG 절차)이 필요하다.
- 정본 정책 파일:
docs/policies/e1rm/policy.json·docs/policies/max-reps/·docs/policies/set-purpose/· 측정 지표 계약 · 측정 PR 이벤트. 이 문서는 그 파일들을 가리키고 요약한다 — 값이 다르면 정책 파일이 맞다. - 기계 판독 장부:
supabase/contracts/stats-projection-writers.jsonexcluded·excludedReasons(§7). 검사:tests/db/statsProjectionWriters.test.mjs③(제외 열·사유),tests/db/statsProjectionDeterminism.test.mjs(§9 불변식·결정성). - 문서 버전 v1 (2026-09-07). 읽은 코드 = main
beff760b.
1. 읽는 법 — 유저 A 의 100kg × 5회
유저 A 가 스쿼트 100kg 을 5회 하고 RPE 8 을 적었다. 이 세트 하나에서 숫자가 이렇게 나온다.
| 순서 | 무엇이 정해지나 | 값 | 이 문서의 절 |
|---|---|---|---|
| ① | 유효 무게 = 든 무게 × 배수 + 체중 몫 − 보조 무게, 소수 2자리 | 100.00 kg | §3, §8 |
| ② | 반복수 5 + RIR 2(RPE 8) → 유효 반복수 7 → 곡선 표에서 %1RM 을 역보간 → e1RM 대표값 | 정책 e1rm 3.0.0 · 곡선 1.0.0, 6자리 반올림 | §2, §3 |
| ③ | 같은 날 다른 세트와 함께 "그날의 대표 e1RM" 을 정보량 가중 중앙값으로 고른다. 정확히 갈리면 낮은 값 | §5 | |
| ④ | 세트 스코어 = e1RM 대표값 ÷ 최근 수행 1RM × 10, 소수 1자리 | 예: 8.3 | §3 |
| ⑤ | 볼륨 = 유효 무게 × 반복수의 합(2자리), 기간 표·달력에 더해진다 | 500.00 | §3 |
| ⑥ | 세트가 완료 main 세트이고 1~20 회면 "5RM 측정 후보" — 이전 5RM 보다 크면 PR 이벤트, 같으면 아무 일도 없음 | §5, §8 | |
| ⑦ | 이 모든 값이 세대 N 으로 계산돼 applied_version = N 이 될 때 화면이 믿는다 | §6 |
2. 정책 버전 — 무엇이 어디에 고정돼 있나
| 정책 | id@version | 정본 | DB 고정 | 투영 행에 새겨지는 곳 | 검사 |
|---|---|---|---|---|---|
| 추정 1RM(e1RM) | lift-guild.e1rm@3.0.0 (2026-09-04, v1 07-24 · v2 08-12, 전부 requires_full_replay) | docs/policies/e1rm/policy.json + CHANGELOG.md | strength_estimation_policy_versions(활성 1행 unique) — breaking_fingerprint_sha256 | user_exercise_strength_observations·_daily·_states 의 policy_id/policy_version | 무결성 엔진이 활성 행 = 하드코딩 튜플('lift-guild.e1rm','3.0.0',…,'EBA6…')을 대조 · test/e1rmPolicy.test.mjs(정책·곡선·CSV 지문) |
| 반복수→%1RM 곡선 | nuzzo-2024-general-inverse@1.0.0 (Nuzzo 2024 일반 곡선, 제품 anchor 1회=100%) | 같은 policy.json curve · docs/policies/e1rm/v1/nuzzo-2024-general.csv(17행, 15~95%) | strength_estimation_curve_versions·_curve_points·_integer_lookup_snapshots | 위 3표의 curve_id/curve_version | 무결성 엔진 golden_lookup(정수 룩업 스냅샷 재평가, 오차 1e-6) |
| 최대 반복수 추정 | barbelic.max-reps@2.0.0 (v1 5구간은 폐기) | docs/policies/max-reps/v2/policy.json | max_rep_estimation_policy_versions(활성 1행) | user_exercise_max_rep_observations.policy_id/version · user_exercise_session_rollups.max_rep_policy_id/version | calculate_max_rep_observation_v2 가 다른 버전을 거부 · pgTAP max_rep_estimation_policy_v1(49) |
| 세트 목적 분류 | set-purpose v3 | docs/policies/set-purpose/ | set_purpose_policy_versions | 새겨지지 않음(stimulus_class 값만) | pgTAP(validate_user_set_purpose_integrity) |
| 세트 스코어 | v1(#1164·#1189·#1202) | 이 문서 §3·§8 + 세트 스코어 용어 | 없음(함수 set_score_observations_v1·set_score_recent_next_v1) | 없음 | pgTAP set_score_recent_performed_v1(33)·set_score_report_bands_v1(25)·composite_set_score_v1(26) |
| 출석 | 주 3일 / 월 12일 | src/react/contracts/attendancePolicy.ts ↔ attendance_weekly_target_days_v1()·attendance_monthly_target_days_v1() | IMMUTABLE 함수 | user_training_period_stats.attendance_* (버전 없음) | pgTAP attendance_policy_v1(9) |
| 볼륨 레벨 | 30단 요구량 표 + 31단부터 100,000 + 500/단 | volume_level_progress_v1(프런트 표와 바이트 동일해야 함) | 함수 상수 | 없음 | pgTAP volume_level_policy_v1(9) |
| 유효 무게 | #1200 공식(§8) | training_effective_load · 유효 무게 문서 | 함수 | exercise_set_part.stats_effective_load_kg(투영 등급) | pgTAP effective_load_* 3파일(41) |
| 측정 PR(nRM) | 1~20RM 정확 반복수·실측만·엄격 증가·동률 무이벤트 | 측정 PR 이벤트 · 측정 지표 | 함수 refresh_user_measured_pr_projection | pr_events·pr_states(세대만) | test/measuredPrProjectionPolicy.test.mjs(10) |
| 반복수 투영(#1256) | 기록 유형에 없는 반복수는 통계에서 null | 원본 불변 §2 R4 | 트리거 project_exercise_set_part_values_v1 | exercise_set_part.stats_reps | pgTAP reps_projection_stats_v1 |
계약 문장
- P-1 정책 버전을 올리면 전체 재계산(
requires_full_replay)이다. 투영 행의policy_version이 활성 버전과 다르면 그 행은 stale 이며 발행 대상이 아니다(§6 규칙 6). - P-2 세션 합계·기록·종목 통계·기간 통계·훈련 기간·달력 표에는 정책 버전 열이 없다. 이 표들의 "어느 정책으로 계산됐는가" 는 세대(§6)로만 판정한다 — 세대 N 은 그 시점의 활성 정책 튜플로 계산된 것이다. 목표 구조에서 이 표들에 정책 열을 둘지는 D11 이 정한다(둔다면 장부
excluded에policy사유로 등재). - P-3 정책 상수를 코드에 복제하지 않는다. 앱은 정책 파일·한도 장부의 값을 호출자가 넘기고(G03 §5), SQL 은 정책 표를 읽는다. 복제된 상수(출석 3일·볼륨 표)는 pgTAP 이 프런트 값과 대조한다.
3. 반올림·정밀도 — 어디서 몇 자리로
| 값 | 자릿수 | 어디서 | 근거 |
|---|---|---|---|
| 곡선 %1RM 보간 결과 | 6자리 | strength_estimation_load_percent_v1 round(…, 6) | policy.json calculation.rounding.curve_percent_decimal_places |
유효 무게(stats_effective_load_kg) | 2자리, 0 이상 | training_effective_load | #1200 |
체중 몫(bodyweight_share_kg) | 2자리 | 근력 core L151 | |
| e1RM lower/representative/upper | 1kg 정수(half-up) — 관측·일별·상태 표에 저장되는 값 자체가 정수 kg 이다(예: 100×5 의 111.28 → 111, 105×5 의 116.84 → 117). 곡선 보간은 6자리로 하고 저장 직전에 한 번 반올림한다 | 관측·일별·상태 | policy.json calculation.rounding.published_projection_kg {increment 1, mode half_up} · 샌드박스 oracle 대조로 확인(2026-09-07) |
든 무게·유효 무게(관측 행의 stats_load_kg·stats_effective_load_kg) | 2자리 그대로 | 관측 | policy.json source_load_kg: preserve |
| 볼륨 합 | 2자리 | round(coalesce(sum(volume),0),2) 기간·훈련 기간 | 열 numeric(12,2) |
| 최고 무게·최고 추정 1RM(기간·종목 통계) | numeric(8,2) | 열 정의 | |
강도 %(set_intensity_percent) | 2자리 | round(load / reference × 100, 2) 근력 core | |
| 세트 스코어 | 1자리 | round(e1rm / recent × 10, 1) · 복합은 세트 행 평균을 다시 1자리 | set_score_observations_v1 |
| 세트 스코어 합·평균 | numeric(12,1) · numeric(5,1) | 열 정의 | |
| 수동 PR 값 | 2자리, 0 < 값 ≤ 5000 | save_manual_pr | |
| RPE 입력 | numeric(3,1), 1.0~10.0 | 열 정의 · calculate_max_rep_observation_v2 거부 | |
| 화면 표시 kg | 1kg 정수(half-up) — 표시에서만 | src/react/kgDisplay.ts roundKg | 오너 결정 2026-08-31(#994) |
| 파운드 원산 무게 | 파운드 성분만 정수 kg 로 | test/statisticalLoadRounding.test.mjs |
계약 문장 — R-1 반올림은 위 표의 자리에서만 한다. 중간 계산은 numeric 그대로 두고, 동치 비교(§7)는 열의 정밀도로 비교한다. 허용 오차를 두는 곳은 곡선 보간 값(1e-6) 하나다. R-2 표시용 1kg 반올림은 투영에 저장하지 않는다(원본 불변 §3 — 원본은 초·kg 숫자, 표기는 화면에서).
4. null 과 0 — 비어 있음과 없음의 구분
| 값 | null 의 뜻 | 0 의 뜻 | 근거 |
|---|---|---|---|
stats_reps | 기록 유형에 반복수가 없어 통계에서 뺀 세트 | 실제로 0회(실패 세트) | #1256 — 0 은 "무게만 있는 실패 세트" 규칙과 충돌하므로 null 로 |
completed_reps(=reps) | (허용 안 됨) | rep_failure 일 때만 0 허용 | policy.json input_contract.completed_reps |
set_result | (저장 안 됨) | — | completed / rep_failure 둘뿐. aborted 는 저장하지 않는다 |
perceived_rpe | 미입력 → effective reps 0, conditional_open, low | — | e1RM README 표 |
load | 무게 없는 세트(반복수만·체중) | 0kg 든 것으로 취급 → 유효 무게에서 체중 몫만 | training_effective_load 가 null 을 0 으로 |
assist_kg | 보조 없음 | 보조 0 | 둘 다 빼는 값 0 |
| 세트 스코어 | 분모(최근 수행 1RM·최대 반복수) 없음 → 점수 없음 | (없음 — ≤ 0 은 null 로) | set_score_recent_next_v1 |
| e1RM 대표값 | 0회 완료 실패 세트(censored_high) · 지원 범위 밖 | — | 대표값 없이 상한 단서만 |
recorded_at(수동 PR) | 날짜 모름 = 온보딩 baseline(모든 날짜 앞) | — | is_baseline = recorded_at is null |
| 기간 통계 합계 | (행이 없음 = 그 기간 기록 없음) | coalesce(sum, 0) | 행이 있으면 합은 0 이상 |
average_set_score·average_set_intensity_percent | 대상 세트 0개 | — | 0 으로 쓰지 않는다 |
계약 문장 — N-1 "없음" 은 null, "0 이었다" 는 0. 시스템이 null 을 0 으로 바꿔 저장하지 않는다(합계의 coalesce 는 집계 결과에만). N-2 화면·oracle 기대값에서 null 은 "값 없음" 으로 적고 0 과 구분해 비교한다.
5. 같은 날·같은 값 — 순서와 동률
| 상황 | 규칙 | 근거 |
|---|---|---|
| 세션 안 세트 정보량 정렬(e1RM 투표 후보) | confidence high→medium→low → 반복 실패 관측 → 주관 체감 → set type top→main→warmup → 지원 유효 반복수 오름차순 → 대표 e1RM 내림차순 → position → id | policy.json aggregation.set_information_sort · 근력 core L186·L255 |
| 세션 한 표 | 정보량 상위 3개의 가중 중앙값(high 4·medium 2·low 1). medium/high 가 하나라도 있으면 low 제외. 정확히 갈리면 낮은 값 | policy.json weighted_median_tie_break: lower_value |
| 하루 대표 e1RM | 세션 표들의 가중 중앙값 한 점. 동점은 e1rm asc, session_created_at asc, session_id asc, source_set_id asc 첫 행 | 근력 core L318~331 |
세션 순서(session_order_at) | (date + start_time) at time zone 'Asia/Seoul', 시각 없으면 created_at | 근력 core L141 |
| 측정 PR 같은 날 여러 후보 | (exercise, target_reps, achieved_on, session) 안에서 value desc, exact_load desc, priority, position, created_at, id 첫 행 하나만 | refresh_user_measured_pr_projection L94~102 |
| 측정 PR 시간선 | is_baseline desc, achieved_on asc nulls first, source_created_at asc, session/source_id asc, position asc, source_id asc. 엄격히 큰 값만 이벤트, 같으면 이전 달성일 유지 | 측정 PR 이벤트 |
| 수동 PR baseline 여러 개 | value desc, exact_load desc, created_at desc, id desc 첫 행 | 같은 함수 L90 |
| 기간 경계 | date_trunc(period, date) — 주는 ISO 월요일, 분기·년 KST 날짜 기준 | refresh_user_training_period_stats_from |
| 리포트 기준일 | KST 오늘로 상한(canonical_seoul_report_as_of_v1) | |
| 스냅샷 창 | as_of_date ∈ [오늘−1, 오늘+1](KST) 3행 | guard_user_stats_refresh_projection_publication |
| 유산소 거리 밴드 | 기록 거리 ≥ 기준 이고 ≤ 기준 × 1.03, 동률은 먼저 달성한 날 | 측정 지표 계약 |
계약 문장 — O-1 위 순서 키는 전부 논리 값(날짜·시각·position·논리 id)이며 무작위 uuid 만으로 순서를 정하는 곳은 없다(uuid 는 마지막 tie-break 로만). 따라서 같은 입력을 다시 계산하면 같은 행이 선택된다 — 결정성 검사(§9 D-1)가 이것을 잰다. O-2 새 작성자는 같은 키·같은 방향으로 정렬해야 한다. 키를 바꾸는 것은 정책 변경이다.
6. 세대(generation)·스냅샷 계약
지금의 열: user_stats_refresh_state.requested_version(요청, enqueue 가 +1) · applied_version(적용, 잡 completed 트리거가 올림) · dirty_from. 완료 기록 영수증 v2 stats_requested_version = 자기 enqueue가 원자적으로 배정한 세대. 계획 쓰기(계획 저장·계획 삭제)는 통계를 바꾸지 않으므로 0 을 기록한다(D02 #1327 — 영수증 v2 계약·G03 §9 와 같은 값. 저장·삭제 엔진은 유저 전체 세대를 잠금 밖에서 읽지 않는다). 잡 target_version = 그 잡이 만들 세대. 앱 계약 = G03 §9 StatsGeneration{requested, applied, stale, dirtyFrom} · isGenerationPublished.
동시 저장(#1324, D02 선행 수리): 기존 UUID 반환 enqueue는 호환 wrapper로 유지하고 내부 core가 (job_id, target_version)을 반환한다. 기존 enqueue 잠금 안에서 세대가 정확히 +1인지 확인한다. 저장·삭제는 사용자별 transaction-local enqueue 계수의 전후 차이로 완료 1회·계획 0회를 확인하므로 다른 연결의 cron·저장을 자기 호출로 세지 않는다. 실패한 호출은 계수도 원본·영수증·dirty job과 함께 rollback된다. 이 계수는 진단용이며 사용자 신원은 기존 명시 인자로만 전달한다. 두 영수증 사이에 별도 enqueue가 있으면 세대가 1보다 크게 차이 날 수 있다. 같은 mutation 재생과 같은 source_ref 생성 재생은 먼저 확정된 영수증의 세대를 유지한다(계획이면 0). 실제 두 연결 회귀는 tests/db/workoutGenerationConcurrency.test.mjs를 전용 sandbox에서 단독 실행한다(D01과 같은 cron 격리 잠금을 사용한다) — #1345 의 9건에 D02 마무리(#1327)가 같은 세션 수정 vs 삭제·계획→완료 전이 vs 별도 enqueue·계획 삭제(세대 0)·세 단계 실패 주입(달력 dirty·자식 세트·통계 잡) 4건을 더했다.
| # | 규칙 | 지금 | 목표(소유) |
|---|---|---|---|
| G-1 | 요청 세대는 단조 증가하고 통계에 영향을 주는 mutation은 enqueue 한 번으로 정확히 +1을 배정받는다(계획 쓰기는 0회). 영수증은 그 호출에 배정된 세대를 보존한다 | enqueue_user_exercise_stats_refresh_job_core의 잠금 안 +1 단언·save_session_v5_engine/delete_session_v5_engine의 호출 횟수 단언 | 유지(D02, #1324 경합 수리) |
| G-2 | 적용 세대는 요청 세대를 넘지 않고, 같은 세대의 형제 잡이 남아 있으면 오르지 않는다 | sync_user_stats_refresh_state_from_job | 유지(D11) |
| G-3 | 한 세대 = 한 판. 활성 경로의 재계산은 새 요청 세대를 사용한다 | D10의 롤오버·repair가 dirty enqueue를 소비한다 | 유지(D10); D11의 full/부분 계산도 같은 publisher 사용 |
| G-4 | 발행 = 모든 투영이 그 게시 세대의 완성 결과로 준비된 뒤 owner의 applied_version 이 오른다. 검증된 변경 없는 행은 이전 생성 세대를 유지할 수 있다 | D11 validate_stats_projection_payload_v1이 18개 출력, owner/source/정책/anchor, 단계 완료·행 수, 새 행의 세대와 재사용 행의 provenance 및 checkpoint family를 검사한다 | 같은 트랜잭션에서 출력·completed/applied 확정. source가 바뀌면 40001로 폐기 |
| G-5 | 잡 하나는 원자다. 10단계 중 하나라도 실패하면 savepoint 롤백으로 그 잡의 모든 쓰기가 사라지고 잡은 failed(설정 max_attempts, 기본 3회까지 pending 재큐). "부분 성공" 상태의 표는 없다 | 격리 worker compute/publish 예외 블록·process_user_exercise_stats_refresh_jobs. 재시도 상한·backoff 는 stats_projection_worker_settings(D08 #1412) | 유지(D08 #1412 landed) — 사용자별 worker 에서도 잡 원자성 유지, 실패는 시도 비례 backoff 뒤 재큐 |
| G-6 | stale worker: 격리 worker 의 durable 임대는 lease_until(기본 5분)이 지나면 만료된다. 살아 있는 worker 는 그 run 의 행 잠금을 계산 내내 쥐므로(만료 회수가 for update skip locked 로 건너뛴다) 단계 경계에서 임대를 갱신하고 끝낸다. 죽은 worker(만료·행 잠금 없음)의 잡은 다음 claim 이 backoff 없이 회수한다. 만료 뒤 다른 worker 가 새 토큰으로 집으면, 옛 worker 의 늦은 쓰기는 울타리 트리거 stats_projection_run_lease_fence_v1(55P03)로 거부돼 세대를 올리지 못한다. statement_timeout(57014)은 사용자 worker 가 보고하고 잡을 남긴다 | 임대 fence·만료 회수·단계 경계 heartbeat(D08 #1412 landed). 동기 process_…_for_user 는 격리 임대가 살아 있으면 미룬다(deferred) | 유지(D08 #1412) |
| G-7 | 실패 범위: 실패한 잡의 세대는 적용되지 않으므로 화면은 이전 적용 세대의 숫자를 계속 믿고 stale=true 로 표시한다(G03 §15 stats_published 아님) | last_error 열 | 유지 |
| G-8 | 세대 번호는 비교에서 제외(§7). 두 계산의 동치는 세대가 아니라 내용으로 판정한다 | 장부 generation 사유 | 유지 |
control 표 불변식(동치 비교 대신 검사하는 것 — §9 C-*): applied ≤ requested · pending 잡의 target_version ≤ requested · completed 잡의 target_version ≤ applied · 같은 사용자의 processing 잡은 트랜잭션 밖에서 0개 · 스냅샷 헤더의 최신 세대 ≤ applied · dirty_from 은 requested > applied 일 때만 non-null.
D11 해석: processing=0은 큐 소진 뒤 검사다. 격리 worker가 계산 중일 때 durable processing/lease가 존재하는 것은 정상이다. source는 요청 버전과 catalog/policy token이며 계산 전후·게시 잠금 뒤 비교한다. 캡처 뒤 바뀐 원본판은 최신 세대로 위장해 게시할 수 없다. 전환·재시도 계약을 따른다.
2026-09-10 추가 승인 계약(구현·검사·병합 진행 중): owner의 applied는 결과 전체의 게시 번호, 통계 행의 applied·materialized 시각은 그 행의 계산 출처다. 15개 통계표는 값·정책·실제 source와 시각이 같으면 이전 provenance를 유지한다. 무결성·checkpoint reader는 과거라는 이유만으로 유효한 재사용 행을 거부하지 않으며, 정책·원본 경계 검증 없이 낮은 세대를 허용하지도 않는다. 바뀐 새 후보의 provenance를 임의로 낮추는 것은 재사용이 아니다.
PR summary의 포함 관계는 snapshot_headers.row_generations의 exercise_id → summary generation으로 확정한다. 같은 요약 행을 여러 publication이 참조할 수 있으며 최신·직전 조회는 각 header에 속한 운동과 순서를 읽는다. 기존 {} header는 정확히 같은 generation 행을 읽는다. 현재·직전 publication의 참조가 남은 summary와 필요한 header는 보존하고, 참조 없는 오래된 backing row/header만 정리한다. source·순위·포함 운동이 달라진 요약을 동일 결과로 취급하지 않는다.
7. 비교 제외 열 — 사유와 목록
정본 = 장부 tables.<표>.excluded(열 → 사유) 와 excludedReasons(사유 → 설명). 검사 ③ 이 열 존재·사유 정의·논리 키와 겹침 없음을 확인한다.
| 사유 | 뜻 | 예 |
|---|---|---|
clock | 쓰는 시각 | updated_at·materialized_at·refreshed_at |
source_clock | 원본 행의 갱신 시각에서 온 열(같은 적재 안에서만 같다) | calendar_day_summaries.source_updated_at·summary_snapshots.stats_updated_at·relevant_at |
generation | 세대 번호 | applied_version·generation·strength_applied_version |
random_id | 무작위 uuid PK — 논리 키로 대신 맞춘다 | records.id·pr_events.id |
random_ref | 무작위 uuid 참조 — 가리키는 행의 논리 키로 바꿔 비교 | pr_states.current_event_id·summary_snapshots.current_1rm_event_id |
job_ref | 잡 id | snapshot_headers.source_job_id |
attempt | 시도 횟수·오류 | control 표 |
sequence | 시퀀스 값을 새긴 열 — 롤백된 트랜잭션도 시퀀스를 전진시켜 병렬 세션이 있으면 같은 입력에서도 달라진다(D13 랜딩 중 tests/db 병렬 실행에서 실측, 2026-09-07) | snapshot_headers.catalog_version(카탈로그 순번 exercise_catalog_latest_seq_v1) |
제외하지 않는 것: 세션·세트 uuid 참조(best_load_session_id·best_estimated_1rm_set_id·source_set_id) — 같은 적재 안에서 같은 값이므로 비교한다. 다른 적재끼리 비교할 때(기준 스냅샷 vs 새 적재)는 스냅샷 생성기가 세션 uuid 를 (user, date, 세션 순번) 논리 키로 바꾼다.
D11은 비교 제외 열을 넓히지 않는다. PR 요약의 최근 활동은 계산 시각 대신 canonical 세션 수정 시각을 사용하고, 최근 PR의 동률은 재생성한 이벤트 UUID 대신 원본 논리 키로 정한다. summary_rank와 DTO 배열 순서는 계속 비교한다. 업무 날짜·원본 생성 시각·수치·null/0·PR 근거는 제외하지 않는다. 이는 재계산만으로 표시 순서가 달라지던 비결정성을 없애며 수치 정책은 유지한다.
이 장부의 oracle 비교 제외와 public 행의 변경 판정은 구분한다. D11의 행 재사용 비교는 실제 source_updated_at를 포함하며, 값이 같아도 원본 시각이 바뀌면 게시한다. 동일 원본 새 세대의 물리 보존 검사는 16개 재사용표의 전체 행과 tuple을 비교하고 I/U/D를 센다. source 필드·수치·정렬·DTO를 제외하여 쓰기 0을 만드는 것은 허용하지 않는다. 전체 full 계산과 임시 표 복사 비용을 줄였다고 이 검사로 주장하지 않는다.
8. 유효 무게·manual baseline·PR 근거 (요약 — 정본은 링크)
- 유효 무게(#1200):
round(max(max(load,0) × m + 체중몫 − max(assist,0), 0), 2),m ∈ {1,2}아니면 1, 체중몫 =body_weight × clamp(factor, 0, 2)(둘 다 > 0 일 때). 계산은 유효 무게 프레임, 표시는 추가 무게 프레임(오너 D9 가져온 덤벨도 ×2). - manual baseline: 수동 PR 은
historical_1rm출처로 측정 시간선에 합류,target_reps = 1. 날짜 없으면 baseline(모든 사실 앞). 더 낮은 수동 값은 이벤트가 아니다. - PR 근거: PR 은 실측만(1~20회 완료 main/top 세트의 든 무게, e1RM 은 절대 이벤트가 되지 않음). 기록 유형(
record-metric.md)이one_rm인 종목만 nRM,max_reps는 반복수 관측,max_hold·distance_time은 세트 원본에서 바로. - 복합 세트(#1189): 관측은
training_complex로 e1RM 투표에서 제외되지만 세트 스코어는 낸다. 세트 행 하나의 여러 세부 세트는 스코어 평균(1자리).
9. 정책 불변식 — 새 구현이 지켜야 하는 독립 검사
oracle 코퍼스(손 계산 기대값)와 기준 스냅샷(옛 구현 결과)에 더해, 구현과 무관하게 참이어야 하는 성질. tests/db/statsProjectionDeterminism.test.mjs 가 샌드박스에서 SQL 로 잰다(구현 표시: ✔ = D01 에서 구현, ⬜ = 후속).
| id | 불변식 | 잰 곳 | D01 |
|---|---|---|---|
| D-1 | 같은 입력을 두 번 full 재계산하면 compare: true 표 전부가 §7 기준으로 같다 | 프로필 1·4년(10년 opt-in) | ✔ |
| D-2 | 재계산 순서(종목 범위 전체 vs 종목 하나씩 나눠)에 관계없이 결과가 같다 | 1년 프로필 | ⬜ D04 |
| E-1 | 1회 완료 @ RPE 10 의 e1RM 대표값 = 유효 무게(product anchor) | 관측 표 | ✔ |
| E-2 | 관측의 e1rm_lower ≤ representative ≤ upper(대표값 있을 때) | 관측 표 | ✔ |
| E-3 | 관측 행 수 = 완료·stats_load_kg > 0·근력 프로필 세트 수 | 관측 vs 원본 | ✔ |
| V-1 | 세션 합계 볼륨 = Σ(유효 무게 × stats_reps) 그 세션×종목 세트, 2자리 | rollups vs 원본 | ✔ |
| V-2 | 기간 통계(day) 볼륨·세트 수 = 그날 세션 합계의 합; week/month/quarter/year = day 합 | period_stats 자기 대조 | ✔ |
| V-3 | 달력 하루 볼륨·세트 수 = 그날 세션 합계의 합 | calendar vs rollups | ✔ |
| V-4 | 강도 4구간 합 = intensity_set_count(CHECK 제약과 동일) · 세트 스코어 4구간 합 = set_score_set_count · RPE 6구간 합 = rpe_set_count | period·calendar·rollups | ✔ |
| P-1 | pr_states.current_value = 해당 (exercise, target_reps) 이벤트 중 최대 value, current_event_id 가 그 이벤트 | pr_states vs pr_events | ✔ |
| P-2 | 이벤트를 achieved_on, source_created_at 순으로 놓으면 value 가 엄격 증가하고 previous_value 가 직전 값 | pr_events | ✔ |
| P-3 | 달력 pr_count = 그날 achieved_on 이벤트 수(baseline 제외) | calendar vs pr_events | ✔ |
| A-1 | 주 attendance_met ⇔ day_count ≥ 3 · 월/분기/년 attendance_week_total = 기간 안 목요일 수 | training_period_stats | ✔ |
| S-1 | 세트 스코어 = round(e1rm_representative / recent_performed_1rm × 10, 1) 가 저장된 recent_performed_1rm_kg 로 재현된다 | 세트 스코어는 저장 열이 없고 함수(set_score_observations_v1)가 낸다 — 기간 합계 구간 검사(V-4)로만 간접 확인 | ⬜ D04 |
| C-1 | control 불변식(§6) 전부 | state·jobs·headers | ✔ |
| M-1 | 최대 반복수 관측: rep_failure 세트는 lower = representative = upper = reps, confidence high | max_rep_observations | ✔ |
계약 문장 — I-1 새 작성자(D02~D11)는 위 불변식을 깨뜨리지 않고, 깨야 한다면 정책 변경으로 오너 결정을 받는다. I-2 불변식·oracle·기준 스냅샷 세 가지가 모두 통과해야 "동치" 다 — 하나만으로 주장하지 않는다(완료 조건 3).
10. 정책 문장 ↔ 근거 함수 ↔ 기존 테스트
| 문장 | 근거 함수(supabase/definitions/…) | 기존 테스트 |
|---|---|---|
| §2 e1RM 정책·곡선 지문 | stats/functions/strength_estimation_load_percent_v1 · validate_user_exercise_stats_integrity_engine_v1 | test/e1rmPolicy.test.mjs(10) · test/strengthEstimationPolicySqlContract.test.mjs · pgTAP e1rm_failure_evidence_v2(18) |
| §2 최대 반복수 | stats/functions/calculate_max_rep_observation_v2 · refresh_user_max_rep_projection | test/maxRepEstimationPolicySqlContract.test.mjs · pgTAP max_rep_estimation_policy_v1(49) · record_metric_max_reps_v1(20) · rollup_max_rep_source_set_clear(1) |
| §3 유효 무게·체중 | workout/functions/training_effective_load · refresh_session_effective_loads | pgTAP effective_load_stats_v1(13) · effective_load_multiplier_assist(19) · effective_load_plan_multiplier_v1(9) · bodyweight_factor_profiles(11) · session_detail_bodyweight_effective_load(6) · calendar_top_sets_effective_load(13) |
| §3·§8 세트 스코어 | stats/functions/set_score_observations_v1 · set_score_recent_next_v1 · refresh_user_set_score_period_stats_v1 | pgTAP set_score_recent_performed_v1(33) · set_score_report_bands_v1(25) · composite_set_score_v1(26) |
| §4 반복수 null | 트리거 project_exercise_set_part_values_v1 | pgTAP reps_projection_stats_v1(12+6) |
| §5·§8 측정 PR | stats/functions/refresh_user_measured_pr_projection | test/measuredPrProjectionPolicy.test.mjs(10) · pgTAP following_pr_overview_v1(6) |
| §5 KST 경계·기간 | db-platform/functions/canonical_seoul_report_as_of_v1 · home/functions/refresh_user_training_period_stats_from | pgTAP seoul_report_date_boundary(7) · session_timing_report(19) |
| §2 출석·볼륨 레벨 | home/functions/apply_training_attendance_policy_v1 · volume/functions/volume_level_progress_v1 | pgTAP attendance_policy_v1(9) · volume_level_policy_v1(9) · volume_overview_internalization(3) |
| §3 파운드 반올림 | 앱 statisticalLoadRounding | test/statisticalLoadRounding.test.mjs(13) |
| §6 세대·발행 | stats/functions/enqueue_user_exercise_stats_refresh_job · sync_user_stats_refresh_state_from_job · guard_user_stats_refresh_projection_publication · process_user_exercise_stats_refresh_jobs | pgTAP stats_refresh_statement_timeout · test/userExerciseStatsIntegrity.test.mjs(3) · tests/react/contracts/commands.test.mjs(G03 isGenerationPublished) |
| §7 원본 등급 | — | tests/react/userFactColumnsContract.test.mjs |
합계: pgTAP 25파일 약 400 단언 + Node 정책 테스트 4파일 36건이 이 문서의 문장을 이미 고정하고 있다. 이 테스트들을 옮기거나 지우는 것은 정책 변경이며 tests/audit/pending-changes.json 신고 대상이다.