기간·달력 단일 계산 작성자 — D06 #1416
2026-09-10. 앱 #1518 / 5b16a162로 release/v0.18.0에 반영한 계약이며 운영 반영 완료가 아니다. 기준 앱 a1d462a8, D05 #1506 포함. 작업 이슈.
입력과 책임
refresh_user_period_calendar_projection_v1(user_id, generation, scope, derived)가 D04 scope와 순차 파생 결과를 받아 종목 기간·훈련 기간·달력 계산을 연결한다. 이 함수는 PR·근력 상태·checkpoint·applied generation을 쓰지 않는다. 각 출력은 하나의 계산 작성자가 전체 행을 완성한다. 기존 publisher의 public 출력 교체는 별도 발행 책임으로 유지한다.
derived v1은 version, user_id, generation, complete, mode, replay_from, exercise_ids, changes, fallback_reason을 갖는다. complete=true만 허용한다. mode=listed의 changes는 {exercise_id, old_date, new_date, metric_families} 배열이다. 생성은 old_date=null, 삭제는 new_date=null이며 둘 다 null인 항목은 거부한다. 빈 changes는 계산 완료 뒤 파생 영향이 없다는 뜻이다. D04 세션 변화와 독립적으로 합친다. 버전·신원·세대 불일치와 불완전 입력은 오류다.
D06 단독 구현은 완료된 계산의 mode=suffix/full adapter를 사용했다. D07 #1417 구현은 이를 실제 전후 비교의 mode=listed 또는 경계가 넓어진 full 결과로 교체했다. 정상적인 계산 결과 null은 미완료와 다르다. 연결·release 검증 상태는 D07 기록을 따른다.
필드별 계산 출처
| 출력 | 필드 | 출처·계산 |
|---|---|---|
| 종목 기간 | user_id, exercise_id, period_type, period_start | 사용자·종목과 KST 업무 날짜. ISO 주 월요일, 월·분기·년 시작 |
| 종목 기간 | session_count, set_count, main_reps, volume | D05 session rollup. 세션 distinct, 나머지는 합. volume 최종 2자리 |
| 종목 기간 | best_load | 해당 기간의 rollup best_load 최대. 삭제 때 남은 후보 재평가 |
| 종목 기간 | best_estimated_1rm | accepted strength daily의 strength_display_frame_kg_v1 값 최대 |
| 종목 기간·달력 | average_set_intensity_percent, intensity_set_count | 원시 strength observation의 강도 합/nonnull 개수. 최종 2자리. 대상 없음은 null |
| 종목 기간·달력 | strength/hypertrophy/endurance_set_count | observation stimulus_class별 개수 |
| 종목 기간·달력 | intensity_lt50, 50_70, 70_85, gte85_set_count | observation 강도 구간별 개수. 합=intensity_set_count |
| 종목 기간·달력 | rpe_set_count, rpe_lt6, rpe_6, rpe_7, rpe_8, rpe_9, rpe_10_set_count | 입력 RPE의 기존 구간별 개수, 구간 합 보존 |
| 종목 기간·달력 | set_score_set_count, lt7, 7_85, 85_10, gte10_set_count | 현행 set_score_projection_observations_v1 결과의 개수·구간 |
| 종목 기간·달력 | set_score_sum, average_set_score, set_score_histogram_counts | 같은 점수 스트림의 합/개수, 최종 1자리, 기존 8개 구간. 복합 세트의 현행 대표·평균 정책 보존 |
| 종목 기간 | report_distributions_v1 | 점수 스트림+strength observation에서 score/load/reps/rpe 10구간, rpe_sum/count. 기존 report_histogram_counts_v1 사용 |
| 훈련 기간 | session_count, day_count | 완료 세션과 업무 날짜 distinct |
| 훈련 기간 | set_count | session_set_totals_v1의 세트 행 수. 복합 세부 종목 rollup 개수를 합하지 않음 |
| 훈련 기간 | main_reps, volume | session rollup 합. volume 최종 2자리 |
| 훈련 기간 | duration_minutes, duration_session_counts, time_slot_counts | 기존 유효 시각·인입 timing materialization 규칙. 양수 지속시간 5구간·06~24시 반시간 36구간, 자정 넘김 보존 |
| 훈련 기간 | attendance_met | 주 운동일 수와 attendance_weekly_target_days_v1 비교. 주 외 null |
| 훈련 기간 | attendance_week_count, attendance_week_total | 목요일이 해당 월/분기/년에 속하는 주의 출석 수·목요일 수. day/week는 null |
| 달력 | completed_session_count, exercise_count, set_count, total_reps, main_reps, volume, duration_minutes, pr_count, source_updated_at | calendar_day_summary_source의 현행 기준. PR-only 날짜도 유지. 원본 세부·복합 세트 의미를 보존 |
| 전체 | updated_at/refreshed_at | 실제로 변경한 행의 계산 시각. 동치 장부 clock 제외 기준 유지 |
평균을 다시 평균내지 않는다. 필요한 기간의 원시 합과 분모를 계산한다. max는 삭제 이전 최댓값을 유지하지 않고 현재 후보에서 다시 구한다. 전체 재계산도 같은 계산 함수를 사용한다.
영향 범위와 검증
D04 old/new 날짜·종목과 D07 변경 날짜·종목의 합집합을 day/week/month/quarter/year로 확장한다. 주 출석 변화는 그 주 목요일이 속한 상위 기간까지 포함한다. 삭제된 날짜·종목은 기존 출력 키에서도 제거할 수 있어야 한다. 무관한 날짜와 빈 scope를 전체로 오인하지 않는다.
기준 DB 사례는 statsPeriodCalendarParity.test.mjs에 둔다. ISO 연도 경계, 동일일 다세션, shared set, 분기 간 이동, 최대값 삭제의 독립 수치 단언과 동일 원본의 full/증분 비교를 포함한다. 추가 검증은 미완료·세대 불일치 거부, 원본 불변, 계산 실패 뒤 재시도, 기간 밖 물리 쓰기 0이다. 비교 예외를 추가해 차이를 덮지 않는다.
계산 쓰기 감소와 기존 publisher의 전체 사용자 교체 비용을 별도로 측정한다. D06만으로 최종 발행이 bucket 단위로 바뀌었다고 보고하지 않는다.
훈련 기간·달력 구현
assemble_user_training_period_rows_v1(user_id, buckets)는 완료 세션의 distinct 날짜/세션, shared-set 합계, session rollup의 볼륨/메인 반복수, 유효 시각의 지속시간·분포·시간대, 목요일 귀속 출석을 한 행으로 완성한다. refresh_user_training_period_buckets_v1만 계산 결과를 저장한다. 훈련 기본·main-reps·timing·attendance 보정 함수 4개는 삭제했다.
assemble_user_calendar_day_rows_v1(user_id, dates)는 calendar_day_summary_source의 원본/PR-only 날짜 의미를 유지하고 관측 강도·목적·RPE·세트 점수를 최종 행에 결합한다. refresh_user_calendar_day_buckets_v1만 저장하며 core 삽입 뒤 초기화·보정하는 경로는 제거했다. 외부에서 쓰는 refresh_user_calendar_day_summaries는 날짜 키를 해결해 같은 writer에 위임하는 호환 진입점이다. source_updated_at은 원본/PR provenance 변화도 저장하므로 값이 같은 PR의 재물질화 시각이 바뀌면 해당 달력 행의 변경으로 취급한다. 비교 장부의 기존 clock 제외 기준은 유지한다.
refresh_user_period_calendar_projection_v1(user_id, generation, scope, derived)는 D04 old/new 날짜·종목, 완료된 D07 영향, PR dirty 날짜를 합친다. 세션 범위가 정확하면 그 사실을 쓰고, legacy range/suffix는 보수적인 후보 범위를 읽는다. 빈 listed scope와 완료된 빈 derived changes는 전체 범위로 승격하지 않는다. 기존 출력 키도 full/suffix 후보에 넣어 빈 bucket을 지운다. 출석 주는 그 주 목요일의 월·분기·년을 추가한다.
누락·버전/owner/generation 불일치·complete=false는 SQLSTATE 55000으로 중단한다. 잘못된 scope/변경 항목은 22023 또는 잘못된 date/uuid 입력 오류다. D06 출시 단위의 legacy-sequential-range adapter는 D07 #1417에서 refresh_user_sequential_projections_v1의 실제 listed 결과로 교체한다(D07 계약, 앱 PR #1522 / e2833dee, release/v0.18.0 반영 완료·운영 미배포). 같은 값의 재생은 빈 changes이며, 공유 점수 변경은 다른 구성 종목도 포함한다. D06 함수는 PR/e1RM checkpoint나 applied generation을 쓰지 않는다.
Phase 3 행동 증거: 기존 full과 새 경로의 종목 기간 39·훈련 20·달력 7행 차이 0. D06 실제 DB 7/7에는 9종 불완전 입력 거부·출력/세대 불변, 빈 영향 0 bucket, listed 강도 변화의 종목 5행/달력 1행 갱신과 full 일치, 12월 삭제→새해 1월 출석 갱신이 포함된다. 기존 출석·shared-set·시간대·신원 pgTAP 4파일도 재조준하여 수치 기대값을 유지했다. 관련 정적 계약 50/50 통과. 아직 릴리스/운영 반영 완료가 아니다.
훈련/달력 migration도 history-4y 1,043세션·원본 33,127행에서 중단(12/24문장) rollback 후 재실행을 검증했다. 원본 값·ID·관계 보존, 조회 오류 0/5, owner 조회 4, 잠금 대기 0, 공존 break 0이며 적용 307ms·WAL 1.20MB였다. 삭제한 내부 함수 5개는 활성 호출자도 함께 교체했다. 초기 예비 실행은 최종 snapshot 준비 전 시작되어 중단했고 증거로 사용하지 않았다. 스키마 준비 후 완주한 실행만 판정에 포함했다.
Phase 4에서 중간 기간 adapter도 제거했다(20260914083000). 빈 DB 재생 뒤 유일한 삭제 대상 함수를 563e8c33 정의로 복원하고 migration 장부도 직전 상태로 맞춘 전용 DB를 harness의 --prepare none 입력으로 사용했다. history-4y 원본 33,127행 보존, 조회 오류 0/15·owner 조회 12, 공존 break 0, 잠금 대기 0, 적용 278ms·WAL 0.10MB. 단일 DROP의 자동 중단 위치는 0/1문장으로, 문장 도중 중단을 검증한 것으로 보지 않는다. 앞선 두 migration의 실제 부분 적용 rollback 검증과 구분한다.
최종 DB 검사는 고정된 기존 full corpus를 포함해 10/10이다. 2025년 세션 뒤 2026년 새 세션을 추가한 동일 fixture의 실제 계산 DML은 종목 100→5, 훈련 19→5, 달력 4→1(총 123→11)이었다. full·증분은 최종 consumer와 같은 assemblers를 사용한다. 4년 fixture의 EXPLAIN은 종목 5행 856.362ms(shared hit 43,923), 훈련 5행 88.431ms(10,598), 달력 1행 119.312ms(5,584), 각 shared read 0이었다. 모두 로컬 단일 실행이며 실제 운영 개선율이나 publisher 비용 측정이 아니다.
종목 기간 구현과 현재 증거
Phase 2에서 assemble_user_exercise_period_rows_v1(user_id, buckets)가 모든 종목 기간 열을 계산하고, refresh_user_exercise_period_buckets_v1(user_id, buckets)만 해당 표를 쓴다. 빈 결과의 옛 키는 삭제하고 존재하는 행은 clock 외 전체 값이 다를 때만 upsert한다. Phase 2의 중간 범위 adapter는 Phase 3 최종 consumer 연결 뒤 활성 호출자가 없어 Phase 4에서 제거했다. full/suffix 범위는 최종 refresh_user_period_calendar_projection_v1가 해결한다.
혼합 base 함수는 종목 요약만 맡는 refresh_user_exercise_summary_from_rollups_v1로 대체했다. strength·set-purpose 함수의 기간 UPDATE를 제거하고 set-score-period·report-distributions 전용 writer를 삭제했다. 공식과 공개 DTO는 유지한다.
전용 DB에서 D05 복합/이동/삭제 fixture의 동일 원본에 기준 a1d462a8의 구 함수와 새 함수를 번갈아 적용한 비교는 종목 기간 39행·훈련 기간 20행·달력 7행에서 차이 0이었다. 새 연도 날짜 append는 기간 INSERT 5·UPDATE 0·서로 다른 키 5, 전년의 모든 기간 ctid 불변, 동일 값의 full rebuild 쓰기 0을 확인했다. 관련 정적 계약·작성자 장부 72건도 통과했다. 이 증거는 운영 배포나 전체 CI 완료를 뜻하지 않는다.
4년 이력 1,043세션·원본 33,127행의 populated upgrade에서 중단 후 rollback/replay, 원본 값·ID·관계 보존과 owner 조회를 검증했다. 적용 281ms, WAL 0.06MB, 잠금 대기 0, 조회 오류 0/10, 공존 break 0이다. 내부 함수 3개 제거 경고는 새 엔진으로 호출자를 함께 교체한 것으로 확인했다. 10·10·20 원시 강도의 기간 평균은 13.33이며 별도 DB 단언이 통과했다. 운영 성능 수치가 아니다.
4년 fixture의 한 종목·2026-09-04가 속한 5개 기간 assembler를 EXPLAIN (ANALYZE, BUFFERS)로 실행했을 때 출력 5행, 실행 856.362ms, shared hit 43,923 blocks, 물리 read 0이었다. 관측·rollup은 요청 bucket 범위, score 스트림은 요청 bucket의 최소 시작~최대 끝 범위를 읽는다. 로컬 단일 측정이며 p95·Production 개선율이 아니다.