Skip to content

통계 투영 정책·세대 계약 — 정책 버전·반올림·null/0·순서·세대·비교 제외 열 (이슈 #1286, D01)

2026-09-10 D06 #1416: 수치 정책은 유지하고 기간·달력 계산 작성자를 통합했다. 입력의 완료/세대 검증, 원시 합·분모·최대값, full/증분 동치는 기간·달력 계약작업 기록을 따른다. 앱 #1518로 release/v0.18.0에 반영됐으며 운영 반영 완료가 아니다.

한 문장 — 통계 숫자를 만드는 규칙(정책)은 값 그대로 두고, 그 규칙이 어디에 적혀 있고 어느 표가 어느 버전으로 계산됐는지, 소수를 어디서 몇 자리로 자르는지, 비어 있는 값과 0 을 어떻게 구분하는지, 같은 날 여러 세트·세션이 있을 때 무엇이 먼저인지, 세대 번호가 무엇을 약속하는지를 한 곳에 적은 계약이다. D02·D04~D11 은 계산 구조를 바꾸면서 이 문장들이 그대로 참인지 를 §9 의 불변식과 oracle 코퍼스로 증명한다. 짝 문서 = 계산 DAG·작성자 계약.

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.mdstrength_estimation_policy_versions(활성 1행 unique) — breaking_fingerprint_sha256user_exercise_strength_observations·_daily·_statespolicy_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.jsonmax_rep_estimation_policy_versions(활성 1행)user_exercise_max_rep_observations.policy_id/version · user_exercise_session_rollups.max_rep_policy_id/versioncalculate_max_rep_observation_v2 가 다른 버전을 거부 · pgTAP max_rep_estimation_policy_v1(49)
세트 목적 분류set-purpose v3docs/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.tsattendance_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_projectionpr_events·pr_states(세대만)test/measuredPrProjectionPolicy.test.mjs(10)
반복수 투영(#1256)기록 유형에 없는 반복수는 통계에서 null원본 불변 §2 R4트리거 project_exercise_set_part_values_v1exercise_set_part.stats_repspgTAP reps_projection_stats_v1

계약 문장

  • P-1 정책 버전을 올리면 전체 재계산(requires_full_replay)이다. 투영 행의 policy_version 이 활성 버전과 다르면 그 행은 stale 이며 발행 대상이 아니다(§6 규칙 6).
  • P-2 세션 합계·기록·종목 통계·기간 통계·훈련 기간·달력 표에는 정책 버전 열이 없다. 이 표들의 "어느 정책으로 계산됐는가" 는 세대(§6)로만 판정한다 — 세대 N 은 그 시점의 활성 정책 튜플로 계산된 것이다. 목표 구조에서 이 표들에 정책 열을 둘지는 D11 이 정한다(둔다면 장부 excludedpolicy 사유로 등재).
  • 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/upper1kg 정수(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 < 값 ≤ 5000save_manual_pr
RPE 입력numeric(3,1), 1.0~10.0열 정의 · calculate_max_rep_observation_v2 거부
화면 표시 kg1kg 정수(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, lowe1RM 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 → idpolicy.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-6stale 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_fromrequested > 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_generationsexercise_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잡 idsnapshot_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-11회 완료 @ 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_countperiod·calendar·rollups
P-1pr_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-1attendance_metday_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-1control 불변식(§6) 전부state·jobs·headers
M-1최대 반복수 관측: rep_failure 세트는 lower = representative = upper = reps, confidence highmax_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_v1test/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_projectiontest/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_loadspgTAP 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_v1pgTAP set_score_recent_performed_v1(33) · set_score_report_bands_v1(25) · composite_set_score_v1(26)
§4 반복수 null트리거 project_exercise_set_part_values_v1pgTAP reps_projection_stats_v1(12+6)
§5·§8 측정 PRstats/functions/refresh_user_measured_pr_projectiontest/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_frompgTAP seoul_report_date_boundary(7) · session_timing_report(19)
§2 출석·볼륨 레벨home/functions/apply_training_attendance_policy_v1 · volume/functions/volume_level_progress_v1pgTAP attendance_policy_v1(9) · volume_level_policy_v1(9) · volume_overview_internalization(3)
§3 파운드 반올림statisticalLoadRoundingtest/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_jobspgTAP 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 신고 대상이다.