코칭 엔진 v2 강화 — 하이브리드 개발 원자단위 WBS (빌드 플랜)

2026-06-13 · 설계 사상 = v3의 두뇌(진단·계획·커리큘럼)를 가이드로 가져오고, 작문은 v2 LLM이 직접. 기술 해부도 = coaching-engine-architecture.html. 본 문서는 그 하이브리드 설계 + 별도 세션 감사 인계서(개선 A~I)를 구현 가능한 원자 단위로 분해한 작업 명세서다. 각 원자는 {목적·요구·구현 명세·유즈케이스·테스트·완료 기준(DoD)·의존·규모}를 갖는다. 원자 ID로 커밋·테스트를 추적한다.

전체 규모 요약 — EPIC 10개 · 원자 111개 · 테스트 893개 · 마일스톤 D1~D5. 신규 파일 36(web/lib/coachMaterials.ts·web/lib/comboMatrix.ts·web/tests/coach-materials.test.ts·web/tests/coach-recos.test.ts·web/tests/combo-matrix.test.ts·web/tests/fixtures/materials.ts·web/lib/coachGuide.ts·web/tests/coach-guide.test.ts·web/lib/coachGrounding.ts·web/tests/coach-grounding.test.ts·web/lib/coachQuality.ts·web/tests/coach-quality.test.ts·web/lib/coachCompare.ts·web/tests/coach-compare.test.ts·web/tests/fixtures/compare-arin.json·web/components/CompareLetterCard.tsx·web/lib/altLetter.ts·web/tests/alt-letter.test.ts·web/tests/compare-card.test.tsx·web/lib/compareVote.ts·web/app/api/feedback/letter/route.ts·web/app/admin/compare/page.tsx·web/app/admin/compare/VoteButtons.tsx·web/tests/compare-vote.test.ts·web/tests/coach-merged.test.ts·web/tests/compare-replay.test.ts·web/tests/fixtures/arin-golden-b.json·web/scripts/build-ingredient-freq.py·web/public/ingredient-freq.json·web/lib/ingredientFreq.ts·web/lib/comboGuard.ts·web/lib/recoEvidence.ts·web/tests/ingredient-freq.test.ts·web/tests/group-ingredients.test.ts·web/tests/combo-guard.test.ts·web/tests/reco-evidence.test.ts) · 수정 16(web/lib/coachRecos.ts·web/lib/coachDaily.ts·web/lib/coachWeekly.ts·web/lib/curriculumUnits.ts·web/lib/coach.ts·web/tests/coach-guards.test.ts·web/app/api/cron/coach/route.ts·web/app/page.tsx·web/app/admin/[childId]/page.tsx·web/app/admin/layout.tsx·web/app/api/cron/coach-selfcheck/route.ts·web/tests/fixtures/real-arin.json·web/lib/replayMetrics.ts·web/lib/replayRunner.ts·web/package.json·web/app/admin/cron/page.tsx) · SQL 2(web/sql/2026-06-13_letter_feedback_variant.sql·web/sql/2026-06-14_letter_feedback_variant.sql).
🧭 핵심 설계 — 두뇌는 v3에서, 손(작문)은 v2 LLM

v3는 결정(진단·계획)과 표현(작문)을 둘 다 결정론으로 묶어(상태기계→고정 블록) "매번 같은 문장"으로 굳었다(수렴성 4/10·문구 반복 18회). 새 설계 = v3의 두뇌만 가져오고 손은 LLM에 푼다. 커리큘럼·실라버스도 고정 블록이 아니라 가이드로 LLM에 주고 LLM이 직접 쓴다.

아린 2통 A/B — 매일 Letter A(기존 v2 그대로·대조군) + Letter B(개선 A~I 전부 적용·처치군)를 동시 발행. ★모든 개선은 B에만, A는 무변경★ → 어드민 피드백(👍/👎/🔁)에서 B가 이기면 전 자녀 승격. 실증: 하이브리드가 v3 조립본을 6:0으로 이김(은유·환각·기간·구체성).

인계서 개선핵심착지 EPIC
A 괴식 조합검증잘먹는음식×결핍식재료를 kit-dish-matrix(0~3)+pair로 점수화 → 통과 조합만 LLM 후보(미역국+당근 차단)A·H
B 재료 결정론/문장 LLM추천 식재료는 코드가 매일 회전(3일 무재사용), 문장만 LLM(수렴 방지)A·C
C 4기준 가중 랭킹시급도·급식빈도 상위%·궁합·사촌 가중 랭킹 + freqMap 죽은코드 부활 + 근거문구A·E·I
D 잘먹음 판정liked=집(≠급식)+2일+거부없음, 거부 후순위A
E 온보딩<3일분석 대신 입력안내+즉효 팁(환각 차단)B·C
F 기간 수치"최근 7일 중 3일"처럼 수치(모호어 금지)A·C
G 품질축 검증은유사전·나열·모호기간·재료밖 음식명 스캔 → 재생성("게이트 그린≠좋은 편지" 봉합)D·H
H 무검증 채널 차단거울·추천을 본문 합본해 검증 1패스(슬롯값 포함)C·D
I 데이터 정합성GROUP_INGREDIENTS 실측 빈도 정비(단호박 0회 강등)I·A
📌 개발 지시 프로토콜 — 이 문서가 작업 지시의 단일 진실
📊 진척 대시보드 (원자 완료 시 갱신 — 작성 2026-06-13 · A~J 코드 완료·테스트 815)
EPIC원자완료테스트마일스톤상태
A 재료 엔진1212136D1✅ 완료 598bd55 · 테스트 94 그린(전체 314)·tsc 클린·Letter A 무변경
B v3 두뇌 가이드 합성111184D2✅ 완료 a464c2e
C merged 작문 모드141494D2✅ 완료 5659dc2
D 품질·무검증 채널 검증 (coach.ts 발행전 가드)1111101D2✅ 완료(오탐 수정) a464c2e·85ea9b3
E 크론 2통 발행111184D3✅ 코드 완료 · ⏸ 배포+카나리아 검증 cf52387
F 앱 2카드 노출 (아린 코칭 화면 A/B 비교 UI)8860D3✅ 코드 완료 · ⏸ 배포 육안검증 3ccedb8
G 어드민 A/B 2통 비교 + 변형별 피드백 승자 판정121278D3✅ 코드 완료 · ⏸ compare_votes SQL 실행 3ccedb8
H 검증 하네스·회귀·적대 테스트 (Letter B 품질 게이트)1515114D4✅ 완료(괴식·수렴·품질 게이트 0) cf52387
I 데이터 정합성·근거 보강 (급식빈도 상위%·GROUP_INGREDIENTS 정비·괴식 조합 점검·근거 문구)7771D4✅ 완료 a464c2e
J 컷오버·운영·롤백·모니터·문서 (A/B 2통 하이브리드)101071D5✅ 런북·셀프체크(G-11)·WBS · ⏸ 컷오버는 승자 판정 후
합계111111893D1~D5✅ A~J 구현 완료(111/111·테스트 815·tsc·build 그린) · ⏸ 외부 게이트=compare_votes SQL+배포 카나리아
🚀 EPIC J — A/B 컷오버·운영·롤백 절차 (코드 완료 · 운영 대기 2026-06-13)
  1. 활성화(2단계): ① web/sql/2026-06-13_compare_votes.sql 실행(이사님 — 변형별 피드백 저장). ② Vercel env COACH_COMPARE_CHILDREN=43942d34-b339-4bbd-978a-ec3f6a877031(미설정 시 기존 COACH_V3_CHILDREN 폴백) → 다음 새벽 크론부터 아린만 2통(A=기존 v2 대조군 메인 · B=하이브리드 context.altLetter). QA 즉시: ?child=<id>&date=<과거>&force=1.
  2. 모니터: /admin/compare(judgeWinner 승자·신뢰도·🔁신고율) · /admin/[childId] A·B 나란히+투표 · coach-selfcheck compare 집계 · cron_runs.meta(compareLlmCalls/Ok/Failed). 비용=아린 1명 LLM 2배.
  3. 승격(J-02·03): judgeWinner가 B-win(선호율↑·반복신고↓·N일·신뢰도 mid+) 확정 → composeLetterB 전 자녀 메인 승격. 그 전엔 아린만.
  4. 즉시 롤백(J-04): env 제거 = 다음 크론부터 A(v2)만. additive·altLetter는 jsonb 곁가지라 무손상. B 실패도 A 발행 안 막음(폴백).
  5. 엣지 복리(J-10): 발견→fixture+테스트(red→green)+수정. 적용: offMaterialFood 도전·발전 전(jeon) 오탐 차단(85ea9b3·HB-FINDING).
⏸ 외부 게이트: compare_votes SQL 실행(이사님) · 배포 후 아린 화면/어드민 육안 검증 · 카나리아 모니터 후 승격. 코드·테스트(815·괴식 0·수렴 0·품질 0)·tsc·build 전부 그린.
목차

EPIC A — 재료 엔진 — selectDailyMaterials (coachRecos 재설계) D1

v3의 '두뇌'(결정론 재료 선택)를 Letter B용으로 신설한다. 잘먹는 판정(D)·GROUP_INGREDIENTS 빈도 정비(I)·4기준 가중 랭킹+freqMap 실배선(C)·3일 무재사용 회전(B)·괴식 조합검증(A)·결핍 기간 수치화(F)를 전부 순수 함수로 묶어 selectDailyMaterials(args)로 노출. 문장은 LLM이 쓰되 '무엇을 추천할지'는 코드가 못 박는다.

📁 신규 web/lib/coachMaterials.ts web/lib/comboMatrix.ts web/tests/coach-materials.test.ts web/tests/coach-recos.test.ts web/tests/combo-matrix.test.ts web/tests/fixtures/materials.ts · 수정 web/lib/coachRecos.ts

A-01 · GROUP_INGREDIENTS 빈도 정비(I) — 실측 급식빈도 반영·단호박 강등코드 코드 · 0.5h

목적인계서 I: 빈도 가중(C)을 도입하면 급식 0회 식재료(단호박)가 항상 탈락해 대표 식재료가 죽는다. coachRecos.ts의 GROUP_INGREDIENTS 비타민A채소 ['단호박','당근','시금치','근대']를 실측 빈도 순(당근184·시금치13·근대11·단호박0)으로 재배열하고, 빈도 메타를 코드에 명시해 랭킹 엔진이 참조하게 한다.
요구각 식품군 대표 식재료에 실측 급식빈도(learned_menus 1000개 기준)를 부착하고, GROUP_INGREDIENTS는 빈도 내림차순으로 정렬한다. 단호박은 목록 끝(또는 강등)으로. 기존 popularDishesFor/buildRecoFacts(Letter A 경로)는 무영향이어야 한다(상수 정렬만 변경).
명세
유즈케이스
테스트10개
ID종류케이스 · 검증(입력→기대)
A-01-1단위비타민A채소 RANKED 1위는 당근 — GROUP_INGREDIENTS_RANKED['비타민A채소'][0] → '당근'
A-01-2단위단호박은 비타민A채소 RANKED 최하위 — GROUP_INGREDIENTS_RANKED['비타민A채소'].at(-1) → '단호박'
A-01-3단위근대(11)가 단호박(0)보다 상위 — indexOf('근대') < indexOf('단호박') in RANKED['비타민A채소'] → true
A-01-4단위ingredientGioFreq 당근 — ingredientGioFreq('당근') → {freq:184,pct:2}
A-01-5단위ingredientGioFreq 미상 식재료 폴백 — ingredientGioFreq('아스파라거스') → {freq:0,pct:100}
A-01-6단위치즈/요거트 빈도 메타 — ingredientGioFreq('치즈').freq=18 && ingredientGioFreq('요거트').freq=0 → true
A-01-7회귀Letter A 보존 — GROUP_INGREDIENTS 원본 비타민A채소 0번은 여전히 단호박 — import {GROUP_INGREDIENTS} from coachRecos; GROUP_INGREDIENTS['비타민A채소'][0] → '단호박'(불변)
A-01-8회귀RANKED는 모든 키를 GROUP_INGREDIENTS와 동일 집합으로 유지 — 각 키 sort된 RANKED 원소 = sort된 GROUP_INGREDIENTS 원소 → 동일(누락·추가 0)
A-01-9단위기타채소 RANKED 토마토(42)가 양배추(20)보다 상위 — indexOf('토마토') < indexOf('양배추') → true
A-01-10속성빈도 동률 시 안정 정렬(원본 순서 보존) — freq 동일 두 원소는 RANKED에서 GROUP_INGREDIENTS 원본 상대순서 유지
DoD
의존없음

A-02 · liked 판정(D) — 집≠급식·2일+·거부없음 deriveLikedIngredients코드 코드 · 1h

목적인계서 D·P10: 현재 likedIng은 favIngFreq(place 무관) 단순 빈도라 '급식에 차려진 것'을 선호로 오인한다. liked = place!=daycare(집) AND 집에서 2일 이상 등장 AND 거부 이력 없음. 거부된 것은 refused로 후순위.
요구recentMeals 배열(food·place·ateWell·refused·daysAgo)을 입력받아 liked(선호)·refused(거부) 식재료를 분리하는 순수 함수. 급식/간식(차려진 것)은 선호 신호에서 제외. 집 2일 미만이면 liked 제외.
명세
유즈케이스
테스트13개
ID종류케이스 · 검증(입력→기대)
A-02-1단위집 2일 잘먹음 → liked — meals=[당근 home daysAgo1 ok, 당근 home daysAgo3 ok] → liked 포함 '당근'
A-02-2단위집 1일만 → liked 제외 — meals=[당근 home daysAgo1 ok] (1일) → liked 미포함
A-02-3적대급식만 등장 → liked 제외 — meals=[시금치 daycare daysAgo1, 시금치 daycare daysAgo2] → liked 미포함(차려진 것)
A-02-4적대간식 등장 → liked 제외 — meals=[치즈 snack daysAgo1, 치즈 snack daysAgo2] (place=snack 가정) → liked 미포함
A-02-5단위같은 날 2끼는 1일로 — meals=[당근 home daysAgo1 슬롯아침, 당근 home daysAgo1 슬롯점심] → liked 미포함(distinct day=1)
A-02-6단위거부 식재료는 refused·liked 배제 — meals=[가지 home daysAgo1 ateWell:false, 가지 home daysAgo2 ateWell:false] → refused 포함, liked 미포함
A-02-7적대liked·refused 충돌 시 refused 우선 — meals=[당근 home d1 ok, 당근 home d2 ok, 당근 home d3 ateWell:false] → refused 포함이면 liked에서 당근 제거
A-02-8단위place=null은 집 통제로 간주 — meals=[당근 null d1 ok, 당근 null d2 ok] → liked 포함 '당근'
A-02-9단위빈 입력 — deriveLikedIngredients([]) → {liked:[],refused:[]}
A-02-10단위전부 daycare → liked 비어있음 — 전 끼니 place=daycare → liked=[]
A-02-11단위ateWell null은 거부 아님(중립→liked 가산) — meals=[당근 home d1 ateWell:null, 당근 home d2 ateWell:null] → liked 포함
A-02-12속성liked 결과 중복 제거 — 같은 식재료 여러 날 → liked에 1회만
A-02-13속성refused 결과 중복 제거 — 가지 거부 3일 → refused에 '가지' 1회만
DoD
의존없음

A-03 · 괴식 조합 검증(A) — comboMatrix.scoreCombo (kit-dish-matrix + pair)코드 코드 · 1h

목적인계서 A 최우선: '잘먹는음식+결핍식재료' 조합을 kit-dish-matrix.json(음식×식재료 0~3)+food-graph pair로 점수화. 임계 미만 금지. 실증 괴식='미역국+당근'(matrix 미역국·당근=1). OK='볶음밥/카레/덮밥+당근'(=3). LLM이 조합 짓게 두지 않는다.
요구음식(dish)과 식재료(ingredient) 쌍의 정합성을 0~3 점수로 반환하는 순수 함수. matrix scores[dish][ing] 우선, 없으면 cells(동시출현) 또는 pair 그래프 폴백. 임계(기본 2) 미만이면 부적합 판정.
명세
유즈케이스
테스트15개
ID종류케이스 · 검증(입력→기대)
A-03-1단위볶음밥+당근=3 — scoreCombo('볶음밥','당근') → {score:3, source:'matrix'}
A-03-2적대미역국+당근=1(괴식) — scoreCombo('미역국','당근') → {score:1, source:'matrix'}
A-03-3적대미역국+당근 isComboOk false — isComboOk('미역국','당근',2) → false
A-03-4단위카레+당근 OK — isComboOk('카레','당근',2) → true (score 3)
A-03-5단위덮밥+당근 OK — isComboOk('덮밥','당근',2) → true
A-03-6단위미역국+미역=3(자기 식재료 자연) — scoreCombo('미역국','미역').score → 3
A-03-7단위미역국+멸치=3 — scoreCombo('미역국','멸치').score → 3
A-03-8적대미역국+돼지고기=1(부적합) — isComboOk('미역국','돼지고기',2) → false
A-03-9단위존재하지 않는 음식 → none — scoreCombo('탕수육','당근').source → 'none' 또는 'pair'(그래프에 있으면)
A-03-10단위threshold=3 엄격모드 — isComboOk('된장국·찌개','당근',3) → false (된장국 당근=2)
A-03-11속성score 범위 0~3 보장 — 임의 dish×ing 100쌍 scoreCombo().score ∈ [0,3]
A-03-12단위빈 인자 방어 — scoreCombo('','') → {score:0, source:'none'}
A-03-13단위계란찜+당근=3 — scoreCombo('계란찜','당근').score → 3
A-03-14회귀matrix 우선 — pair에도 있어도 matrix 값 채택 — matrix·pair 둘 다 있는 쌍 → source='matrix'
A-03-15단위isComboOk 기본 threshold=2 — isComboOk('미역국','두부') → true (미역국 두부=2)
DoD
의존없음

A-04 · 4기준 가중 랭킹(C) — rankIngredients (시급도·빈도%·궁합·사촌)코드 코드 · 1.5h

목적인계서 C(이사님 핵심): 현 pickFoodReco는 seed%length 블라인드 회전으로 4기준 0반영. ①영양 시급도 ②급식빈도 상위% ③잘먹는음식 궁합(pair) ④잘먹는채소 사촌(bridge) 가중 랭킹으로 결핍 식품군 대표 식재료를 정렬한다.
요구targetGroup + signals + liked + freqMap 입력 → GROUP_INGREDIENTS_RANKED[targetGroup] 식재료를 4기준 가중합으로 정렬한 배열 반환(각 항목에 점수·기여도 분해 포함).
명세
유즈케이스
테스트16개
ID종류케이스 · 검증(입력→기대)
A-04-1단위비타민A채소 결핍 1위=당근 — rankIngredients({targetGroup:'비타민A채소',groupLevel:'red',liked:[]})[0].ing → '당근'
A-04-2단위단호박 최하위(liked 사촌 없을 때) — 위 결과 at(-1).ing → '단호박'
A-04-3단위freq 점수: 당근 pct2 → 3점 — 당근 항목 parts.freq → 3
A-04-4단위freq 점수: 근대 pct39 → 1점 — 근대 항목 parts.freq → 1
A-04-5단위freq 점수: 단호박 pct100 → 0점 — 단호박 항목 parts.freq → 0
A-04-6단위궁합 가산: liked에 달걀 → 당근 pair + — liked=['달걀'] → 당근 parts.pair >= 1 (당근-달걀 pair 존재)
A-04-7단위사촌 가산: liked 고구마 → 단호박 bridge — liked=['고구마'] → 단호박 parts.bridge → 2 (고구마-단호박 bridge)
A-04-8단위사촌 푸드체이닝이 단호박 순위 끌어올림 — liked=['고구마']일 때 단호박 index < liked=[]일 때 index → true
A-04-9단위urgency red>yellow — 동일 식재료 groupLevel red의 parts.urgency > yellow → true
A-04-10단위green이면 urgency 0 — groupLevel:'green' → parts.urgency=0 모든 항목
A-04-11단위score = 가중합 정확 — 임의 항목 score === W.urgency*urgency + W.freq*freq + W.pair*pair + W.bridge*bridge
A-04-12속성동점 시 freq 내림차순 타이브레이크 — weighted score 동일 두 식재료 → gioFreq.freq 큰 쪽 먼저
A-04-13속성동점+동빈도 시 사전순 결정론 — score·freq 동일 → localeCompare 순서(2회 호출 동일 결과)
A-04-14단위미지 targetGroup → 빈 배열 — rankIngredients({targetGroup:'없는군',...}) → []
A-04-15적대pair 가산 2로 클램프 — liked에 당근 궁합 5개 있어도 parts.pair <= 2
A-04-16회귀blind 회전 제거 회귀 — score가 seed에 의존하지 않음 — rankIngredients는 seed 인자 없음·동일 입력 동일 출력(결정론, 4기준 반영)
DoD
의존A-01 · A-02 · A-03

A-05 · 3일 무재사용 회전(B) — rotateRecommendation (재료 결정론)코드 코드 · 1h

목적인계서 B: 추천 식재료를 코드가 매일 회전(당근→치즈→시금치…). 3일 내 재사용 금지로 6통 '당근→미역국' 수렴 방지. 문장은 LLM 자유, 재료만 결정론.
요구rankIngredients 결과(랭킹)와 최근 N일 추천 이력(recentRecos)을 입력받아, 3일 내 추천한 식재료를 제외하고 최상위를 선택. 전부 최근 추천이면 가장 오래된 것 우선(또는 랭킹 1위 폴백).
명세
유즈케이스
테스트11개
ID종류케이스 · 검증(입력→기대)
A-05-1단위어제 추천 제외 — ranked=[당근,치즈], recentRecos=['당근'] → '치즈'
A-05-2단위쿨다운 없으면 1위 — ranked=[당근,치즈], recentRecos=[] → '당근'
A-05-3회귀2일 연속 다른 재료(수렴 방지) — day1 당근 추천→day2 recentRecos=['당근']→day2 결과≠'당근'
A-05-4단위3일 무재사용 → 4일째 재사용 허용 — recentRecos=['당근','시금치','근대'](3일분) cooldownDays=3, ranked=[당근,...] → 당근 외 후보 우선, 없으면 폴백
A-05-5적대전부 쿨다운 → 랭킹 1위 폴백(null 아님) — ranked=[당근,치즈], recentRecos=['당근','치즈'] → '당근'(폴백)
A-05-6단위빈 랭킹 → null — rotateRecommendation({ranked:[],recentRecos:[]}) → null
A-05-7속성결정론 — 2회 호출 동일 — 동일 args 두 번 호출 → 동일 결과
A-05-8단위cooldownDays=0이면 항상 1위 — cooldownDays=0, ranked=[당근,치즈], recentRecos=['당근'] → '당근'
A-05-9단위score 순서 보존(랭킹 무시 안 함) — ranked=[치즈(score5),당근(score3)] recentRecos=[] → '치즈'(score 높은쪽)
A-05-10단위recentRecos 중복 무해 — recentRecos=['당근','당근'] → 당근만 제외, 결과 동일
A-05-11단위5일 회전 시 3일 윈도우만 본다 — recentRecos=최근3일만 전달 가정 → 4일전 추천은 재사용 가능(호출자 책임이나 cooldownDays 슬라이스 검증)
DoD
의존A-04

A-06 · 결핍 기간 수치화(F) — deficiencyWindow (모호어 금지)코드 코드 · 0.75h

목적인계서 F: '요즘/최근/이번주' 모호어 금지. '최근 7일 중 비타민A 채소가 3일'처럼 수치(주간 등장일)와 임계를 코드로 정의해 LLM 재료에 제공. LLM이 모호어 쓰지 않도록 수치 사실을 못 박는다.
요구computeGroupSignals/computeSignals 결과를 입력받아 가장 시급한 결핍의 {nutrient, daysOf7, threshold, windowDays:7} 사실 객체를 반환. weeklyEst를 7일 등장일로 환산하고 임계(green 기준)를 명시.
명세
유즈케이스
테스트10개
ID종류케이스 · 검증(입력→기대)
A-06-1단위red 결핍 수치화 — signals=[{비타민A채소,red,2.1}] → {group:'비타민A채소',daysOf7:2,threshold:5}
A-06-2단위weeklyEst 반올림 — weeklyEst:2.6 → daysOf7:3
A-06-3단위전부 green → null — 모든 signal level green → deficiencyWindow → null
A-06-4단위red 다수 시 weeklyEst 최저 우선 — 비타민A채소 red 1.0 vs 콩류 red 0.5 → 콩류(더 시급) 또는 채소 우선 가산 명시(정렬 규칙 고정)
A-06-5단위threshold = GROUP_TARGET.green 일치 — 콩류 결핍 → threshold:2 (rotation green 2)
A-06-6단위windowDays 기본 7 — 반환 객체 의미상 7일 기준(daysOf7 ≤ 7)
A-06-7속성daysOf7 ∈ [0,7] 클램프 — 임의 weeklyEst 입력 → daysOf7 ∈ [0,7]
A-06-8단위yellow만 있을 때도 결핍 반환 — red 없고 yellow 있으면 yellow 중 시급한 것 반환(null 아님)
A-06-9단위빈 signals → null — deficiencyWindow([]) → null
A-06-10적대모호어 0 — 출력에 '요즘/최근/이번주' 문자열 없음 — 반환 객체 JSON에 모호 기간어 미포함(숫자만 — 모호어는 G 검증에서 차단)
DoD
의존없음

A-07 · 검증 조합 후보 생성(A+D) — buildValidatedCombos코드 코드 · 1h

목적인계서 A: '잘먹는음식+결핍식재료' 조합 중 isComboOk 통과한 것만 LLM 후보로. liked(D)와 추천 식재료(rotate)를 묶어 검증된 조합 배열을 만든다. LLM이 조합 짓지 못하게 못 박는 핵심.
요구추천 식재료(recommendedIng)와 liked 음식·식재료를 조합해 isComboOk(threshold) 통과한 {liked, deficient, score, source} 배열을 점수 내림차순으로 반환. 미통과(미역국+당근류)는 제외.
명세
유즈케이스
테스트12개
ID종류케이스 · 검증(입력→기대)
A-07-1적대미역국+당근 후보에서 제외 — likedDishes=['볶음밥','미역국'], ing='당근' → 결과에 미역국 없음, 볶음밥 있음
A-07-2단위카레·덮밥+당근 둘 다 채택 — likedDishes=['카레','덮밥'], ing='당근' → 2개 모두 score3 포함
A-07-3단위score 내림차순 정렬 — 섞인 score 조합 → 결과[0].score >= 결과[1].score
A-07-4단위검증 통과 0 → 빈 배열 — likedDishes=['미역국'], ing='당근' → []
A-07-5단위최대 N개 제한 — 통과 조합 6개·N=4 → 길이 4
A-07-6속성중복 (liked,deficient) 제거 — likedDishes 중복 '볶음밥' 2개 → 결과 1개
A-07-7단위staple 형태 변환 — recommendedIng='쌀' → deficient='밥'(stapleDisplay)로 조합 또는 STAPLE_FORMS 적용
A-07-8단위빈 likedDishes → 빈 배열 — likedDishes=[] → []
A-07-9단위threshold 3 엄격 → 후보 축소 — threshold:3, likedDishes=['된장국·찌개'], ing='당근'(score2) → []
A-07-10단위source 필드 채워짐 — 채택 조합 source ∈ {matrix,cells,pair}
A-07-11속성결정론 — 2회 동일 — 동일 args 2회 → 동일 배열
A-07-12회귀전부 통과해도 score<threshold는 없음 — 모든 결과 항목 score >= threshold(괴식 0 보장)
DoD
의존A-03 · A-02

A-08 · 근거문구 생성(C) — buildReasonPhrases코드 코드 · 0.75h

목적인계서 C: 근거문구를 LLM 재료로 제공('당근=급식 상위2%·베타카로틴이 눈·면역'). 랭킹 4기준을 사람이 읽는 사실 문구로 변환해 LLM이 인용하게 한다. 문장은 LLM이 쓰지만 사실은 코드가 못 박는다.
요구추천 식재료·rankIngredients parts·deficiencyWindow를 입력받아 근거 사실 문구 배열(예: '당근은 급식 상위 2%', '비타민A 채소가 최근 7일 중 2일')을 반환. 영양 역할 키워드는 식재료별 사실 테이블에서.
명세
유즈케이스
테스트12개
ID종류케이스 · 검증(입력→기대)
A-08-1단위당근 빈도 문구 — buildReasonPhrases({ing:'당근',...}) 결과에 '상위 2%' 포함 문구 존재
A-08-2단위근대(39%)는 빈도 문구 생략 — ing='근대'(pct39) → '상위' 문구 미포함(pct>20)
A-08-3단위결핍 수치 문구 — window={비타민A채소,2,5} → '최근 7일 중 2일' 포함 & '권장 5일' 포함
A-08-4단위window null → 결핍 문구 없음 — window:null → 결핍 관련 문구 0(환각 방지)
A-08-5단위궁합 문구 — pairLiked='빵' → '잘 먹는 빵' 포함 문구 존재
A-08-6단위사촌 문구 — cousinOf='고구마', ing='단호박' → '고구마' & '단호박' 포함
A-08-7단위영양역할 라벨 — 당근 — ing='당근' → '베타카로틴' 포함 문구 존재
A-08-8단위영양역할 라벨 — 치즈 — ing='치즈' → '칼슘' 포함
A-08-9적대모호 기간어 미포함 — 결과 join에 '요즘'·'이번주'(수치없는) 미포함
A-08-10단위빈 parts·null window → 영양역할만 — parts 전부 0·window null → 영양역할 라벨 1개만(또는 빈)
A-08-11속성결정론 — 동일 args 2회 → 동일 배열
A-08-12단위라벨 미상 식재료 graceful — ing='파스닙'(NUTRI_ROLE 미상) → 에러 없이 빈도/결핍 문구만
DoD
의존A-01 · A-04 · A-06

A-09 · 온보딩 가드(E) — 기록<3일 분기 materialsForLowData코드 코드 · 0.5h

목적인계서 E: 기록<3일이면 결핍 분석(환각 위험)을 끄고, 빠뜨린 입력 항목 안내 + 배경 없는 즉효 편식 팁을 재료로 제공. '가입 다음날 채소 비어요'=환각 차단.
요구recordedDays(기록일 수) 입력. <3일이면 분석 재료 대신 {mode:'onboarding', missingInputs, tip} 반환. ≥3일이면 정상 분석 모드 신호(null 또는 mode:'analyze').
명세
유즈케이스
테스트9개
ID종류케이스 · 검증(입력→기대)
A-09-1단위기록 1일 → onboarding — recordedDays:1 → mode:'onboarding'
A-09-2단위기록 3일 → analyze — recordedDays:3 → mode:'analyze'
A-09-3단위키 없음 → missingInputs '키' — hasHeight:false → missingInputs 포함 '키'
A-09-4단위전부 입력됨 → missingInputs 짧음 — 키·몸무게·질환·끼니 충분 → missingInputs=[]
A-09-5속성tip 결정론 — 동일 tipSeed 2회 → 동일 tip
A-09-6단위tipSeed 다르면 tip 회전 — tipSeed 1 vs 2 → tip 다를 수 있음(풀>1)
A-09-7적대환각 차단 — onboarding엔 결핍 분석 없음 — onboarding 반환 객체에 deficiency/recommendedIng 없음
A-09-8단위끼니<3 → missingInputs '끼니 기록' — mealCount:2 → missingInputs 포함 '끼니 기록'
A-09-9회귀nutrition 가드와 임계 일치(3일) — recordedDays<3 임계가 computeSignals recordedDays<3 보류와 동일
DoD
의존없음

A-10 · 오케스트레이터 — selectDailyMaterials(args) 통합코드 코드 · 1h

목적인계서 핵심 산출물. A-02~A-09를 조립해 selectDailyMaterials(args)→{targetGroup, recommendedIng, validatedCombos, reasonPhrases, deficiencyWindow, liked, refused, mode}를 반환하는 결정론 재료 엔진. Letter B 작문가가 받을 사실·재료 묶음 단일 진실.
요구signals·meals·recentRecos·freqMap·온보딩 메타를 입력받아 하나의 재료 객체를 반환. 온보딩 분기 → 결핍 산출 → 랭킹 → 회전 → 조합검증 → 근거문구를 순서대로 호출. 전부 결정론·순수.
명세
유즈케이스
테스트12개
ID종류케이스 · 검증(입력→기대)
A-10-1통합정상 분석 모드 전체 형태 — 5일 기록·red 입력 → {mode:'analyze', targetGroup:'비타민A채소', recommendedIng truthy, validatedCombos 배열, reasonPhrases 비어있지않음}
A-10-2통합온보딩 분기 — recordedDays:1 → mode:'onboarding'·targetGroup null·tip 존재
A-10-3회귀회전 통합 — 어제 당근이면 오늘 다른 재료 — recentRecos=['당근'] → recommendedIng !== '당근'
A-10-4적대괴식 차단 통합 — 미역국 favorite여도 당근 조합 없음 — favoriteFoods=['미역국'], recommendedIng='당근' → validatedCombos에 미역국 없음
A-10-5통합전부 green → 결핍 강요 없음 — 모든 signal green → targetGroup null·validatedCombos=[]
A-10-6속성결정론 — 2회 호출 동일 — 동일 args 2회 → deep equal
A-10-7회귀순수 — fs/network 미사용 — freqMap 미주입·signals만으로 동작(외부 IO 없음)
A-10-8통합liked/refused 분리 전달 — 거부 식재료는 liked 아닌 refused에
A-10-9적대reasonPhrases에 모호어 없음 — reasonPhrases join에 수치없는 '요즘/이번주' 미포함
A-10-10단위recommendedIng은 targetGroup 식재료 — recommendedIng ∈ GROUP_INGREDIENTS_RANKED[targetGroup](staple 형태 변환 고려)
A-10-11회귀validatedCombos 전부 score>=threshold — 모든 combo.score >= 2(괴식 0)
A-10-12회귀Letter A 미호출 — composeLetter 경로와 독립 — selectDailyMaterials는 planFor/composeLetter import 안 함(B 전용·A 무변경)
DoD
의존A-02 · A-04 · A-05 · A-06 · A-07 · A-08 · A-09

A-11 · freqMap 실배선 — loadFreqMap·route 전달 헬퍼(C 죽은 코드 부활)코드 코드 · 0.75h

목적인계서 C: freqMap(ingredient-recipes 급식빈도)이 route.ts에서 로드만 되고 본경로 미전달=죽은 코드. selectDailyMaterials까지 freqMap이 도달하는 로딩·전달 헬퍼를 만들고 형태를 고정한다(통합은 H 에픽이 배선, 여기서는 순수 어댑터·검증).
요구ingredient-recipes 데이터를 FreqMap 형태(coachRecos.ts FreqMap=Record<string,{name,freq}[]>)로 정규화하는 순수 어댑터와, 비어있을 때 kit-matrix 폴백이 동작함을 보장하는 검증.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
A-11-1단위정상 raw 정규화 — normalizeFreqMap({당근:[{name:'볶음밥',freq:10}]}) → {당근:[{name:'볶음밥',freq:10}]}
A-11-2단위freq 내림차순 정렬 — 입력 freq 오름차순 → 출력 내림차순
A-11-3단위null raw → 빈 객체 — normalizeFreqMap(null) → {}
A-11-4적대형식 불량 graceful — normalizeFreqMap('garbage') → {} (throw 안 함)
A-11-5회귀freqMap 빈객체여도 kit-matrix 폴백 — popularDishesFor('당근',{}) → kit-matrix dishes 반환(빈배열 아님)
A-11-6단위freqMap 주입 시 빈도 우선 — popularDishesFor('당근',{당근:[{name:'당근밥',freq:9}]}) → ['당근밥',...] (freqMap 우선)
A-11-7적대name 누락 항목 방어 — normalizeFreqMap({당근:[{freq:5}]}) → 해당 항목 드롭 또는 안전 처리(throw 없음)
A-11-8통합selectDailyMaterials가 freqMap 받음 — selectDailyMaterials({...,freqMap:normalized}) → 랭킹 freq 점수 반영 확인
DoD
의존A-04 · 외부: H 에픽 cron freqMap 로딩 배선

A-12 · 재료 fixture + 회귀 박제 (아린 6통 괴식·수렴 사례)테스트 테스트 · 1h

목적인계서 실증(6통 '당근→미역국' 괴식·'당근 반복' 수렴)을 fixture로 박제. selectDailyMaterials가 그 입력에서 괴식 0·재료 회전을 만들어냄을 회귀로 고정(red→green→복리).
요구아린 실데이터 모양의 fixture(6일치 끼니·signals·연속 추천 이력)를 만들고, 6일 시뮬레이션에서 (1)괴식 조합 0 (2)추천 식재료 3일내 무재사용 (3)전부 green 아니면 결핍 수치 제공을 검증.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
A-12-1회귀6일 괴식 0 박제 — 6일 시뮬 모든 validatedCombos score>=2 → 괴식 0건
A-12-2회귀6일 수렴 방지 박제 — recommendedIng 시퀀스 어떤 3일 연속 윈도우에도 중복 0
A-12-3적대미역국+당근 절대 미발생 — 6일 전 조합에 {liked:'미역국',deficient:'당근'} 0건
A-12-4회귀red일 결핍 수치 제공 — 비타민A채소 red인 날 deficiencyWindow.daysOf7 ∈ [0,7] 존재
A-12-5통합온보딩 1~2일 분기 — recordedDays 1,2 fixture → mode:'onboarding'
A-12-6단위liked가 미역국이어도 liked 신호 정확 — 미역국이 집 2일+ → liked, 급식만이면 미제외 (D 판정 fixture 검증)
A-12-7회귀freqMap 주입/미주입 둘 다 괴식 0 — freqMap={} 와 freqMap 주입 두 경로 모두 괴식 0
A-12-8속성결정론 — 같은 날 입력 2회 동일 추천 — 동일 day fixture 2회 → 동일 recommendedIng
DoD
의존A-10

EPIC B — v3 두뇌 가이드 합성 — buildTeachingGuide D2

v3 두뇌(decideDailyV3 일간 진단·계획 + runWeeklyPlanning/impression 주간 종합 + UNITS 12유닛 커리큘럼)를 고정 블록이 아니라 LLM용 '가이드 브리프'로 합성한다. 결정·진단은 결정론으로 못 박고(unit_ko·lever·stepBehavior·arcStage·재서술 금지목록), 표현(문장·도입·톤)만 LLM에 푼다. 새 순수함수 buildTeachingGuide()의 출력은 coachRecos 재료와 합쳐 Letter B 입력(LetterInput)이 되며, 모든 변경은 Letter B에만 적용(A 무변경).

📁 신규 web/lib/coachGuide.ts web/tests/coach-guide.test.ts · 수정 web/lib/coachDaily.ts web/lib/coachWeekly.ts web/lib/curriculumUnits.ts

B-01 · TeachingGuide 타입·계약 정의 (coachGuide.ts 골격)설계 코드 · 0.5h

목적v3 두뇌를 LLM 재료로 전달하는 단일 계약(TeachingGuide)을 박제해 이후 원자(B-02~)와 Letter B 조립(EPIC 외부)이 같은 형태를 공유하게 한다. 고정 슬롯·byte 거울 금지 원칙을 타입 주석에 명시.
요구buildTeachingGuide의 출력 타입 TeachingGuide = {unit_ko, lever, stepBehavior, why, arcStage, weeklyImpressionSoft, doNotRestate, stepN, mode} 를 lib/coachGuide.ts에 선언. 모든 필드는 '코드가 못 박는 사실/가이드'이며 문장 작문은 LLM 몫임을 JSDoc에 명문화.
명세
유즈케이스
테스트4개
ID종류케이스 · 검증(입력→기대)
B-01-1단위TeachingGuide 타입 컴파일·필드 완결성 — buildTeachingGuide 더미 호출 결과 객체에 unit_ko·lever·stepBehavior·why·arcStage·weeklyImpressionSoft·doNotRestate·stepN·mode 9키가 모두 존재 → toHaveProperty 9건 통과
B-01-2단위lever 값은 UnitDef.lever 유니온 내 — 임의 유닛 가이드의 lever ∈ {food,environment,autonomy,texture,mixed} → 포함 검증 true
B-01-3단위doNotRestate는 항상 배열(null 아님) — 가이드.doNotRestate가 Array.isArray=true (빈 배열이라도 배열)
B-01-4회귀coachGuide.ts가 coach.ts(Letter A 경로)를 import하지 않음 — grep -L 'coach.ts' 관점: coachGuide.ts 소스에 "from './coach'" 미포함 → A 경로 비오염 보장
DoD
의존없음

B-02 · decideDailyV3 결정 → stepBehavior·unit_ko·lever·stepN 매핑코드 코드 · 1h

목적DailyDecision(unit·step·mode)을 UNITS 레지스트리에서 사람이 읽는 가이드 재료(한국어 유닛명·해당 단의 행동 문구·레버·단계번호)로 변환한다. 이것이 '무엇을 가르칠지'를 코드가 못 박는 핵심.
요구buildTeachingGuide가 decision.unit→UNITS[unit].label(unit_ko)·.lever, decision.step→steps[step-1].behavior(stepBehavior)·stepN을 추출. step 범위 밖(0·초과)이면 가장 가까운 유효 단으로 클램프. decision=null이면 lowdata/온보딩 분기(B-07)로 위임.
명세
유즈케이스
테스트11개
ID종류케이스 · 검증(입력→기대)
B-02-1단위table-stage step1 매핑 — decision={unit:'table-stage',step:1,mode:'advance'} → unit_ko='식탁 무대', lever='environment', stepBehavior='하루 한 끼 화면 끄고 식탁에서', stepN=1
B-02-2단위table-stage step2 매핑 — step:2 → stepBehavior='주 5끼+ 같은 자리·같은 시간', stepN=2
B-02-3단위exposure-savings food 레버 매핑 — decision.unit='exposure-savings' → lever='food', unit_ko='새 음식 조금씩 노출'
B-02-4단위pressure-off mixed 레버 매핑 — unit='pressure-off' → lever='mixed', unit_ko='압박 내려놓기'
B-02-5적대step=0 클램프 방어 — decision.step=0 → stepN=1, stepBehavior=steps[0].behavior (에러 없음·undefined 아님)
B-02-6적대step 초과 클램프 방어 — sensory-texture step=99 → stepN=2(=steps.length), stepBehavior=steps[1].behavior
B-02-7단위pivot 결정이 pivotTo 유닛으로 매핑 — decision={unit:'autonomy-part',step:1,mode:'pivot',pivotTo:'autonomy-part'} → unit_ko='자율성·참여 트랙'(stop된 직전 focus 아님)
B-02-8단위12 유닛 전부 stepBehavior non-empty — UNIT_IDS 각각 step1·step2로 buildTeachingGuide → stepBehavior.length>0 (24케이스 전부 truthy)
B-02-9단위mode 통과(가공 없음) — decision.mode='celebrate' → guide.mode='celebrate' (변형 없이 통과)
B-02-10단위link-rhythm step2 매핑 — unit='link-rhythm',step=2 → stepBehavior='격일 리듬으로 재노출 이어가기(주 2회)', lever='food'
B-02-11회귀food-bridge/no-bargain/table-talk label 정확 — 세 유닛 unit_ko가 각각 '확장 트랙(음식 다리)'·'달콤한 협상 끊기'·'식탁의 말'
DoD
의존B-01

B-03 · mode → arcStage 결정론 매핑 (advance/deepen/pivot/celebrate/observe→intro/how/obstacle/observe/reinforce)코드 코드 · 1h

목적v3 일간 mode와 v2 작문 아크(WeeklyArcStage)를 잇는다. mode가 '오늘 무엇을 할지'라면 arcStage는 '편지를 어떤 각도로 쓸지'. 고정 블록 대신 LLM에 '오늘은 이 단계 톤으로'를 가이드.
요구buildTeachingGuide가 mode + firstOfWeek + lastArcStage + progress(관측됨)를 받아 arcStage를 결정. planFromWeekly의 기존 아크 회전 규칙(intro=주첫·reinforce 이틀연속 금지·how/obstacle/observe 요일회전)을 재사용하되 mode 신호를 우선 반영.
명세
유즈케이스
테스트11개
ID종류케이스 · 검증(입력→기대)
B-03-1단위celebrate→reinforce — mode='celebrate' → arcStage='reinforce' (firstOfWeek 무관)
B-03-2단위주첫+advance→intro — firstOfWeek=true,mode='advance' → arcStage='intro'
B-03-3단위주첫+pivot→intro — firstOfWeek=true,mode='pivot' → arcStage='intro'(새 유닛 도입)
B-03-4단위maintain→observe — mode='maintain' → arcStage='observe'(행동 권유 없는 위안 톤)
B-03-5단위progress 관측+직전 비reinforce→reinforce — progress=true,lastArcStage='how' → arcStage='reinforce'
B-03-6적대reinforce 2연속 차단 — progress=true,lastArcStage='reinforce' → arcStage∈{how,obstacle,observe}(reinforce 아님)
B-03-7적대deepen은 주중에 intro 금지 — firstOfWeek=false,mode='deepen' → arcStage∈ROT(intro 아님)
B-03-8단위요일 회전 결정론(dow별 안정) — firstOfWeek=false,progress=false,mode='deepen', dow=2 두 번 호출 → 동일 arcStage(순수·결정론)
B-03-9단위요일 회전 분산(dow 다르면 단계 변화 가능) — dow=1,2,3 폴백 회전 → ROT 인덱스가 ((dow+6)%7)%3로 분포(최소 2종 이상 stage)
B-03-10회귀intro는 주중(firstOfWeek=false)엔 절대 안 나옴 — firstOfWeek=false인 모든 mode(advance·deepen·pivot·maintain·observe) → arcStage!=='intro'
B-03-11단위observe mode + 주첫이 아니면 ROT — firstOfWeek=false,mode='observe',progress=false → arcStage∈ROT
DoD
의존B-01 · B-02

B-04 · why(코칭 근거) 결정론 생성 — 유닛·단계·수치 기반코드 코드 · 1h

목적'왜 이 코칭인가'를 코드가 수치·유닛 근거로 못 박아 LLM 재료로 제공(F 기간수치 원칙과 정합). LLM이 근거를 지어내지 않게(환각 차단), 그러나 문장은 LLM이 자유롭게 쓰게.
요구buildTeachingGuide가 focus 유닛의 progress evidence(passWhen 신호값)와 주간 닻 mission_target을 근거 문구로 압축. 모호어('요즘/최근') 대신 수치(주간 등장일·비율) 포함. 부족 임계는 TH 상수로 정의.
명세
유즈케이스
테스트10개
ID종류케이스 · 검증(입력→기대)
B-04-1단위table-stage why에 수치·임계 포함 — evidence.envTablePct7d=0.45,mode=advance → why에 '45'와 임계 '40' 둘 다 등장(모호어 '요즘'·'최근' 미포함)
B-04-2단위exposure-savings why에 노출일 수치 — evidence.targetExposeDays7d=1 → why에 '1' 및 주 목표 수치(TH.exposeWeekly=2) 등장
B-04-3단위evidence null → 표본부족 degrade — evidence={} (수치 키 없음) → why가 '표본'·'관찰' 류 문구, 가짜 수치 0건(/[0-9]+%/ 매치 없음)
B-04-4회귀why에 모호 기간어 금지 — 어떤 입력이든 why에 '요즘'·'최근'·'이번주(공백없이)' 모호어 미포함(수치/명시 기간만)
B-04-5단위mode별 각도 분기 — maintain — mode='maintain' → why에 '정체' 또는 '태도' 류 + 행동 권유 어휘 미포함
B-04-6단위mode별 각도 분기 — pivot — mode='pivot' → why에 '전환' 또는 '바꿔' 류(목표 전환 근거)
B-04-7회귀임계는 TH에서 읽음(하드코딩 금지) — TH.envTableStep1을 0.5로 모킹/변경 시 why의 임계 수치도 50으로 따라감(매직넘버 아님)
B-04-8단위celebrate why=졸업·유지 통과 — mode='celebrate' → why에 '유지' 또는 '통과' 류
B-04-9단위why 길이 상한(재료지 문단 아님) — 임의 입력 why.length <= 120 (LLM 재료용 짧은 사실 조각)
B-04-10단위deepen why=신호 있으나 정체 — mode='deepen' → why에 '신호'와 '정체'(또는 '이어') 류
DoD
의존B-01 · B-02

B-05 · weeklyImpressionSoft — runWeeklyPlanning impression의 부모비노출 가공코드 코드 · 0.5h

목적주간 닻 impression(Sonnet 내부 소견·부모 비노출)을 일간 가이드의 '배경 맥락 한 줄'로 부드럽게 정제해 LLM 재료로 준다. 진단어·내부개념을 LLM 입력 단계에서 차단(H 무검증 채널 닫기와 정합).
요구buildTeachingGuide가 anchor.impression(coachWeekly WeeklySynthesis.impression)을 받아 weeklyImpressionSoft로 가공. 진단/처방 단어·'미션/과제/진도/단계' 내부 용어 스캔 후 제거 또는 null화. impression 없으면 null.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
B-05-1단위정상 impression 통과 — impression='지난주 환경이 나아졌어요' → weeklyImpressionSoft에 동 내용 포함(금칙어 없으면 보존)
B-05-2적대진단어 문장 드롭 — impression='ARFID 의심됩니다. 환경부터.' → weeklyImpressionSoft에 'ARFID' 미포함
B-05-3적대내부용어 '미션/진도' 드롭 — impression='이번 주 미션은 진도 2단계' → ' 미션'·'진도' 미포함
B-05-4단위null impression → null — anchor.impression=null → weeklyImpressionSoft===null
B-05-5단위길이 상한 — impression 300자 → weeklyImpressionSoft.length <= 200
B-05-6적대anchor=null 방어 — anchor=null → weeklyImpressionSoft===null(throw 없음)
DoD
의존B-01

B-06 · doNotRestate — D-04 사실 재서술 금지 목록 산출코드 코드 · 1h

목적v3 'D-04 사실 재서술 원장'을 가이드로 옮긴다. 직전 N일 편지가 이미 말한 사실(거울 문장·인용 식재료·도입 동일성)을 LLM에 '이건 다시 말하지 마라' 목록으로 줘 반복 수렴을 줄인다(반복방지 B·검증 비대칭 G와 정합).
요구buildTeachingGuide가 최근 편지 컨텍스트(pastLetters/blocks)에서 이미 도입한 유닛·이미 인용한 핵심 사실을 수집해 doNotRestate 배열로 반환. 빈 입력이면 [].
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
B-06-1단위이미 intro한 focus 유닛 → 재도입 금지 — ctxs[0].blocks=['table-stage.intro.1'], focus=table-stage → doNotRestate에 '식탁 무대' 관련 재도입 금지 항목 포함
B-06-2단위intro 안 한 유닛 → 금지 없음 — ctxs blocks에 focus 유닛 intro 없음 → doNotRestate에 그 유닛 재도입 금지 미포함
B-06-3단위빈 ctxs → 빈 배열 — ctxs=[] → doNotRestate=[] (length 0)
B-06-4회귀recentIntroUnitsOf 재사용(중복구현 금지) — coachDaily.recentIntroUnitsOf와 동일 입력에 동일 유닛 집합 도출(공용 함수 사용 — 패턴 'X.intro.N' 파싱 일관)
B-06-5적대common.intro는 유닛으로 안 잡음 — blocks=['common.intro.1'] → doNotRestate에 'common' 미포함(recentIntroUnitsOf가 common 제외)
B-06-6적대null/undefined ctx 항목 방어 — ctxs=[null, undefined, {}] → throw 없이 doNotRestate=[]
B-06-7단위거울 사실 재인용 금지 항목 수집 — ctxs[0].mirror에 '당근' 인용 메타 존재 → doNotRestate에 해당 사실 재인용 금지 항목 추가(있을 때만)
B-06-8단위항목 중복 제거 — 두 ctx가 같은 유닛 intro → doNotRestate에 동일 항목 1건만(dedup)
DoD
의존B-01

B-07 · 온보딩·무기록 분기 (decision=null / lowData=true)코드 코드 · 1h

목적개선 E(기록<3일)와 decideDailyV3의 lowData/observe 경로를 가이드에 반영. 분석 대신 '빠뜨린 입력 안내 + 배경없는 즉효 팁'을 가이드로 줘 가입 다음날 환각('요즘 채소 비어요')을 차단.
요구buildTeachingGuide가 DailyV3Result.lowData=true 또는 decision=null이면 분석형 가이드(why 수치)를 만들지 않고 온보딩 가이드(arcStage='observe'·doNotRestate에 '데이터 단정 금지'·why='기록이 적어 관찰 중')를 반환.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
B-07-1단위lowData=true → 온보딩 가이드 — lowData=true → arcStage='observe', why에 숫자/% 매치 0건, doNotRestate에 '단정 금지' 류 포함
B-07-2단위decision=null → 관찰 가이드 — decision=null → guide.mode='observe', stepBehavior='' (행동 권유 없음)
B-07-3적대온보딩 가이드는 가짜 결핍 0 — lowData=true, anchor.mission_target='채소' → why에 '채소 비어요' 류 결핍 단정 미포함
B-07-4단위lowData에서도 unit_ko는 focus 있으면 노출 — lowData=true, decision.unit='table-stage' → unit_ko='식탁 무대'(유닛은 보이되 진단은 보류)
B-07-5회귀lowData 우선(분석경로 미진입) — lowData=true이면 B-04 수치 why 생성 함수 미호출(spy 0회 또는 why에 evidence 수치 0)
B-07-6단위frow.step 보존(리셋 아님) — lowData=true, decision.step=2 → stepN=2(복귀 후 이어가기 — 리셋 아님)
DoD
의존B-01 · B-02 · B-04

B-08 · buildTeachingGuide 통합 — 전 조각 합성 + 진입점 시그니처코드 코드 · 1.5h

목적B-02~B-07 조각을 하나의 진입 함수로 묶어 크론(H)이 한 번 호출해 TeachingGuide를 받게 한다. 결정·진단=결정론, 표현=LLM 경계의 단일 게이트.
요구buildTeachingGuide(p: {dailyResult: DailyV3Result; anchor: WeeklyAnchor | null; firstOfWeek: boolean; lastArcStage?: string | null; progress: boolean; recentCtxs: Array<Record<string,unknown>|null>; dow: number}): TeachingGuide. 내부에서 lowData→B-07, 그 외→B-02·B-03·B-04·B-05·B-06 조립.
명세
유즈케이스
테스트9개
ID종류케이스 · 검증(입력→기대)
B-08-1통합정상 합성 — 전 필드 채워짐 — decision=table-stage/advance, anchor 정상 → 9필드 전부 truthy(doNotRestate 배열), mode='advance'
B-08-2통합lowData 분기 우선 — dailyResult.lowData=true → onboarding 가이드(arcStage='observe', why 수치 0)
B-08-3단위plateau → observe 강제 — dailyResult.plateau=true, decision.mode='maintain' → arcStage='observe'
B-08-4적대anchor=null 안전 폴백 — anchor=null → throw 없음, weeklyImpressionSoft=null, why는 evidence만으로 생성
B-08-5회귀LLM 0콜(순수) — buildTeachingGuide 내부에서 callClaude 미import/미호출 — 결정론 보장(소스 grep 'callClaude' 0건)
B-08-6통합firstOfWeek 전파 → arcStage — firstOfWeek=true, advance → arcStage='intro'(B-03 위임 정합)
B-08-7통합recentCtxs 전파 → doNotRestate — recentCtxs에 focus intro 있음 → doNotRestate non-empty(B-06 위임 정합)
B-08-8속성동일 입력 동일 출력(결정론) — 같은 인자 2회 호출 → JSON.stringify 동일(시계·랜덤 의존 0)
B-08-9회귀warnings는 가이드에 누설 안 됨 — dailyResult.warnings=['같은 전개 3연속'] → TeachingGuide 어느 필드에도 내부 warning 문자열 미노출(부모 비노출)
DoD
의존B-01 · B-02 · B-03 · B-04 · B-05 · B-06 · B-07

B-09 · 진도 영속 정책(B는 진도 굴림) — goalsAfter/updates 반영 계약설계 코드 · 1h

목적A/B 구조에서 아린의 진도(curriculum_progress·weekly_plans.goals)를 누가 굴리는지 못 박는다. compare 모드는 v3 순수 조립을 대체하므로, 진도 전진(decideDailyV3.updates·goalsAfter)은 Letter B 경로가 책임지고 영속한다(미래 차단·연속성).
요구buildTeachingGuide는 진도를 변경하지 않는 읽기 전용(순수)이고, 진도 영속은 크론(H)이 decideDailyV3의 updates·goalsAfter를 curriculum_progress·anchor.goals에 upsert함을 계약으로 문서화. B는 진도를 굴리고 A는 진도를 굴리지 않음(대조군은 v2 planFor만, 진도 무관).
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
B-09-1단위updates → progressUpserts 통과 — dailyResult.updates=[row1,row2] → nextProgressState.progressUpserts.length=2 (그대로 전달)
B-09-2단위goalsAfter → goalsForAnchor — dailyResult.goalsAfter=[focus,standby] → goalsForAnchor 동일 배열(닻 저장용)
B-09-3회귀피벗 focus 플립 보존 — goalsAfter에 unit A=stopped·B=focus → goalsForAnchor도 A=stopped·B=focus(다음날 되돌림 방지)
B-09-4단위lowData=true → 진도 동결 — dailyResult.lowData=true,updates=[] → progressUpserts.length=0(전이·적립 없음 — 동결)
B-09-5회귀순수(DB 미접근) — nextProgressState 소스에 supabase/db import 0건(형태만 계산·SQL은 H)
B-09-6수동정책 상수 존재·내용 — GUIDE_PERSISTENCE_NOTE에 'Letter B만'·'진도'·'A는 비참여' 키워드 포함(정책 박제)
DoD
의존B-01 · B-08

B-10 · 데이터 정합성 I — GROUP_INGREDIENTS 대표 식재료 급식빈도 정비(가이드 노출 정합)코드 코드 · 1h

목적개선 I: 단호박(급식 0회)·근대(11회) 같은 빈도 없는/낮은 대표 식재료가 가이드 why·재료에 올라오면 freqMap 가중(C)에서 탈락하므로, exposure-savings/food-bridge 유닛이 가이드에 노출하는 대표 식재료를 급식빈도 있는 것으로 정비한다.
요구가이드의 food 레버 유닛(exposure-savings·food-bridge·link-rhythm)이 참조하는 대표 식재료 목록을 급식빈도(learned_menus 실측) 기준으로 점검·교체. 단호박→토마토/브로콜리 등 빈도 있는 식재료로. 이 정비는 coachRecos.GROUP_INGREDIENTS(EPIC C 소관)와 정합을 맞추되, B 가이드가 직접 식재료를 만들지 않고 C 재료를 받는 경계를 명확히.
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
B-10-1적대0회 식재료 가이드 노출 탐지 — why에 '단호박' 삽입(인위) + freqMap[단호박]=0 → assertNoZeroFreqStaple이 위반 1건 탐지(경고)
B-10-2단위정상 빈도 식재료는 통과 — why에 '당근'(freq 184) → 위반 0건
B-10-3회귀freqMap fixture 정합 — fixture freqMap: 당근=184·토마토=42·단호박=0·요거트=0 (인계서 실측과 일치)
B-10-4회귀가이드는 식재료를 새로 만들지 않음(C 경계) — buildTeachingGuide 입력에 식재료 후보 미주입 시 why/stepBehavior에 새 식재료명 0건 생성(C 재료에만 의존)
B-10-5적대빈 freqMap 방어 — freqMap={} → assertNoZeroFreqStaple throw 없이 위반 0(빈도 정보 없으면 검사 스킵)
DoD
의존B-04 · B-08

B-11 · coach-guide 테스트 스위트 + 리플레이 픽스처(아린 6통 회귀)테스트 테스트 · 1.5h

목적불변 원칙 ③(엣지=fixture+테스트+수정 복리)을 EPIC B에 적용. 아린 실데이터 6통이 만들던 수렴/환각/모호기간 패턴이 buildTeachingGuide 경로에서 재발하지 않음을 박제. '게이트 그린≠좋은 편지'를 깨는 가이드측 회귀.
요구tests/coach-guide.test.ts에 B-02~B-10 케이스를 모으고, 아린 6통 실증 패턴(당근→미역국 수렴·요즘/최근 모호어·가입 다음날 환각·미역국에 당근 괴식 조합은 A/B 조합검증 소관이나 가이드 why에 그 조합이 안 나옴)을 가이드 레벨에서 회귀.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
B-11-1회귀같은 mode 6일 → arcStage 분산 — focus 동일·mode='deepen' 6일(firstOfWeek=false) → arcStage가 최소 2종(how/obstacle/observe 회전·6통 복붙 수렴 차단)
B-11-2회귀14일 시뮬 intro 주1회 이하 — 14일 buildTeachingGuide 시뮬 → 임의 7일 창에서 arcStage==='intro' 횟수 <=1
B-11-3회귀6 가이드 모호어 0 — 아린 6통 재현 가이드 전부 why에 /요즘|최근/ 매치 0건
B-11-4회귀limping 고착 → pivot/plateau 가이드 — table-stage envTablePct 0.14·coachedDays>=3·passStreak 0 → guide.mode∈{pivot,maintain} (18일 deepen 고착 재발 안 함)
B-11-5적대가입 다음날 환각 0 — 기록 1일(lowData) → 가이드 why에 결핍 식품군 단정 0건
B-11-6회귀reinforce 이틀연속 0 — 14일 시뮬에서 연속 두 날 모두 arcStage='reinforce'인 경우 0건
B-11-7E2E전 유닛 가이드 스모크(throw 0) — 12유닛×{advance,deepen,maintain,pivot,celebrate,observe} 조합 buildTeachingGuide 호출 → throw 0·9필드 항상 채워짐
B-11-8속성결정론 — 시드 고정 재현 — 동일 입력 시퀀스 2회 시뮬 → 일별 TeachingGuide JSON 완전 동일
DoD
의존B-02 · B-03 · B-04 · B-05 · B-06 · B-07 · B-08 · B-10

EPIC C — merged 작문 모드 — composeLetter groundingMode (재료=결정론·문장=LLM·사실=카드) D2

composeLetter에 Letter B 전용 merged(grounding) 모드를 추가한다. 거울·사실카드를 byte 고정 없이 데이터로 주입하고, 검증된 재료(selectDailyMaterials)·근거문구·수치 기간을 LLM 재료로 묶되 문장만 LLM이 자유롭게 쓰게 하며, 두뇌 가이드(buildTeachingGuide)를 주입하고, 기록<3일 온보딩 분기와 raw timeseries 강등(사실 인용은 카드만)을 적용한다. Letter A 경로(planFor+composeLetter 기존)는 분기로 완전 무변경.

📁 신규 web/lib/coachGrounding.ts web/tests/coach-grounding.test.ts · 수정 web/lib/coach.ts web/tests/coach-guards.test.ts

C-01 · LetterInput grounding 확장 + GroundingMode 타입코드 코드 · 0.5h

목적merged 모드가 받을 입력 필드를 LetterInput에 추가하되 기존 필드·Letter A 경로는 무변경(모두 optional). 분기 신호(groundingMode)와 두뇌 가이드·재료·온보딩 안내를 담는 컨테이너를 정의한다.
요구lib/coach.ts의 LetterInput(현 103~138줄)에 optional 필드만 추가하고, groundingMode 미지정 시 buildLetterUser/composeLetter 동작이 byte 동일이어야 한다(Letter A 대조군 보호).
명세
유즈케이스
테스트4개
ID종류케이스 · 검증(입력→기대)
C-01-1회귀groundingMode 미지정 시 buildLetterUser 출력 byte 동일 — 기존 LetterInput fixture(groundingMode 없음) → buildLetterUser 출력이 변경 전 스냅샷과 정확히 일치
C-01-2단위신규 필드 전부 optional — 빈 객체 통과 — buildLetterUser({}) → throw 없이 문자열 반환
C-01-3단위GroundingMode 타입은 'merged'만 허용 — const m: GroundingMode = 'merged' 컴파일 OK · 'x' 컴파일 에러(tsc 게이트)
C-01-4단위groundingMode='merged'면 buildLetterUser merged 분기 진입 — base.groundingMode='merged' → 출력에 merged 전용 마커 문자열 포함
DoD
의존없음

C-02 · buildLetterUser merged 분기 — 재료(결정론) 블록 주입코드 코드 · 1.5h

목적merged 모드에서 '검증된 재료(materials)'를 LLM 재료로 명시 주입하고 'LLM은 재료를 지어내지 말고 주어진 것만 쓴다'를 지시한다. 재료=결정론 문장=자유 사상(6/8 '당근→미역국' 수렴 차단).
요구groundingMode='merged'일 때만 materials 블록을 ctx에 끼우고, 레거시는 기존 bridgeFacts 블록(426~427줄)을 그대로 쓴다.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
C-02-1단위merged + materials 있음 → 재료 블록 포함 — buildLetterUser({groundingMode:'merged',materials:'짜파게티+당근(2)'}) → '짜파게티+당근(2)'·'오늘의 재료'·'목록 안에서만' 포함
C-02-2단위merged + materials 없음 → bridgeFacts 폴백 — buildLetterUser({groundingMode:'merged',materials:null,bridgeFacts:'BF'}) → '검증된 추천'·'BF' 포함
C-02-3회귀레거시 → 기존 bridgeFacts만, materials 미주입 — buildLetterUser({bridgeFacts:'BF',materials:'M'}) (미지정) → '오늘의 재료' 미포함, 'BF' 포함
C-02-4단위재료=결정론 사상 문구 주입 — merged 출력에 '어떻게 따뜻하게 쓸지만' 또는 '결정' 취지 포함
C-02-5단위특수문자(·+()) 깨짐 없음 — materials='볶음밥+당근·카레+당근(2) / 근거: 베타카로틴' → 출력에 원문 그대로 포함
C-02-6적대'목록 밖 음식 지어내기 금지' 지시 유지 — merged 출력에 '목록 밖'·'지어내'·'괴식' 중 최소 2개 포함
DoD
의존C-01 · C-07

C-03 · buildLetterUser merged — 거울(mirror) 데이터 주입(byte 고정 금지)코드 코드 · 1h

목적compileFactCards가 만든 mirror를 merged 프롬프트에 '데이터'로 주입하되, LLM이 매일 다르게 풀어 쓰도록 지시한다(고정 슬롯·byte 거울 금지 — 이사님 확정). 사실은 묶되 표현은 풀어 유연함 유지.
요구merged 모드에서만 mirror 블록을 끼우고 '이 거울을 그대로 복사하지 말고 너의 문장으로'를 명시. 레거시는 buildLetterUser에 mirror를 안 쓰므로 무영향.
명세
유즈케이스
테스트4개
ID종류케이스 · 검증(입력→기대)
C-03-1단위merged + mirror → 거울 데이터 + byte 복사 금지 지시 — buildLetterUser({groundingMode:'merged',mirror:'당근 비어요'}) → '당근 비어요'·'그대로 베끼지'·'byte' 또는 '복사 금지' 포함
C-03-2단위mirror 없음 → 거울 블록 미주입 — buildLetterUser({groundingMode:'merged',mirror:null}) → '식단 거울' 헤더 미포함
C-03-3회귀레거시는 mirror 무시(무영향) — buildLetterUser({mirror:'X'}) (미지정) → 'X' 미포함, 기존 출력과 동일
C-03-4단위거울 사실(메뉴·결핍군) 보존 지시 — merged+mirror 출력에 '사실'·'바꾸지' 취지 또는 '메뉴·결핍' 유지 지시 포함
DoD
의존C-01

C-04 · buildLetterUser merged — 두뇌 가이드(teachingGuide) 주입코드 코드 · 1h

목적buildTeachingGuide(EPIC B) 산출을 고정 블록이 아니라 '가이드'로 LLM에 주고 LLM이 직접 쓰게 한다(v3의 두뇌만, 손은 v2 LLM). 커리큘럼·실라버스도 가이드로 전달.
요구merged 모드에서만 teachingGuide 블록을 끼우고 '교육 의도를 따르되 문장·도입·톤은 자유'를 명시. teachingGuide 없으면 기존 arcBlock(weeklyArc) 폴백.
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
C-04-1단위merged + teachingGuide → 가이드 + 자유 작문 지시 — buildLetterUser({groundingMode:'merged',teachingGuide:'당근 푸드체이닝'}) → '당근 푸드체이닝'·'문장·도입'·'자유' 포함
C-04-2단위teachingGuide 있으면 arcBlock 생략 — buildLetterUser({groundingMode:'merged',teachingGuide:'G',weeklyArc:{stage:'how',behaviorGoal:'B'}}) → '이번 주 코칭 방향'(arcBlock) 미포함, 'G' 포함
C-04-3단위teachingGuide 없음 → weeklyArc arcBlock 폴백 — buildLetterUser({groundingMode:'merged',teachingGuide:null,weeklyArc:{stage:'how',behaviorGoal:'B'}}) → arcBlock 포함
C-04-4회귀레거시는 teachingGuide 무시 — buildLetterUser({teachingGuide:'G'}) (미지정) → 'G' 미포함, 기존 arcBlock 동작 유지
C-04-5단위merged 가이드에 P7 원칙 유지 — merged+teachingGuide 출력에 '행동'·'하나' 또는 '한 가지' 포함
DoD
의존C-01

C-05 · buildLetterUser merged — 온보딩(기록<3일) 분기코드 코드 · 1h

목적기록<3일이면 분석 대신 (1)빠뜨린 입력 안내 + (2)배경 없는 즉효 편식 팁만 쓴다(개선 E). 가입 다음날 '요즘 채소 비어요' 환각 분석을 구조적 차단.
요구merged + onboardingMode=true이면 분석·결핍·시계열 블록을 모두 빼고 온보딩 전용 프롬프트로 분기. Letter A·레거시 무영향.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
C-05-1단위merged + onboarding → 분석 블록 전부 생략 — buildLetterUser({groundingMode:'merged',onboardingMode:true,reds:['철분'],missing:['채소'],timeseries:['T']}) → '철분'·'부족 영양소'·'시계열 사실' 미포함
C-05-2단위온보딩 → 결핍 분석 금지 지시 + 입력 안내 — 출력에 '분석하지'·'안내'·'팁' 취지 포함, missingInputHints 항목 포함
C-05-3단위온보딩 → 즉효 팁 1개 주입 — buildLetterUser({groundingMode:'merged',onboardingMode:true}) → pickTip 결과 문자열 포함(TIPS 존재 시)
C-05-4회귀merged이지만 onboardingMode=false → 정상 분석 — buildLetterUser({groundingMode:'merged',onboardingMode:false,reds:['철분']}) → '철분' 포함
C-05-5회귀레거시는 onboardingMode 무시 — buildLetterUser({onboardingMode:true,reds:['철분']}) (미지정) → '철분' 포함
C-05-6단위missingInputHints 없으면 기본 안내 — buildLetterUser({groundingMode:'merged',onboardingMode:true,missingInputHints:null}) → '끼니'·'거부'·'환경' 중 최소 1개 포함
DoD
의존C-01 · C-08

C-06 · buildLetterUser merged — raw timeseries 강등(사실 인용은 카드만)코드 코드 · 0.75h

목적merged에서 raw timeseries를 '인용 가능 사실'에서 강등하고 모든 빈도·횟수·패턴 주장은 factCards(시계열 라벨)만 근거로 삼게 한다. timeseries는 톤 참고로만(개선 G·F).
요구merged에서 timeseries 라벨을 '사실 인용 금지·톤 참고용'으로 바꾸고 factCards를 '유일 사실 출처'로 강조. 레거시는 'timeseries=사실' 취급 유지.
명세
유즈케이스
테스트4개
ID종류케이스 · 검증(입력→기대)
C-06-1단위merged → timeseries 라벨 '참고용·인용 금지' — buildLetterUser({groundingMode:'merged',timeseries:['당근 거부']}) → '참고용'·'인용하지' 취지 포함, '당근 거부' 자체는 포함
C-06-2단위merged → factCards 유일 사실 출처 강조 — buildLetterUser({groundingMode:'merged',factCards:['거부: 당근 단발 1회']}) → '사실 카드에만' 또는 '사실 주장은' 강화 지시 포함
C-06-3회귀레거시 → '시계열 사실' 라벨 유지 — buildLetterUser({timeseries:['T']}) (미지정) → '시계열 사실' 포함, '참고용' 미포함
C-06-4단위merged + timeseries 없음 → 자연 처리 — buildLetterUser({groundingMode:'merged',timeseries:[]}) → throw 없음, '없음' 또는 빈 처리
DoD
의존C-01

C-07 · serializeMaterials — 검증 재료를 LLM 텍스트로 직렬화(lib/coachGrounding.ts)코드 코드 · 1.5h

목적selectDailyMaterials(EPIC B) 결과(검증 조합·근거문구·수치기간)를 buildLetterUser merged가 주입할 단일 텍스트 블록으로 직렬화하는 순수 함수. 재료=결정론 사상의 직렬화 계층.
요구조합·근거문구·수치기간·사촌을 정해진 포맷 문자열로 합성. 순수 함수(fs/HTTP 없음·단독 테스트).
명세
유즈케이스
테스트9개
ID종류케이스 · 검증(입력→기대)
C-07-1단위기본 직렬화 — 타깃·조합·근거·기간 전부 포함 — serializeMaterials({target:'비타민A채소',targetIngredient:'당근',combos:[{dish:'짜파게티',ingredient:'당근',score:2}],rationale:'급식 상위2%',periodFact:'최근 7일 중 3일'}) → '당근'·'짜파게티'·'급식 상위2%'·'최근 7일 중 3일' 전부 포함
C-07-2적대score 0 조합 제외 — combos에 {dish:'미역국',ingredient:'당근',score:0} → 출력에 '미역국' 미포함
C-07-3단위빈 combos → 근거·사촌만 — serializeMaterials({...,combos:[],cousins:['단호박']}) → '검증된 조합' 라인 미포함, '단호박' 포함
C-07-4단위조합 점수순 정렬 — combos=[score:1,score:3,score:2] → 출력 순서 3·2·1
C-07-5적대타깃·조합·사촌 외 음식명 합성 안 함 — 입력에 없는 음식명은 출력에 등장하지 않음
C-07-6단위cousins 없으면 사촌 라인 생략 — serializeMaterials({...,cousins:undefined}) → '사촌' 라인 미포함
C-07-7단위특수문자(·+()↑) 보존 — rationale='눈·면역 ↑' → 출력에 '눈·면역 ↑' 그대로
C-07-8적대score 음수/NaN 방어 — 제외 — combos에 score:NaN, score:-1 → 둘 다 제외
C-07-9단위긴 combos 상한 절단 — 10개 combos → 출력 조합 항목 <= 4
DoD
의존없음

C-08 · buildOnboardingDecision — 기록<3일 판정 + 입력 안내 산출(순수)코드 코드 · 1h

목적전체 기록 일수로 onboardingMode를 결정하고 비어 있는 빠뜨린 입력 항목(거부·환경·저녁끼니)을 안내 목록으로 산출하는 순수 함수. 개선 E의 결정 계층.
요구loggedDaysTotal<3이면 onboarding=true. 빠뜨린 항목은 실제 행 데이터에서 비어 있는 것만 안내. 순수 함수(단독 테스트).
명세
유즈케이스
테스트9개
ID종류케이스 · 검증(입력→기대)
C-08-1단위loggedDaysTotal 2 → onboarding true — buildOnboardingDecision({rows:[],loggedDaysTotal:2}).onboarding === true
C-08-2단위loggedDaysTotal 3 → false(경계) — buildOnboardingDecision({rows:[3일분],loggedDaysTotal:3}).onboarding === false
C-08-3단위거부 없음 → '거부한 음식' 안내 — rows 전부 refused:null → hints에 '거부한 음식' 포함
C-08-4단위거부 있음 → '거부한 음식' 제외 — rows에 refused:'당근' 1건 → hints에 '거부한 음식' 미포함
C-08-5단위환경 칩 없음 → '식사 환경' 안내 — rows 전부 environment:null → hints에 '식사 환경' 취지 포함
C-08-6단위저녁 슬롯 없음 → '저녁 끼니' 안내 — rows에 dinner 0건 → hints에 '저녁' 포함
C-08-7단위모두 채움 → hints 빈 배열 — 거부·환경·아침/점심/저녁 다 있는 rows → hints.length === 0
C-08-8단위빈 rows → onboarding true + 기본 안내 다수 — buildOnboardingDecision({rows:[],loggedDaysTotal:0}) → onboarding true, hints.length >= 2
C-08-9단위ONBOARDING_MIN_DAYS 상수 노출 — ONBOARDING_MIN_DAYS === 3
DoD
의존없음

C-09 · verifyComboSafety — 섞기 조합 정합성 검증(괴식 차단)코드 코드 · 1.5h

목적merged가 주입할 mirror·materials의 '잘먹는음식+결핍식재료' 조합이 kit-dish-matrix(0~3)+food-graph pair로 검증 통과한 것인지 확인하는 순수 함수. 미통과 조합은 재료에서 제거(개선 A — '미역국에 당근' 차단).
요구조합 후보를 scoreOf(주입)로 필터(score<1 또는 없음=금지), 통과 조합만 반환. 순수 함수.
명세
유즈케이스
테스트9개
ID종류케이스 · 검증(입력→기대)
C-09-1단위score>=1만 통과 — verifyComboSafety([{dish:'a',ingredient:'x'}],()=>2) → 1개 통과, score:2
C-09-2적대score 0 제거(괴식) — verifyComboSafety([{dish:'미역국',ingredient:'당근'}],(d)=>d==='미역국'?0:2) → 빈 배열
C-09-3단위혼합 — 통과/탈락 분리 — [짜파게티,미역국] + scoreOf 짜파게티2·미역국0 → 짜파게티만 반환
C-09-4적대없는 셀(미스)=0 → 탈락 — scoreOf가 미존 셀에 0 반환 → 제거
C-09-5단위빈 입력 → 빈 출력 — verifyComboSafety([],()=>2) === []
C-09-6단위통과 조합에 score 부착 — verifyComboSafety([{a,x}],()=>3)[0].score === 3
C-09-7회귀실증 OK 조합 통과(짜파게티·볶음밥·카레+당근) — 세 조합 + scoreOf>=2 → 3개 모두 통과
C-09-8단위score 정확히 1 → 통과(경계) — verifyComboSafety([{a,x}],()=>1) → 1개 통과
C-09-9적대score 0.9 → 탈락 — verifyComboSafety([{a,x}],()=>0.9) → 빈 배열
DoD
의존없음

C-10 · composeLetter groundingMode 분기 — Letter B 통합 + Letter A 무변경 보장코드 코드 · 2h

목적composeLetter가 base.groundingMode='merged'일 때 merged 재료·거울·가이드·온보딩을 적용한 letterInput으로 생성하고, 미지정이면 기존 Letter A 경로(byte 동일)를 탄다. 재생성·검증 루프는 양 모드 공유.
요구composeLetter 시그니처에 grounding 입력을 따로 추가하지 않고 base(LetterInput)의 grounding 필드를 그대로 letterInput에 전달(635~642줄 letterInput 합성부 확장). 미지정 시 전부 기존과 동일.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
C-10-1회귀미지정 → 기존 composeLetter 경로 — callClaude 모킹 + base(grounding 없음) → buildLetterUser가 merged 블록 없는 기존 프롬프트로 호출(spy 확인)
C-10-2통합merged → materials/mirror/guide 프롬프트 전달 — merged base → callClaude user 텍스트에 셋 전부 포함
C-10-3통합merged → detBad 재생성 루프(가드 대칭) — 첫 생성 letterDeterministicBad true → 재생성 시도(callClaude 호출 증가·coachRegen true)
C-10-4통합merged + onboarding → 유사도 재생성 1회 제한 — onboardingMode=true + pastLetters 있음 → simBadness 재생성 최대 1회
C-10-5통합merged → verifyFacts에 materials/mirror 포함 — merged → verifyLetter facts에 materials·mirror 요약 포함(C-11 연계)
C-10-6통합merged + materials null → bridgeFacts 폴백 발행 — groundingMode='merged',materials:null,bridgeFacts 있음 → throw 없이 반환
C-10-7회귀반환 shape 불변 — merged·레거시 둘 다 {letter,oneliner,plan,scenarioId,coachRegen,verify,modelUsed} 키 동일
C-10-8통합deadline 초과 → merged 추가 콜 생략(S7) — deadlineMs 과거 → merged도 재생성·검증 생략하고 첫 생성 발행
DoD
의존C-01 · C-02 · C-03 · C-04 · C-05 · C-06 · C-11

C-11 · verifyFacts merged 확장 — 거울·재료를 검증 데이터에 합본(무검증 채널 차단)코드 코드 · 1h

목적개선 H — 거울·추천(materials/mirror)도 발행 전 검증 통과시킨다. 슬롯값을 본문과 합본해 verifyLetter 1패스에 포함, 무검증 채널을 닫는다.
요구verifyFacts(597~613줄)가 merged 모드에서 materials·mirror를 '제공 데이터'에 추가. 레거시 verifyFacts 무변경.
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
C-11-1단위merged → verifyFacts에 materials 포함 — verifyFacts({groundingMode:'merged',materials:'짜파게티+당근'},plan) → '짜파게티+당근' 포함
C-11-2단위merged → verifyFacts에 mirror 포함 — verifyFacts({groundingMode:'merged',mirror:'당근 비어요'},plan) → '당근 비어요' 포함
C-11-3회귀레거시 → materials/mirror 미추가 — verifyFacts({materials:'M',mirror:'X'},plan) (미지정) → 'M'·'X' 미포함
C-11-4단위merged + 둘 다 없음 → 기존 라인만 — verifyFacts({groundingMode:'merged'},plan) → throw 없이 기존 라인만 반환
C-11-5단위출력 줄 단위(빈 라인 filter) — merged verifyFacts 출력에 연속 \n\n 없음(filter(Boolean) 유지)
DoD
의존C-01

C-12 · qualityScan — 발행 전 품질축 결정론 스캔(은유·나열·모호기간·재료밖)코드 코드 · 2h

목적개선 G — '게이트 그린≠좋은 편지'를 깨는 핵심. LLM 출력에 품질축(클리셰 은유 과용·어제 X 먹었어요 나열·모호 기간어·재료 밖 음식명)을 결정론 스캔으로 검사, 위반 시 재생성. 순수 함수.
요구letterDeterministicBad와 별개 순수 함수 qualityScan(letter, materialFoods)로 4개 품질축 검사해 위반 사유 배열 반환. 순수 함수(단독 테스트).
명세
유즈케이스
테스트12개
ID종류케이스 · 검증(입력→기대)
C-12-1단위클리셰 은유 2개+ → 위반 — qualityScan({letter:'입맛은 통장이고 노출은 적금이에요',materialFoods:[]}).length >= 1
C-12-2단위은유 1개 → 통과(오탐 방지) — qualityScan({letter:'한 걸음씩 나아가고 있어요',materialFoods:[]}) — 은유 사유 미포함
C-12-3단위나열 패턴 → 위반 — qualityScan({letter:'어제 당근 먹고 브로콜리 먹었어요',materialFoods:[]}) → 나열 위반 포함
C-12-4단위수치 없는 모호 기간어 → 위반 — qualityScan({letter:'요즘 채소가 아쉬워요',materialFoods:[]}) → 모호기간 위반 포함
C-12-5단위수치 동반 기간어 → 통과 — qualityScan({letter:'최근 7일 중 채소가 3일이에요',materialFoods:[]}) — 모호기간 사유 미포함
C-12-6적대재료 밖 음식명 → 위반 — qualityScan({letter:'시금치무침을 권해요',materialFoods:['당근','짜파게티']}) → 재료밖 위반 포함
C-12-7단위재료 안 음식명 → 통과 — qualityScan({letter:'짜파게티에 당근을 넣어보세요',materialFoods:['당근','짜파게티']}) — 재료밖 사유 미포함
C-12-8단위일반 명사(밥·국·반찬) 오탐 안 함 — qualityScan({letter:'밥에 반찬을 곁들여요',materialFoods:['당근']}) — 재료밖 위반 미포함
C-12-9회귀방법론 일반론 통과 — qualityScan({letter:'거부는 정상이고 보통 8~10회 노출이면 받아들여요',materialFoods:[]}).length === 0
C-12-10단위깨끗한 편지 → 빈 배열 — qualityScan({letter:'아린이가 짜파게티를 잘 먹어요. 당근을 살짝 다져 넣어보세요.',materialFoods:['짜파게티','당근']}) === []
C-12-11적대동일 은유 반복도 위반 — qualityScan({letter:'통장처럼 쌓이고 통장처럼 불어나요',materialFoods:[]}).length >= 1
C-12-12단위빈 letter → 빈 배열(방어) — qualityScan({letter:'',materialFoods:[]}) === []
DoD
의존C-07

C-13 · composeLetter merged 품질 재생성 루프 — qualityScan 배선코드 코드 · 1.5h

목적qualityScan(C-12)을 composeLetter merged 경로에 배선. 품질 위반 시 위반 사유를 fixNotes로 주입해 1회 재생성하되 det·유사도 가드 재통과 + 위반 감소 시만 채택(가드 대칭 S1).
요구merged에서만 verifyLetter 루프 다음에 qualityScan 패스 추가. materialFoods는 base.materialFoods에서. deadline·온보딩 시 생략. 레거시 무영향.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
C-13-1통합merged 품질 위반 → 1회 재생성 — 첫 생성 qualityScan 위반 1건 → generateLetter 추가 1회 호출(fixNotes 주입)
C-13-2통합재생성이 위반 줄임 → 채택 — 재생성본 qualityScan 빈 && detBad false && sim<1 → gen 교체·quality.regen true
C-13-3적대재생성이 det 위반 → 미채택 — 재생성본 detBad true → 원본 유지(가드 대칭)
C-13-4통합merged + onboarding → 품질 패스 생략 — onboardingMode=true → qualityScan 재생성 미실행
C-13-5회귀레거시 → 품질 패스 미실행 — 미지정 → qualityScan 호출 0회
C-13-6통합deadline 초과 → 품질 패스 생략(S7) — deadlineMs 과거 → qualityScan 재생성 생략
C-13-7단위반환에 quality 필드(merged) — merged 반환 객체에 quality 키 존재, 레거시는 quality 없음/null
DoD
의존C-10 · C-12

C-14 · coach-grounding 테스트 스위트 + prebuild 게이트 회귀테스트 테스트 · 1.5h

목적EPIC C 순수 함수·분기를 tests/coach-grounding.test.ts로 묶고 Letter A 무변경(byte 동일) 회귀를 coach-guards.test.ts에 추가해 prebuild 게이트(npm test)에 편입. 엣지 발견 시 fixture+테스트(red→green)+수정 복리 원칙 적용.
요구C-01~C-13의 모든 test 케이스를 vitest로 구현하고 기존 220개 통과를 깨지 않는다. Letter A byte 동일 회귀 스냅샷 포함. callClaude 모킹.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
C-14-1회귀신규 스위트 전부 그린 — npx vitest run tests/coach-grounding.test.ts → 0 fail
C-14-2회귀기존 220개 회귀 무파손 — npx vitest run → 기존 7파일 통과 수 >= 220
C-14-3회귀Letter A buildLetterUser byte 동일 스냅샷 — groundingMode 없는 fixture → toMatchSnapshot 일치(대조군 보호)
C-14-4통합callClaude 모킹으로 composeLetter 통합 결정론 — vi.mock(callClaude) → merged/레거시 둘 다 네트워크 없이 통과
C-14-5회귀prebuild 게이트 편입 확인package.json prebuild/test 스크립트가 tests/ 전체를 돌림(신규 파일 포함)
C-14-6적대엣지 fixture(빈 데이터·온보딩) red→green — 기록 0일 fixture → onboarding 분기 → 분석 블록 0, 입력안내 present(발견 엣지 고정)
DoD
의존C-02 · C-03 · C-04 · C-05 · C-06 · C-07 · C-08 · C-09 · C-10 · C-11 · C-12 · C-13

EPIC D — 품질·무검증 채널 검증 (coach.ts 발행전 가드) D2

근본원인 ①②를 닫는다 — (G)LLM 출력에 품질축 결정론 스캔(은유 클리셰·'어제 X 먹었어요' 나열·모호 기간어·재료 밖 음식명)을 추가해 위반 시 재생성하고, (H)거울·추천·슬롯값을 본문과 합본해 검증 1패스로 무검증 채널을 봉합한다. 모든 신규 가드는 Letter B 전용이며 v2 Letter A(planFor+composeLetter)는 무변경 대조군으로 보존한다.

📁 신규 web/lib/coachQuality.ts web/tests/coach-quality.test.ts · 수정 web/lib/coach.ts web/tests/coach-guards.test.ts web/app/api/cron/coach/route.ts

D-01 · 은유 클리셰 사전 + metaphorOveruse(L) 검출기코드 코드 · 1h

목적v3가 굳어버린 클리셰 은유(통장·적금·문·계단·걸음·무대·디딤돌·사슬·길·풍경)를 과용하는 편지를 결정론으로 검출. '테스트 그린인데 이사님 별로'를 깨는 품질축 첫 조각.
요구은유 사전 상수 METAPHOR_CLICHES와 순수함수 metaphorOveruse(L: string): boolean을 새 파일 lib/coachQuality.ts에 만든다. 단일 은유 1회 등장은 허용(따뜻한 비유는 자산), 같은 은유 반복 또는 서로 다른 클리셰 은유 2종 이상 동시 등장 = 과용으로 true.
명세
유즈케이스
테스트12개
ID종류케이스 · 검증(입력→기대)
D-01-1단위통장+적금+계단 3종 동시 = 과용 — metaphorOveruse('식습관은 통장에 적금을 쌓듯, 계단을 한 걸음씩 오르는 일이에요') → true
D-01-2단위단일 은유 1회 = 허용(오탐 방지) — metaphorOveruse('새로운 맛에 마음을 여는 작은 문을 하나 열었네요') → false
D-01-3회귀같은 은유 2회 반복 = 과용 — metaphorOveruse('한 걸음씩 나아가요. 오늘도 한 걸음 더 내디뎠네요') → true
D-01-4단위은유 0종 = 통과 — metaphorOveruse('어제 저녁 당근볶음밥을 반 그릇 비웠어요') → false
D-01-5적대동음 오탐: '질문/방문/문제'는 '문' 은유 아님 — metaphorOveruse('식사에 대한 질문이 있으시면 언제든 문의하세요. 문제 없어요') → false
D-01-6적대동음 오탐: '길게/길어'는 '길' 은유 아님 — metaphorOveruse('식사가 30분 넘게 길어지면 부담 없이 정리하세요') → false
D-01-7단위디딤돌+사슬 2종 = 과용 — metaphorOveruse('이번 노출은 다음 음식으로 이어지는 디딤돌이자 사슬의 첫 고리예요') → true
D-01-8단위무대 은유 단독 1회 = 허용 — metaphorOveruse('식탁이 아린이의 작은 무대가 되어주네요') → false
D-01-9적대빈 문자열 = false(안전) — metaphorOveruse('') → false
D-01-10단위여정+길 2종 = 과용 — metaphorOveruse('편식은 긴 여정이지만 함께 걷는 길이에요') → true
D-01-11속성결정론 재현성 — 동일 입력 2회 호출 결과 동일
D-01-12회귀회귀 박제: 6/8 실제 v3 클리셰 편지 fixture = true — 인계서 v3 수렴 편지(통장 은유) fixture → metaphorOveruse=true
DoD
의존없음

D-02 · '어제 X 먹었어요' 나열 패턴 검출 mealEnumeration(L)코드 코드 · 1h

목적v3 조립본이 '어제 당근 먹었어요. 그제 시금치 먹었어요…'식 데이터 나열(거울 재서술)로 굳는 것을 결정론 검출. 거울은 코드가 못 박되 본문이 그것을 기계적으로 나열하면 편지가 영수증이 된다.
요구lib/coachQuality.ts에 순수함수 mealEnumeration(L: string): boolean 추가. '{시점어}{음식}먹었어요' 류 문장이 2개 이상 연속/반복되면 나열 패턴으로 true. 단발 사실 인용 1회은 허용(품질 좋은 편지의 정상 요소).
명세
유즈케이스
테스트11개
ID종류케이스 · 검증(입력→기대)
D-02-1단위3문장 시점+먹었어요 나열 = true — mealEnumeration('어제 카레를 먹었어요. 그제 볶음밥을 먹었네요. 오늘 빵을 먹었어요') → true
D-02-2단위1회 사실 인용 = 허용 — mealEnumeration('어제 저녁 당근볶음밥을 처음 비웠다는 게 반가워요') → false
D-02-3단위2문장 = 임계 초과(true) — mealEnumeration('어제 두부를 먹었어요. 오늘 시금치를 먹었어요') → true
D-02-4단위쉼표 음식 3개+시점+먹었 = 나열 — mealEnumeration('어제 당근, 시금치, 두부를 골고루 먹었어요') → true
D-02-5적대시점어 없는 음식 나열(요약형) = 허용 — mealEnumeration('아린이가 잘 먹는 볶음밥·카레·계란말이를 떠올려보면') → false
D-02-6단위'비웠/남겼' 변형 동사 포착 — mealEnumeration('어제 밥을 비웠어요. 오늘 국을 남겼어요') → true
D-02-7적대빈 문자열 = false — mealEnumeration('') → false
D-02-8단위N일 전 시점어 + 먹었 2회 = true — mealEnumeration('3일 전 생선을 먹었어요. 2일 전 콩을 먹었네요') → true
D-02-9회귀정상 코칭(행동 제안)은 미검출 — mealEnumeration('오늘 저녁엔 좋아하는 볶음밥에 당근을 잘게 섞어보세요') → false
D-02-10속성결정론 재현성 — 동일 입력 2회 = 동일 결과
D-02-11적대오탐: '먹고 싶어 함' 인용 1회 = 허용 — mealEnumeration('어제 배가 불편한데도 먹고 싶어 했다니 식욕은 살아있네요') → false
DoD
의존D-01

D-03 · 모호 기간어 검출 vagueTimeWord(L) (FORBID_TIME과 분리)코드 코드 · 0.5h

목적인계서 F·G — '요즘/최근/이번주' 같은 수치 없는 모호 기간어를 검출. 코드가 '최근 7일 중 3일'처럼 수치를 재료로 줬는데도 LLM이 모호어로 뭉개면 재생성. coach.ts FORBID_TIME(환각 기간)과 역할이 달라 별도 함수.
요구lib/coachQuality.ts에 vagueTimeWord(L: string): boolean. '요즘/최근/이번 주/얼마 전/한동안/요새/근래' 등 수치 없는 기간어가 등장하면 true. 단, 바로 옆에 수치(N일/N번/N회)가 동반되면 허용(예 '최근 7일'은 OK).
명세
유즈케이스
테스트11개
ID종류케이스 · 검증(입력→기대)
D-03-1단위수치 없는 '요즘' = true — vagueTimeWord('요즘 채소를 잘 안 드시네요') → true
D-03-2단위'최근 7일' 수치 동반 = 허용 — vagueTimeWord('최근 7일 중 비타민A 채소가 3일이었어요') → false
D-03-3단위'이번 주 2번' 수치 동반 = 허용 — vagueTimeWord('이번 주 콩류가 2번 나왔어요') → false
D-03-4단위'한동안' 모호어 = true — vagueTimeWord('한동안 생선이 비어 있었어요') → true
D-03-5적대'최근' 단독(수치 멀리) = true — vagueTimeWord('최근 들어 표정이 밝아졌어요') → true
D-03-6단위FORBID_TIME 영역('지난달')은 무관 — vagueTimeWord('지난달부터 잘 먹어요') → false
D-03-7단위기간어 0개 = false — vagueTimeWord('오늘 저녁 당근을 권해보세요') → false
D-03-8적대빈 문자열 = false — vagueTimeWord('') → false
D-03-9단위'며칠째' 모호어 = true — vagueTimeWord('며칠째 같은 반찬이 이어졌네요') → true
D-03-10단위'요즘' + 직후 '3일' = 허용 — vagueTimeWord('요즘 3일 동안 채소가 한 번도 없었어요') → false
D-03-11속성결정론 재현성 — 동일 입력 2회 = 동일 결과
DoD
의존D-01

D-04 · 재료 밖 음식명 스캔 offMaterialFood(L, allowed)코드 코드 · 1.5h

목적인계서 G·A — LLM이 '검증된 추천'(bridgeFacts) 목록 밖의 음식명/조합을 지어내면(괴식·환각 입구) 검출. 화이트리스트(코드가 LLM에 준 재료) 대조 방식이라 임의 코퍼스 스캔의 오탐을 피한다.
요구lib/coachQuality.ts에 offMaterialFood(L: string, allowed: string[]): string[] — 편지에서 '~찌개/~국/~볶음/~구이/~조림/~찜/~밥/~죽/~전' 등 조리 음식명 후보를 추출해, allowed(코드가 준 인기음식·궁합·사촌·favoriteFoods·STAPLE_FORMS 표시) 안에 없는 것을 위반 목록으로 반환. 빈 배열=통과.
명세
유즈케이스
테스트12개
ID종류케이스 · 검증(입력→기대)
D-04-1단위목록 밖 음식명 검출 — offMaterialFood('마파두부로도 자주 먹어요', ['순두부찌개','볶음밥']) → ['마파두부']
D-04-2단위목록 내 음식 전부 통과 — offMaterialFood('볶음밥에 두부를 섞고 순두부찌개도', ['순두부찌개','볶음밥','두부']) → []
D-04-3적대일반명사 '밥/국' 단독 면제 — offMaterialFood('밥과 국을 차려보세요', []) → []
D-04-4적대괴식 조합 음식명 검출(미역국에 당근 이름화) — offMaterialFood('당근미역국을 만들어보세요', ['미역국','당근볶음밥']) → ['당근미역국']
D-04-5단위allowed 부분일치 통과 — offMaterialFood('고등어무조림이 좋아요', ['고등어무조림','고등어구이']) → []
D-04-6단위음식명 0개 편지 = [] — offMaterialFood('화면을 끄고 식탁에 함께 앉아보세요', ['볶음밥']) → []
D-04-7단위복수 위반 중복 제거 — offMaterialFood('된장찌개와 된장찌개, 김치전을 권해요', ['볶음밥']) → ['된장찌개','김치전']
D-04-8적대빈 allowed + 음식명 = 전부 위반 — offMaterialFood('카레볶음밥을 권해요', []) → ['카레볶음밥']
D-04-9적대빈 문자열 본문 = [] — offMaterialFood('', ['볶음밥']) → []
D-04-10회귀STAPLE 형태(빵) allowed면 통과 — offMaterialFood('통밀빵을 간식으로', ['통밀빵','빵']) → []
D-04-11적대접미사 없는 식재료명은 미검사(과탐 방지) — offMaterialFood('당근과 시금치를 권해보세요', []) → []
D-04-12속성결정론 재현성 — 동일 (L,allowed) 2회 = 동일 배열
DoD
의존D-01

D-05 · letterQualityBad(L, opts) 통합 품질 스캐너코드 코드 · 1h

목적인계서 G의 4축(은유·나열·모호기간어·재료밖음식명)을 하나의 결정론 함수로 합쳐 letterDeterministicBad와 대칭으로 쓸 수 있게 한다. composeLetterB 재생성 루프(D-08)와 검증 hint 생성(D-07)이 공유하는 단일 진입점.
요구lib/coachQuality.ts에 letterQualityBad(L: string, opts: { allowedFoods?: string[] }): { bad: boolean; reasons: string[] }. D-01~D-04를 호출해 위반 사유 배열을 모으고 하나라도 있으면 bad=true. reasons는 검증자 fixNotes/verify hint로 재사용 가능한 한국어 사유.
명세
유즈케이스
테스트10개
ID종류케이스 · 검증(입력→기대)
D-05-1단위은유 과용만 = bad + 사유1 — letterQualityBad('통장에 적금 쌓듯 계단을 오르듯', {}) → bad=true, reasons에 '은유 과용'
D-05-2단위복수 위반 사유 집계 — letterQualityBad('통장에 적금 쌓듯. 어제 콩 먹었어요. 오늘 두부 먹었어요', {}) → reasons.length>=2
D-05-3단위깨끗한 편지 = bad false — letterQualityBad('오늘 저녁 볶음밥에 당근을 잘게 섞어보세요', { allowedFoods:['볶음밥'] }) → bad=false, reasons=[]
D-05-4적대allowedFoods 미제공 시 음식명 검사 skip — letterQualityBad('마파두부를 권해요', {}) → bad=false
D-05-5단위allowedFoods 제공 + 목록밖 = bad — letterQualityBad('마파두부를 권해요', { allowedFoods:['볶음밥'] }) → bad=true, reasons에 '목록 밖 음식: 마파두부'
D-05-6단위모호 기간어 단독 = bad — letterQualityBad('요즘 채소가 비어요', {}) → bad=true
D-05-7회귀수치 동반 기간어는 통과 — letterQualityBad('최근 7일 중 채소가 3일이었어요', {}) → bad=false
D-05-8적대빈 편지 = bad false(안전) — letterQualityBad('', { allowedFoods:['밥'] }) → bad=false
D-05-9속성결정론 재현성 — 동일 (L,opts) 2회 = 동일 결과 객체(deep equal)
D-05-10단위reasons는 D-07 hint로 쓸 한국어 문장 — letterQualityBad('통장 적금 계단', {}).reasons[0]가 string이고 빈 문자열 아님
DoD
의존D-01 · D-02 · D-03 · D-04

D-06 · 검증 합본 입력 빌더 — 거울·추천·슬롯값을 본문과 1패스로코드 코드 · 1h

목적인계서 H — 거울(coachFacts.buildMealMirror)·추천(bridgeFacts)·슬롯값이 발행 직전 가드를 우회하던 무검증 채널을 봉합. 본문+거울+추천을 합본해 품질·det·검증을 한 번에 통과시킨다.
요구lib/coachQuality.ts(또는 coach.ts)에 composeVerifiableText(p: { letter: string; mirror?: string|null; recoText?: string|null }): string — 본문과 거울·추천 문장을 줄바꿈으로 합쳐 단일 검증 텍스트를 만든다. 이 합본을 letterQualityBad·letterDeterministicBad 입력으로 써서 슬롯값도 검사 대상에 포함.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
D-06-1단위본문+거울+추천 합본 — composeVerifiableText({letter:'A', mirror:'B', recoText:'C'}) → 'A\nB\nC'
D-06-2단위null 슬롯 제외 — composeVerifiableText({letter:'A', mirror:null, recoText:'C'}) → 'A\nC'
D-06-3통합거울에 숨은 모호어가 합본 검사로 적발 — letterQualityBad(composeVerifiableText({letter:'오늘 당근을 권해요', mirror:'요즘 채소가 비어요'}), {}).bad → true
D-06-4통합추천 슬롯의 괴식 음식명 적발 — letterQualityBad(composeVerifiableText({letter:'권해요', recoText:'당근미역국 추천'}), {allowedFoods:['미역국']}).bad → true
D-06-5단위셋 다 깨끗 = 통과 — letterQualityBad(composeVerifiableText({letter:'오늘 저녁 함께 앉아보세요', mirror:'어제 두부를 처음 비웠어요', recoText:'볶음밥'}), {allowedFoods:['볶음밥']}).bad → false
D-06-6단위본문만(슬롯 없음) = 본문 그대로 — composeVerifiableText({letter:'A'}) → 'A'
D-06-7통합거울의 det 위반(섞기 금지 프레임)도 합본으로 적발 — letterDeterministicBad(composeVerifiableText({letter:'화면을 끄세요', mirror:'두부를 섞어 주세요'}), 'mealtime-atmosphere', '') → true
DoD
의존D-05

D-07 · verifyLetter 품질 사유 hint 주입 + allowedFoods 파서(B 전용)코드 코드 · 1h

목적인계서 H·G — verifyLetter(coach.ts:585)가 본문만 보던 것을, Letter B에서는 letterQualityBad 사유(D-05)를 LLM 검증 전에 먼저 결정론으로 거르고(콜 절약), allowedFoods를 bridgeFacts/favoriteFoods에서 추출해 재료 밖 음식명 검사에 공급한다. A는 무변경.
요구coach.ts에 allowedFoodsFromBridge(bridgeFacts: string|undefined, favoriteFoods: string[]): string[] 파서 추가. composeLetterB(D-08) 안에서 letterQualityBad 사유를 fixNotes로 병합해 재생성(LLM 검증 콜 없이도). verifyLetter 자체는 기존 verifyFacts 입력 유지, 품질 위반은 hint/fixNotes로 LLM에 전달.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
D-07-1단위품질 위반 사유가 fixNotes로 변환 — letterQualityBad('통장 적금 계단',{}).reasons → fixNotes 배열(빈 문자열 없음)
D-07-2단위allowedFoodsFromBridge 파싱 — allowedFoodsFromBridge('[오늘 타깃 콩류] 두부 (또래 인기 음식: 순두부찌개·두부조림)', ['볶음밥']) → {'순두부찌개','두부조림','볶음밥'} 포함
D-07-3단위bridgeFacts 없으면 favoriteFoods만 — allowedFoodsFromBridge(undefined, ['카레']) → ['카레']
D-07-4통합B: 은유 과용 생성본 → 재작성 트리거(mock) — generateLetter mock 1차=은유과용,2차=깨끗 → composeLetterB 2차 채택
D-07-5통합검증자 LLM 콜은 품질 통과 후에만(콜 절약) — qualityBad.bad일 때 우선 결정론 재작성, verifyLetter LLM 콜이 불필요하게 늘지 않음
D-07-6회귀A 경로 무변경(게이트 미적용) — 기존 composeLetter 호출은 품질 게이트 미실행 — 동일 입력 출력 byte 동일
D-07-7통합deadline 초과 시 품질 게이트 생략(S7) — deadlineMs 초과면 qualityBad 재작성 루프 skip하고 현재 본문 발행
DoD
의존D-05 · D-06

D-08 · composeLetterB — Letter B 통합 작성기(개선 A~I 적용·가드 대칭)코드 코드 · 2h

목적Letter B 전용 작성기. 기존 composeLetter(A·v2)는 건드리지 않고, B에서만 품질 게이트(D-05)·합본 검증(D-06)·품질 사유 재작성(D-07)을 det·유사도·의미검증 루프에 대칭으로 추가. 재생성 산출물도 det+quality+sim+verify 전부 재통과해야 채택(S1).
요구coach.ts에 composeLetterB(p: composeLetter 인자 + { allowedFoods?: string[]; mirror?: string|null })를 추가. 반환 타입에 quality?: { bad, reasons, regen } 추가. 가드 대칭 원칙(coach.ts:647 S1)을 품질축까지 확장.
명세
유즈케이스
테스트10개
ID종류케이스 · 검증(입력→기대)
D-08-1통합B: 품질 위반 → 재작성 → 통과본 채택(mock) — generateLetter mock 1차=은유과용·2차=깨끗 → composeLetterB letter=2차, quality.regen=true
D-08-2적대가드 대칭: 유사도 재생성본이 품질 bad면 미채택 — 유사도는 낮아졌지만 품질 bad인 g는 채택 안 됨
D-08-3적대가드 대칭: 검증자 fix본이 품질 bad면 미채택 — verify v2.ok이나 qBad(g)면 gen 미교체
D-08-4통합deadline 초과 시 품질 루프 skip — deadlineMs 과거값 → 품질 재작성 콜 0회, 현재 본문 발행
D-08-5회귀A(composeLetter) 무변경 회귀 — 기존 composeLetter 호출 시그니처·동작 불변
D-08-6단위quality 필드 반환 형태 — composeLetterB 반환에 quality={bad,reasons,regen} 포함
D-08-7통합품질 통과본은 재작성 안 함(불필요 콜 없음) — 1차 생성이 깨끗하면 품질 재작성 콜 0회
D-08-8통합검증자 장애(throw) 시에도 품질 결정론 게이트 동작 — verifyLetter throw해도 letterQualityBad 검사는 발행 전 수행
D-08-9통합모든 가드 통과본 발행 — det·quality·sim·verify 전부 통과한 본문이 최종 letter로 반환
D-08-10통합재작성 fixNotes에 품질 사유 + 검증 위반 병합 — 품질 reasons와 verify violations가 같은 재작성 콜의 fixNotes로 합쳐짐
DoD
의존D-05 · D-06 · D-07

D-09 · 기존 가드 회귀 테스트 보강 + 품질축 fixture테스트 테스트 · 1h

목적불변 원칙 ③ — 엣지 발견 시 fixture+테스트(red→green). 신규 품질 함수와 기존 letterDeterministicBad·letterSimilarity가 충돌·중복 검출하지 않는지, 실제 v3 수렴 편지·v2 양질 편지 fixture로 회귀 박제.
요구tests/coach-quality.test.ts(신규)에 D-01~D-06 케이스 + 실데이터 fixture. tests/coach-guards.test.ts(수정)에 품질축이 기존 det 가드를 깨지 않는다는 회귀 케이스 추가. tests/fixtures에 v3 수렴 편지 1·v2 양질 편지 1 샘플.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
D-09-1회귀v3 수렴 편지 fixture = quality bad — fixtures/letter-v3-convergent.txt → letterQualityBad(...).bad=true
D-09-2회귀v2 양질 편지 fixture = quality 통과(오탐 0) — fixtures/letter-v2-good.txt → letterQualityBad(...).bad=false
D-09-3회귀기존 det 가드 케이스 전부 그린 유지coach-guards.test.ts 기존 12케이스 통과 불변
D-09-4통합품질·det 이중 검출 무해 — 튀김+은유 동시 편지 → detBad=true AND qualityBad=true 둘 다 재생성 트리거
D-09-5회귀양질 편지는 det·quality·sim 전부 통과 — letter-v2-good.txt → letterDeterministicBad=false, letterQualityBad.bad=false
D-09-6E2Enpm test 그린(prebuild 게이트) — npm test → 신규 coach-quality.test.ts 포함 전체 그린
D-09-7단위fixture 로딩 안정성 — fixture 파일 읽기 실패 시 테스트가 명확히 실패(빈 문자열로 false 통과 위장 방지)
DoD
의존D-01 · D-02 · D-03 · D-04 · D-05 · D-06

D-10 · route.ts Letter B 합본·품질 컨텍스트 배선 + altLetter 저장코드 코드 · 2h

목적프로젝트 A/B 구조 — 아린 cron 루프에서 Letter A(기존 composeLetter)는 그대로 두고, composeLetterB로 Letter B를 생성해 coach_letters.context.altLetter={letter,oneliner,design,mirror,materials,quality}에 저장. 메인 letter/oneliner=A. 스키마 변경 0.
요구app/api/cron/coach/route.ts에서 비교 코호트(COACH_V3_CHILDREN 재활용 또는 신규 COACH_COMPARE_CHILDREN) 자녀에 대해 A 생성 후 composeLetterB도 호출, altLetter를 finalCtx에 병합 upsert. allowedFoods(favoriteFoods+bridgeFacts 인기음식)·mirror(fc.mirror)를 composeLetterB에 전달.
명세
유즈케이스
테스트9개
ID종류케이스 · 검증(입력→기대)
D-10-1단위compare 코호트 판정 — isCompare('43942d34-b339-4bbd-978a-ec3f6a877031')=true (env에 포함), isCompare('other')=false
D-10-2단위altLetter 컨텍스트 형태 — altLetter 객체에 {letter,oneliner,design,mirror,materials,quality} 키 존재
D-10-3통합메인 letter=A(v2) 보존 — compare 자녀도 upsert 메인 letter/oneliner는 composeLetter(A) 산출
D-10-4통합비교 외 자녀는 altLetter 없음 — isCompare=false 자녀 finalCtx에 altLetter 키 미존재(기존 경로 무변경)
D-10-5회귀스키마 변경 0(jsonb 키 추가만) — coach_letters upsert 컬럼 동일(context jsonb 내부만 확장) — SQL 미실행
D-10-6적대B 생성 실패 시 A 발행 영향 없음 — composeLetterB throw → altLetter=null이되 메인 A 편지 정상 발행
D-10-7통합allowedFoods 전달 확인 — composeLetterB 호출 인자 allowedFoods에 favoriteFoods + bridgeFacts 인기음식 포함
D-10-8통합mirror 슬롯이 B 검증 합본에 포함 — fc.mirror가 composeLetterB로 전달되어 합본 품질 검사 대상이 됨
D-10-9통합compare 모드는 v3 순수 조립 미실행 — isCompare 자녀는 v3Enabled 블록 대신 A+B 경로만 탐(중복 발행 없음)
DoD
의존D-07 · D-08

D-11 · 어드민 검증·품질 위반 보고(cron_runs.issues + altLetter.quality)운영 코드 · 0.5h

목적근본원인 ① — '게이트 그린≠좋은 편지'를 모니터링으로 닫는다. Letter B의 품질 위반·재작성·합본 검증 결과를 cron_runs.issues와 context.altLetter.quality에 노출해 카나리아 3일 모니터에서 B의 실제 품질을 추적.
요구route.ts에서 composeLetterB의 quality.bad(발행됐으나 품질 위반)·quality.regen·verify 결과를 issues 배열에 push(기존 검증위반 발행 패턴 route.ts:693 재사용). context.altLetter.quality에 기록해 A vs B 비교 가능.
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
D-11-1통합B 품질 위반 발행 → issues 기록 — altLetter.quality.bad=true → cron_runs.issues에 'B품질위반' 항목 존재
D-11-2통합B 재작성 통과 → 위반 미기록 — altLetter.quality.bad=false(재작성 통과) → issues에 B품질위반 없음, context에 regen=true
D-11-3회귀A 기준 repeatAlert 무변경 — 기존 simToPrev/repeatAlert는 메인(A) 편지 기준 그대로
D-11-4수동altLetter.quality 어드민 노출 — 어드민 스레드에서 context.altLetter.quality(bad/reasons/regen) 확인 가능
D-11-5적대B 생성 실패 시 issues 노이즈 없음 — composeLetterB throw로 altLetter=null이면 B품질 issues push 안 함(null 가드)
DoD
의존D-08 · D-10

EPIC E — 크론 2통 발행 — 아린 compare 분기(Letter A 대조군 v2 + Letter B 처치군 하이브리드) D3

app/api/cron/coach/route.ts에 아린 compare 분기를 심어 매일 편지 2통을 동시 발행한다. Letter A는 기존 v2(planFor+composeLetter) 그대로 무변경 대조군, Letter B는 개선 A~I 전부 적용한 하이브리드(코드가 재료·진단·가이드를 못 박고 LLM이 작문)이며 context.altLetter에 저장(스키마 변경 0). freqMap 죽은코드를 본경로에 배선하고 아린 진도를 영속시키며, compare 모드는 기존 v3 순수 조립을 대체한다. additive — 다른 자녀는 무영향.

📁 신규 web/lib/coachCompare.ts web/tests/coach-compare.test.ts web/tests/fixtures/compare-arin.json · 수정 web/app/api/cron/coach/route.ts web/lib/coachDaily.ts

E-01 · compareEnabled 판정 함수 — COACH_COMPARE_CHILDREN(기본=COACH_V3_CHILDREN 재활용)코드 코드 · 0.5h

목적아린이 'A/B 2통 발행' 대상인지 결정론으로 판정. 기존 카나리아 env를 재활용하되 별도 신규 env로도 지정 가능하게 해 롤백을 env 토글 한 번으로 끝낸다.
요구lib/coachDaily.ts의 v3Enabled(env, childId) 패턴을 본떠 compareEnabled(env, childId): boolean를 추가한다. COACH_COMPARE_CHILDREN이 있으면 그것을, 없으면 COACH_V3_CHILDREN을 폴백 코호트로 사용. COACH_COMPARE=1이면 전체 ON(승격용). 미설정=비활성(기존 경로).
명세
유즈케이스
테스트12개
ID종류케이스 · 검증(입력→기대)
E-01-1단위COACH_COMPARE_CHILDREN에 든 자녀=true — compareEnabled({COACH_COMPARE_CHILDREN:'aaa,bbb'}, 'bbb') → true
E-01-2단위코호트에 없는 자녀=false — compareEnabled({COACH_COMPARE_CHILDREN:'aaa'}, 'zzz') → false
E-01-3단위COACH_COMPARE_CHILDREN 미설정 시 COACH_V3_CHILDREN 폴백 — compareEnabled({COACH_V3_CHILDREN:'aaa'}, 'aaa') → true
E-01-4단위COACH_COMPARE_CHILDREN이 빈 문자열이면 V3 폴백 사용 — compareEnabled({COACH_COMPARE_CHILDREN:'', COACH_V3_CHILDREN:'aaa'}, 'aaa') → true
E-01-5단위COACH_COMPARE=1이면 임의 자녀 true(전체 승격) — compareEnabled({COACH_COMPARE:'1'}, 'anything') → true
E-01-6단위전부 미설정이면 false(기존 경로) — compareEnabled({}, 'aaa') → false
E-01-7단위공백 섞인 CSV trim — compareEnabled({COACH_COMPARE_CHILDREN:' aaa , bbb '}, 'bbb') → true
E-01-8적대COMPARE_CHILDREN 우선순위 — 설정되면 V3 폴백 무시 — compareEnabled({COACH_COMPARE_CHILDREN:'aaa', COACH_V3_CHILDREN:'bbb'}, 'bbb') → false (compare 명단에 없음)
E-01-9적대빈 토큰(연속 콤마) 무시 — compareEnabled({COACH_COMPARE_CHILDREN:'aaa,,bbb'}, '') → false (빈 id가 매칭 안 됨)
E-01-10적대COACH_COMPARE!='1'(예 'true')은 전체ON 아님 — compareEnabled({COACH_COMPARE:'true'}, 'aaa') → false (정확히 '1'만)
E-01-11단위대소문자 구분 id 매칭 — compareEnabled({COACH_COMPARE_CHILDREN:'AAA'}, 'aaa') → false
E-01-12회귀v3Enabled와 독립 — compare 끔이 v3에 영향 없음 — v3Enabled({COACH_V3_CHILDREN:'aaa'},'aaa')=true 이면서 compareEnabled({},'aaa')=false 동시 성립
DoD
의존없음

E-02 · Letter B 빌더 모듈 lib/coachCompare.ts — buildLetterB 오케스트레이터 시그니처·폴백 골격코드 코드 · 1.5h

목적route.ts를 비대하게 만들지 않도록 Letter B 생성 전 과정을 단일 진입점 buildLetterB로 분리한다. 입력은 이미 계산된 신호 묶음, 출력은 altLetter 페이로드. 실패하면 throw 없이 null을 돌려 A만 발행(발행 보장).
요구lib/coachCompare.ts에 buildLetterB(args)를 만든다. 내부에서 selectDailyMaterials(B EPIC) → buildTeachingGuide(가이드 EPIC) → composeLetterB(LLM 작문) → 품질검증(G EPIC) 순으로 호출하고, 성공 시 { letter, oneliner, design, mirror, materials, guide, verify, modelUsed, llmCalls } 반환. 어떤 단계 실패도 catch해 null 반환(A는 별개로 항상 발행).
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
E-02-1단위모든 단계 성공 시 AltLetter 반환 — 스텁 단계 전부 정상 → buildLetterB(...) → {letter:비어있지않음, design 포함} 반환
E-02-2단위재료 선택 단계 throw → null — selectDailyMaterials 스텁이 throw → buildLetterB(...) → null
E-02-3단위작문 단계 throw → null — composeLetterB 스텁이 throw → buildLetterB(...) → null
E-02-4단위검증 단계 throw → null — verify 스텁이 throw → buildLetterB(...) → null
E-02-5적대deadlineMs 이미 지남 → LLM 호출 0회·null — deadlineMs=Date.now()-1, callClaude 스파이 → buildLetterB(...) → null이고 callClaude 호출횟수=0
E-02-6단위성공 결과에 llmCalls 카운트 포함 — 작문 1콜+검증 1콜 스텁 → result.llmCalls === 2
E-02-7단위materials·guide·mirror가 결과에 그대로 실림(저장용) — 스텁이 mirror='거울문장' 반환 → result.mirror === '거울문장'
E-02-8단위callClaude를 인자로 주입(전역 import 비의존) — 주입한 fakeClaude만 호출되고 실제 anthropic 미호출
DoD
의존E-01

E-03 · freqMap 본경로 배선 — 죽은코드 부활(C 의존)코드 코드 · 0.75h

목적route.ts:101에서 로드만 되고 추천 본경로에 전달되지 않는 freqMap을 Letter B 재료 선택(selectDailyMaterials)에 실배선해 급식빈도 가중 랭킹이 실제로 동작하게 한다.
요구route.ts에서 이미 로드된 freqMap(/ingredient-recipes.json)을 buildLetterB(args.freqMap)로 전달한다. 로드 실패 시 빈 {} 폴백은 기존대로 유지(kit-matrix 폴백). Letter A 경로(buildRecoFacts at 664)는 무변경.
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
E-03-1통합freqMap이 buildLetterB로 전달됨 — route 분기 시뮬에서 buildLetterB 스파이의 args.freqMap === 로드된 맵
E-03-2적대freqMap 로드 실패 시 빈 객체 폴백 — fetch reject → freqMap={} 로 buildLetterB 호출(throw 없음)
E-03-3회귀Letter A buildRecoFacts 호출 무변경(회귀) — A 경로에서 buildRecoFacts가 동일 인자로 호출됨(diff 없음)
E-03-4통합freqMap 차 있을 때 빈도 가중이 단호박(0회) 후순위 — freqMap 당근184·단호박0 주입 → B 재료 후보에서 단호박이 당근보다 낮은 랭크
E-03-5통합freqMap 빈 객체일 때 kit-matrix 폴백 경로 진입 — freqMap={} → 재료 선택이 popularDishesFor의 kit-matrix 폴백 분기 사용
DoD
의존E-02

E-04 · compare 분기 — Letter A(v2 그대로) 경로 보존·격리코드 코드 · 1h

목적compare 대상 자녀에서도 Letter A는 기존 v2 경로(planFor+composeLetter)를 100% 그대로 타게 한다. A가 손상되면 대조군이 무의미해지므로 격리가 핵심.
요구compareEnabled(cid)가 true여도 letter/oneliner(메인 컬럼)는 반드시 기존 v2 composeLetter 산출이 되도록 한다. compare 모드는 v3 순수 조립을 '대체'하므로(아린은 더 이상 v3 블록 조립 안 함), 아린의 경우 v3Enabled 분기(502~614행)를 건너뛰고 항상 레거시 v2 생성 경로(650~694행)로 A를 만든다.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
E-04-1통합compare 자녀는 v3Enabled 분기 미진입 — compareEnabled=true → v3Enabled 게이트 내부(decideDailyV3) 미호출
E-04-2통합compare 자녀 메인 letter = v2 composeLetter 산출 — compare 시뮬 → 최종 letter === composeLetter.letter(레거시)
E-04-3회귀non-compare v3 자녀는 기존 v3 조립 유지(회귀) — compareEnabled=false·v3Enabled=true → decideDailyV3 호출됨, v3Ctx 생성
E-04-4회귀compare가 A의 base/precomputed 입력 불변 — compare on/off로 composeLetter에 들어가는 base 객체 동일(snapshot)
E-04-5회귀non-compare 레거시 자녀 완전 무변경 — compare·v3 모두 off → 분기 진입 0, 기존 경로 byte 동일
E-04-6적대compare 자녀 B 빌드 실패해도 A 정상 발행 — buildLetterB→null → letter(A) 여전히 v2 산출로 채워짐
DoD
의존E-01 · E-02

E-05 · compare 분기 — Letter B 호출·daySeed/cidHash·재료 회전 입력 준비코드 코드 · 1h

목적B EPIC 재료 결정론 회전(B: 3일내 재사용 금지)에 필요한 입력(최근 B 재료 이력·daySeed·cidHash·likedIng·groupSignals)을 route.ts에서 모아 buildLetterB에 전달한다.
요구compare 자녀에 한해 최근 B 편지들의 재료 이력(context.altLetter.materials의 추천 식재료)을 조회해 buildLetterB의 recentBMaterials로 넘긴다. daySeed=Math.floor(Date.parse(today)/86400000)·cidHash(기존 관용구)·likedIng(300행)·computeGroupSignals(byDay,catOf)를 그대로 재사용해 전달.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
E-05-1통합recentBMaterials 추출 — altLetter.materials.food 수집 — 최근 3일 context.altLetter.materials.food=['당근','치즈'] → buildLetterB args.recentBMaterials에 두 식재료 포함
E-05-2단위daySeed 산식 일치 — today='2026-06-13' → daySeed === Math.floor(Date.parse('2026-06-13')/86400000)
E-05-3단위cidHash 산식이 기존 관용구와 동일 — 동일 cid에 대해 cidHash === (438~439행 산식 결과)
E-05-4통합pastBLetters는 A가 아닌 B 이력 — context.altLetter.letter만 수집, 메인 letter(A) 미포함
E-05-5적대altLetter 이력 없는 첫날 — recentBMaterials 빈 배열 — context.altLetter 부재 → recentBMaterials === []
E-05-6통합likedIng·groupSignals 전달 — buildLetterB args.likedIng === likedIng(300행 산출), args.groupSignals === computeGroupSignals(byDay,catOf)
E-05-7회귀non-compare 자녀는 recentBMaterials 조회 안 함 — compareEnabled=false → altLetter 이력 쿼리 미발생
DoD
의존E-04

E-06 · context.altLetter 저장 — 스키마 변경 0, 메인 letter/oneliner=A 유지코드 코드 · 0.75h

목적B의 산출을 A와 같은 coach_letters row에 context.altLetter로 합본 저장한다. 컬럼 추가·테이블 변경 없이(jsonb) A/B 비교 데이터를 확보.
요구696~722행 upsert에서 finalCtx에 altLetter 키를 추가한다. 메인 letter/oneliner/source_hash는 A(v2) 그대로. altLetter = { letter, oneliner, design, mirror, materials, guide, verify, model, llmCalls, regen }. buildLetterB가 null이면 altLetter 키를 넣지 않거나 { failed: true, reason }로 기록.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
E-06-1통합메인 letter=A·context.altLetter.letter=B — compare upsert 시뮬 → row.letter===A.letter && row.context.altLetter.letter===B.letter
E-06-2회귀source_hash는 A 기준(srcHash) 불변 — row.source_hash === srcHash(368행 산식)
E-06-3적대buildLetterB null → altLetter.failed=true — B=null → row.context.altLetter === {failed:true, reason:...}
E-06-4회귀non-compare 자녀 context에 altLetter 없음 — compareEnabled=false → row.context.altLetter === undefined
E-06-5단위altLetter에 design/mirror/materials/verify 포함 — altLetter 키 집합 ⊇ {letter,oneliner,design,mirror,materials,verify,model,llmCalls}
E-06-6단위스키마 변경 0 — 컬럼은 기존 키만(child_id,parent_id,letter_date,letter,oneliner,source_hash,context) — upsert 객체 키 === 기존 7키 집합(신규 컴럼 0)
E-06-7적대materials/guide는 요약본(크기 가드) — 거대 materials 입력 → 저장된 altLetter.materials는 핵심 필드만(전체 직렬화 아님)
DoD
의존E-04 · E-05

E-07 · 아린 진도 영속 — compare 모드에서 curriculum_progress·weekly_plans 일관성코드 코드 · 1h

목적compare 모드가 v3 순수 조립을 대체하면서 아린의 진도(curriculum_progress)와 주간 닻(weekly_plans)이 끊기지 않게 한다. B의 두뇌(진단·계획)는 v3 상태기계를 재사용하므로 진도는 계속 굴러야 한다.
요구B 재료·진단 선택이 decideDailyV3/진도를 참조한다면(브리프 '두뇌만 가져온다'), compare 분기에서도 진도 로드·decideDailyV3·진도 upsert·goals 영속(539~548행)을 수행해야 한다. 단 편지 작문(assembleLetter)은 하지 않고 두뇌 출력만 B 재료/가이드에 넘긴다.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
E-07-1통합compare에서 진도 로드됨 — compare 시뮬 → curriculum_progress select 호출됨
E-07-2통합compare에서 decideDailyV3 호출 — compare 시뮬 → decideDailyV3 1회 호출, decision 반환
E-07-3통합진도 upsert 실행·error 시 issues.push — dr.updates 있음 → curriculum_progress upsert 호출 / upsert error → issues에 '진도 저장 실패' 포함
E-07-4통합goalsAfter 변경 시 weekly_plans goals 영속 — dr.goalsAfter≠goals → weekly_plans.goals update 호출(547행 패턴)
E-07-5회귀compare에서 assembleLetter 미호출 — compare 시뮬 → assembleLetter 호출횟수 0(B는 LLM 작문)
E-07-6단위decision이 altLetter.design에 기록 — altLetter.design.decision.unit === dr.decision.unit
E-07-7통합이튿날 연속성 — 어제 진도를 today가 이어받음 — fixture 2일 연속 실행 → 2일차 progress가 1일차 step을 승계
E-07-8적대진도 없는 첫날 콜드스타트 — curriculum_progress 빈 결과 → decideDailyV3 lowData 분기, upsert 신규 행
DoD
의존E-04 · E-06

E-08 · 시간예산·데드라인 — 2통이라 비용 2배, A 우선·B 잔여예산 게이트코드 코드 · 0.75h

목적compare 자녀는 LLM 호출이 2배(A 작문+재생성, B 작문+검증+재생성)다. 1자녀(아린)이라 총 부담은 작지만, A를 항상 먼저 보장하고 B는 잔여 예산이 있을 때만 돌려 SIGKILL 좀비(6/12 사고)를 막는다.
요구TIME_BUDGET_MS(50s)·maxDuration(60s) 안에서 A를 먼저 완성·발행하고, B는 (Date.now()-runStart < TIME_BUDGET_MS - SAFETY) 일 때만 buildLetterB 호출. B의 deadlineMs = runStart + TIME_BUDGET_MS - 2000(A보다 여유 적게 — A 보호 우선). B가 예산 초과면 altLetter.skipped로 기록.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
E-08-1통합잔여 예산 충분 → B 빌드 시도 — runStart 직후 → buildLetterB 호출됨
E-08-2적대잔여 예산 부족(<12s) → B skipped·A는 발행 — Date.now()-runStart=45_000 → altLetter={skipped:true}, 메인 letter(A) 저장됨
E-08-3단위B deadlineMs가 A보다 보수적(여유 적음) — B.deadlineMs(=runStart+TIME_BUDGET_MS-2000) > A.deadlineMs(=runStart+TIME_BUDGET_MS-4000) — A를 먼저 안전 마감
E-08-4회귀B 빌드가 A 발행을 막지 않음 — buildLetterB가 시간 초과로 null → A upsert는 정상 수행
E-08-5통합B llmCalls가 cron_runs.meta에 합산 — B llmCalls=2 → cron_runs.meta에 compareLlmCalls 또는 issues로 반영
E-08-6적대compare로 인한 다음 자녀 굶김 없음(1자녀 가정 검증) — activeIds에 compare 1 + 일반 2 → B 예산 소비 후에도 일반 2 처리됨(전역 skippedTime=0)
E-08-7단위B skipped 상태가 altLetter에 기록 — 예산 부족 → row.context.altLetter.skipped === true
DoD
의존E-02 · E-06

E-09 · 어드민 검증 메타 — context.altLetter.design 스냅샷·repeatAlert·verify 합본코드 코드 · 0.5h

목적어드민 스레드와 보고서가 B의 '무엇을·왜·어떻게'를 검증할 수 있게 design 스냅샷(재료·근거·진단·검증 결과·반복 자가측정)을 altLetter에 싣는다. '게이트 그린≠좋은 편지'(인계서 근본원인①)를 어드민이 눈으로 잡을 수 있게.
요구altLetter.design에 { decision, materials(추천 식재료+근거문구), guideSummary, verify(품질축 위반), simToPrevB(B끼리 유사도), repeatAlertB, model, llmCalls, paths(A~I 중 탄 개선) } 를 기록. A의 기존 verifyCtx/simToPrev/repeatAlert(704~712행) 패턴을 B에도 평행 적용.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
E-09-1단위design.decision 기록 — altLetter.design.decision.unit === dr.decision.unit
E-09-2단위design.materials에 근거문구 포함 — altLetter.design.materials[0].reason 비어있지 않음(근거 노출 — C 의존)
E-09-3통합B 검증 위반 → issues.push — B.verify.ok=false → issues에 'B검증위반' 포함
E-09-4단위simToPrevB는 B 이력 기준 — pastBLetters와 letterSimilarity 최대값 === altLetter.design.simToPrevB
E-09-5적대repeatAlertB 임계 0.6 — simToPrevB=0.61 → repeatAlertB=true / 0.59 → false
E-09-6단위design.paths에 탄 개선 플래그 — 조합검증·품질검증 경유 → paths ⊇ ['A','G']
E-09-7회귀검증 위반이 발행을 막지 않음(fail-open) — B.verify.ok=false → altLetter.letter 여전히 저장(A처럼)
E-09-8적대B 이력 없을 때 simToPrevB=null — pastBLetters=[] → altLetter.design.simToPrevB === null
DoD
의존E-06 · E-08

E-10 · compare 통합 리플레이 — 아린 fixture 2일 연속 A≠B·진도 영속·재료 회전테스트 테스트 · 1.5h

목적기존 tests/replay.test.ts 하네스 정신을 따라, 아린 fixture로 크론 compare 분기를 prebuild 게이트에서 재현해 회귀를 박제한다. A 무변경·B 적용·진도 연속·재료 3일내 비반복을 한 번에 검증.
요구tests/coach-compare.test.ts + tests/fixtures/compare-arin.json 신규. compare 분기 로직(순수 부분: compareEnabled·buildLetterB 오케스트레이션·altLetter 합본)을 DB 스텁으로 2일 연속 구동해 불변식을 검증한다.
명세
유즈케이스
테스트10개
ID종류케이스 · 검증(입력→기대)
E-10-1회귀메인 letter(A) compare on/off 동일 — fixture 구동 → A.letter(compare)===A.letter(legacy v2)
E-10-2통합altLetter.letter ≠ 메인 letter — compare 구동 → row.context.altLetter.letter !== row.letter
E-10-3통합B 재료 3일내 비반복 — 1일차 materials.food='당근' → 2·3일차 food ∉ {당근}
E-10-4통합진도 2일 연속 승계 — 1일차 step=2 advance → 2일차 progress.step≥2
E-10-5적대B 괴식 조합 0건 — fixture에 미역국 좋아함+당근 결핍 → B materials에 '미역국+당근' 조합 미출현
E-10-6적대예산 부족 시 A만+altLetter.skipped — runStart 조작으로 예산 0 → row.letter=A 존재, altLetter.skipped=true
E-10-7회귀compareEnabled=false fixture는 기존 v2/v3 경로 — env 비우고 구동 → altLetter 미생성, 기존 경로 산출
E-10-8적대B 빌드 throw → A 발행·altLetter.failed — buildLetterB 스텁 throw → row.letter=A, altLetter.failed=true
E-10-9통합B 품질검증 위반 시 issues 기록(품질축 가시화) — B에 클리셰 은유 주입 → cron issues에 'B검증위반' 포함
E-10-10회귀스키마 변경 0 — upsert 키 집합 고정 — 캡처된 upsert 객체 키가 기존 7키만(컬럼 추가 없음)
DoD
의존E-04 · E-05 · E-06 · E-07 · E-08 · E-09

E-11 · compare 운영 안전·롤백·라이브 무영향 확인운영 운영 · 0.75h

목적additive 원칙 준수를 배포 전 실증한다. env 미설정 시 기존 경로 byte 동일, env 토글로 즉시 롤백, 라이브 타 자녀 무영향을 점검 절차로 박는다.
요구compare env 미설정에서 크론이 기존과 동일 동작(타 자녀·아린의 기존 v3/v2)함을 확인하고, COACH_COMPARE_CHILDREN=아린id 설정 후 ?child=아린id&date=과거 시뮬로 A·B 2통이 한 row에 생기는지 라이브-세이프 검증. 롤백은 env 삭제 한 번.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
E-11-1수동env 미설정 빌드·테스트 그린 — env 없이 npm run build && npm test → 0 실패
E-11-2E2Ecompare 시뮬 — A·B 2통 한 row — ?child=아린&date=과거 → coach_letters row.letter(A) + context.altLetter.letter(B) 둘 다 존재
E-11-3회귀타 자녀 무영향(diff 0) — compare off 자녀 ?child 시뮬 산출이 배포 전과 동일
E-11-4수동env 삭제 롤백 — COACH_COMPARE_CHILDREN 삭제 후 시뮬 → altLetter 미생성, 기존 경로 복귀
E-11-5수동B 비용·실패율 모니터 노출 — cron_runs.meta에 compareLlmCalls·B skipped/failed 카운트 확인 가능
E-11-6E2Eforce 미사용 시 재사용 분기 정상(A 재사용) — 같은 date 2회 시뮬(force 없음) → 2회차는 reused, B도 캐시 정책 준수
DoD
의존E-10

EPIC F — 앱 2카드 노출 (아린 코칭 화면 A/B 비교 UI) D3

아린 코칭 화면에 기존 v2(라벨 '기존')와 새 설계 Letter B(라벨 '새 설계') 두 카드를 나란히 렌더하고, 변형별(A/B) 1탭 피드백을 받는다. context.altLetter가 있는 자녀만 두 번째 카드를 그리며, 다른 자녀는 단일 카드로 무영향이어야 한다(스키마 변경 0 + 피드백 variant 컬럼 1개).

📁 신규 web/components/CompareLetterCard.tsx web/lib/altLetter.ts web/tests/alt-letter.test.ts web/tests/compare-card.test.tsx · 수정 web/app/page.tsx · SQL web/sql/2026-06-13_letter_feedback_variant.sql

F-01 · altLetter 추출·검증 순수 함수 lib/altLetter.ts코드 코드 · 1h

목적coach_letters.context jsonb에서 Letter B(altLetter)를 안전하게 파싱·검증하는 순수 함수를 만들어, 렌더 레이어가 raw jsonb를 직접 만지지 않게 한다(다른 자녀 = altLetter 없음 → null 반환으로 단일 카드 보장).
요구context가 임의 shape인 jsonb이므로(타입 미보장), letter 문자열이 실제로 있을 때만 비교 카드 후보를 반환한다. 빈 문자열·누락·타입 불일치는 전부 null(=무영향).
명세
유즈케이스
테스트17개
ID종류케이스 · 검증(입력→기대)
F-01-1단위정상 altLetter 추출 — pickAltLetter({altLetter:{letter:'B편지',oneliner:'한줄'}}) → {letter:'B편지', oneliner:'한줄', design:null, mirror:null, materials:null}
F-01-2단위altLetter 없음 → null — pickAltLetter({reds:['철'],mirror:'거울'}) → null
F-01-3단위context null → null — pickAltLetter(null) → null
F-01-4단위context undefined → null — pickAltLetter(undefined) → null
F-01-5적대letter 빈 문자열 → null(빈 카드 방지) — pickAltLetter({altLetter:{letter:'',oneliner:'x'}}) → null
F-01-6적대letter 공백만 → null — pickAltLetter({altLetter:{letter:' '}}) → null
F-01-7적대letter 비-문자열(숫자) → null — pickAltLetter({altLetter:{letter:123}}) → null
F-01-8적대altLetter 비-객체(문자열) → null — pickAltLetter({altLetter:'B편지'}) → null
F-01-9단위oneliner 비-문자열 → null로 정규화 — pickAltLetter({altLetter:{letter:'B',oneliner:42}}).oneliner → null
F-01-10단위oneliner 누락 → null — pickAltLetter({altLetter:{letter:'B'}}).oneliner → null
F-01-11단위mirror·design 통과 — pickAltLetter({altLetter:{letter:'B',mirror:'거울문',design:'food'}}) → mirror==='거울문' && design==='food'
F-01-12단위materials 유효 배열 통과 — pickAltLetter({altLetter:{letter:'B',materials:['당근','치즈']}}).materials → ['당근','치즈']
F-01-13적대materials 비-배열 → null — pickAltLetter({altLetter:{letter:'B',materials:'당근'}}).materials → null
F-01-14적대materials 원소에 비-문자열 섞임 → null — pickAltLetter({altLetter:{letter:'B',materials:['당근',5]}}).materials → null
F-01-15속성입력 context 불변(순수성) — 입력 ctx={altLetter:{letter:'B'}} 깊은복사 보관 후 pickAltLetter(ctx) 호출 → ctx가 호출 전과 deep-equal(변형 없음)
F-01-16적대altLetter=null(명시적) → null — pickAltLetter({altLetter:null}) → null
F-01-17회귀중첩 깊은 unexpected 키 무시 — pickAltLetter({altLetter:{letter:'B',extra:{x:1}}}) → {letter:'B',...}로 정상, extra 무시
DoD
의존없음

F-02 · 홈 coach_letters select에 context 추가 + altLetter 상태코드 코드 · 1h

목적현 홈 쿼리(select('letter_date,letter,oneliner')·select('letter,oneliner,source_hash'))는 context를 안 가져와 altLetter가 클라에 도달 못 한다. context를 select에 추가하고 altB 상태를 세팅한다(Letter A 표시 경로는 무변경).
요구오늘 편지·재생성 폴백·과거 폴백 3경로(app/page.tsx 313~344) 모두에서 context를 함께 읽되, 메인 aiLetter/aiOneliner는 기존대로 A(v2)를 가리킨다. altB는 별도 상태.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
F-02-1회귀select에 context 포함(정적 검사) — grep로 app/page.tsx의 coach_letters select 3곳이 모두 'context' 문자열 포함 → 일치
F-02-2통합altB 세팅 — altLetter 있는 row — mock cached={letter:'A',oneliner:'oa',context:{altLetter:{letter:'B'}}} → 렌더 후 두 번째 카드(라벨 '새 설계') DOM 존재
F-02-3통합altB null — altLetter 없는 row — mock cached={letter:'A',context:{reds:[]}} → 두 번째 카드 미존재, 첫 카드만
F-02-4적대자녀 전환 시 altB 리셋 — 아린(altLetter 있음)→다른 자녀(altLetter 없음) 전환 → 두 번째 카드 사라짐(이전 altB 잔류 없음)
F-02-5적대cancelled 가드 — 전환 중 setAltB 미반영 — 로드 도중 selectedId 변경(cancelled=true) → setAltB 호출 안 됨(교차오염 0)
F-02-6회귀Letter A 경로 무변경 — context 추가 후에도 aiLetter=cached.letter(=A)·aiOneliner=cached.oneliner 그대로 — A 표시 동일
DoD
의존F-01

F-03 · CompareLetterCard 두 카드 나란히 렌더 컴포넌트UI 코드 · 2h

목적기존 v2 카드와 Letter B 카드를 라벨('기존'/'새 설계')과 함께 나란히(세로 스택) 렌더하는 클라이언트 컴포넌트. altB가 있을 때만 두 번째 카드를 그린다.
요구이 Next는 브레이킹 체인지 버전 — node_modules/next/dist/docs 확인 후 기존 app/page.tsx 코치 카드 마크업(582~637행: rounded-2xl·linear-gradient·인라인 style)을 그대로 패턴화. altB=null이면 컴포넌트는 두 번째 카드 자리에 null 반환(단일 카드 유지).
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
F-03-1단위altB 있음 → 두 카드 렌더 — render(CompareLetterCard, {letterA:'A',altB:{letter:'B'}}) → 텍스트 'A'·'B' 둘 다 DOM 존재
F-03-2단위라벨 '기존'/'새 설계' 표기 — altB 있을 때 '기존' 배지와 '새 설계' 배지 모두 렌더
F-03-3단위altB null → A 카드만 — render({letterA:'A',altB:null}) → 'A' 존재, '새 설계' 배지 미존재
F-03-4단위B oneliner 렌더 — altB={letter:'B',oneliner:'B한줄'} → 'B한줄' DOM 존재
F-03-5단위B mirror 있으면 표기, 없으면 생략 — altB={letter:'B',mirror:'거울'} → '거울' 노출 / altB={letter:'B'} → mirror 영역 미존재
F-03-6적대isMockup일 때 B 미렌더 — isMockup=true, altB=null → '새 설계' 배지 미존재
F-03-7단위날짜 라벨 A/B 동일 — letterDate='2026-06-13' → 두 카드 모두 fmtLetterDate('2026-06-13') 결과 노출
F-03-8단위onFeedback 콜백 variant 전달 — B 카드 '👍' 클릭 → onFeedback('B','up') 호출(variant='B')
DoD
의존F-01

F-04 · 피드백 variant 컬럼 SQL + 유니크 재정의SQL SQL · 0.5h

목적현 letter_feedback unique(child_id,letter_date)는 하루 1표라 A/B 두 카드에 따로 피드백을 못 받는다. variant 컬럼(A/B)을 추가하고 유니크를 (child_id,letter_date,variant)로 재정의해 변형별 표를 받는다.
요구기존 단일 카드 피드백(variant 없음)을 깨지 않게 variant 기본값 'A'. SQL 실행 확인 없이 다음 단계(F-05) 금지(점심 사고 교훈).
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
F-04-1통합variant 컬럼 존재 — SQL 실행 후 information_schema.columns에 letter_feedback.variant 존재(default 'A')
F-04-2통합유니크 (child_id,letter_date,variant) — 같은 child_id·letter_date로 variant='A','B' 두 행 insert 성공(중복 에러 없음)
F-04-3적대같은 variant 중복 거부 — child_id·letter_date·variant='A' 동일 행 2번 insert → 두 번째는 unique 위반(upsert로만 갱신)
F-04-4회귀기존 행 default 'A' — variant 미지정 insert → variant='A'로 저장
F-04-5적대variant 제약 위반 — variant='C' insert → check 제약 위반 에러
DoD
의존없음

F-05 · variant별 피드백 upsert 배선 + 1탭 UI 상태코드 코드 · 1.5h

목적app/page.tsx의 letter_feedback upsert(602행)와 letterFb 상태가 단일 카드(variant 없음) 전제다. A/B 카드별로 rating을 보내고 카드별 선택 상태를 표시한다.
요구기존 A 카드 피드백 동작은 variant='A'로 보존. B 카드는 variant='B'. onConflict는 'child_id,letter_date,variant'로 변경(F-04 선행 필수).
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
F-05-1통합A 피드백 variant='A' 전송 — A 카드 👍 클릭 → upsert payload.variant==='A' && rating==='up'
F-05-2통합B 피드백 variant='B' 전송 — B 카드 👎 클릭 → upsert payload.variant==='B' && rating==='down'
F-05-3회귀onConflict 키 변경 — upsert 옵션 onConflict==='child_id,letter_date,variant'
F-05-4단위A/B 선택 독립 — A=👍 선택 후 B=🔁 선택 → letterFb={A:'up',B:'repeat'}(서로 안 덮음)
F-05-5적대미로그인/childId 없음 → no-op — user=null 또는 childId=null → upsert 호출 안 됨(기존 가드 보존)
F-05-6적대altB null 자녀 B 핸들러 미노출 — altB=null → B 피드백 버튼 DOM 미존재 → variant='B' upsert 경로 도달 불가
F-05-7회귀기존 단일 카드 A 동작 회귀 — 단일 카드(altB null) 👍 → variant='A' upsert(기존 행동과 동일 결과)
F-05-8통합초기 로드 선택 복원 — DB에 variant='B',rating='up' 존재 → 마운트 후 B 카드 👍 칩 active 하이라이트
DoD
의존F-03 · F-04

F-06 · 홈 카드 영역에 CompareLetterCard 통합 + 기존 카드 치환UI 코드 · 1.5h

목적app/page.tsx 582~637행의 기존 단일 코치 카드를 CompareLetterCard로 치환하되, altB=null·mockup일 때 기존 화면과 동일하게 보이도록 한다(과한 변경 금지 — 대조군·기존 UX 보존).
요구기존 mockup 예시 카드(613~618행)·지난 편지(showPast)·리텐션 멘트(631~635행)는 그대로 유지. 비교 카드는 실데이터(aiLetter 있음)일 때만 두 카드, 그 외 기존 마크업 폴백.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
F-06-1통합실데이터+altB → 두 카드 — aiLetter='A', altB={letter:'B'} → '기존'·'새 설계' 두 카드 + 각 피드백 버튼
F-06-2회귀실데이터+altB null → 단일 카드(회귀) — aiLetter='A', altB=null → A 카드만, '새 설계' 미존재, 기존과 동일 구조
F-06-3회귀mockup 카드 보존 — isMockup=true → 기존 예시 카드 텍스트('집 끼니에 콩류가...') 그대로 노출
F-06-4회귀지난 편지 토글 유지 — pastLetters 2건 → '📬 지난 편지 2 ▾' 버튼 동작, showPast 토글 시 과거 편지 렌더
F-06-5회귀리텐션 멘트 유지 — 실데이터 → '🍪 코치 편지는 매일 새로 도착' 멘트 렌더
F-06-6통합타입·빌드 통과 — tsc + next build 에러 0(CompareLetterCard import·props 정합)
DoD
의존F-03 · F-05

F-07 · 어드민 [childId] A/B 피드백 분리 집계UI 코드 · 1h

목적app/admin/[childId]/page.tsx의 fb 집계(109~111행)가 variant 무관 합산이다. A/B를 분리 집계해 '새 설계(B)가 기존(A)을 이기는지'를 어드민이 한눈에 보게 한다(승격 판정 근거).
요구기존 fb 합산 위젯은 유지하되 A/B 분리 카운트 추가. variant 컬럼 없는 옛 행은 'A'로 간주(F-04 default와 일치).
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
F-07-1통합variant 분리 집계 — fbRaw=[{rating:'up',variant:'A'},{rating:'up',variant:'B'},{rating:'repeat',variant:'B'}] → fbA.up=1, fbB.up=1, fbB.repeat=1
F-07-2회귀variant 없는 옛 행 → A — fbRaw=[{rating:'up'}] (variant 누락) → fbA.up=1, fbB.up=0
F-07-3단위B 카운트 0 → 비교 줄 미표기 — fbB 전부 0 → 'B 👍' 비교 줄 미렌더(기존 단일 위젯만)
F-07-4회귀select에 variant 포함 — letter_feedback select 문자열에 'variant' 포함
F-07-5회귀자가진단 A 기준 유지 — maxSim·mirrorRate 계산 입력이 메인 letter(=A) 기준 그대로(B로 안 바뀜)
DoD
의존F-04

F-08 · E2E 회귀: 다른 자녀 단일 카드 무영향 검증테스트 테스트 · 0.5h

목적모든 개선이 Letter B(아린)에만 적용되고 다른 자녀는 화면이 한 톨도 안 바뀌는지 통합 검증(대조군·무영향 원칙의 안전망).
요구altLetter 없는 자녀 = 카드 1개·피드백 variant='A'·어드민 B 카운트 0. 회귀가 깨지면 즉시 red.
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
F-08-1E2EaltB null 자녀 단일 카드 — altB=null 렌더 → 카드 1개·'새 설계' 0개·피드백 3개
F-08-2E2E아린 두 카드 — altB={letter:'B'} → 카드 2개·피드백 6개·라벨 '기존'+'새 설계'
F-08-3회귀빈 altLetter → 단일 카드 — context.altLetter.letter='' → pickAltLetter null → 카드 1개
F-08-4회귀A 본문은 항상 v2 메인 letter — 두 카드든 한 카드든 첫 카드 본문 === coach_letters.letter(=A·무변경)
F-08-5회귀mockup 무영향 — isMockup=true → 두 카드 분기 미진입, 기존 예시 카드 유지
DoD
의존F-03 · F-06

EPIC G — 어드민 A/B 2통 비교 + 변형별 피드백 승자 판정 D3

어드민 쓰레드에서 날짜별 Letter A(v2 대조군)와 Letter B(새 설계 처치군)를 나란히 보여주고, 👍/👎/🔁 피드백을 변형(A/B)별로 수집·집계해 'B가 A를 이기는지'를 데이터로 판정한다. 이사님이 매일 선호를 찍어 컷오버 근거를 만든다.

📁 신규 web/lib/compareVote.ts web/app/api/feedback/letter/route.ts web/app/admin/compare/page.tsx web/app/admin/compare/VoteButtons.tsx web/tests/compare-vote.test.ts · 수정 web/app/admin/[childId]/page.tsx web/app/admin/layout.tsx web/app/api/cron/coach-selfcheck/route.ts web/app/page.tsx · SQL web/sql/2026-06-14_letter_feedback_variant.sql

G-01 · letter_feedback.variant 컬럼 + compare_votes 테이블 SQLSQL SQL · 0.5h

목적피드백을 변형(A/B)별로 구분 저장하고, 이사님의 일일 어드민 선호 판정(A vs B)을 별도 테이블에 기록할 스키마를 추가한다.
요구기존 letter_feedback(child_id,letter_date uniq·rating up/down/repeat)에 variant를 더하고, 어드민 비교투표용 compare_votes를 신설한다. 기존 부모 피드백 행은 무영향(default 'A').
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
G-01-1수동variant 컬럼 존재·기본값 A — 실행 후 \d letter_feedback → variant text not null default 'A' 컬럼 존재
G-01-2수동variant CHECK 제약 — insert variant='C' → check 위반으로 거부
G-01-3수동기존 행 default 백필 — 기존 letter_feedback 행 SELECT → 전부 variant='A'(NOT NULL 위반 없음)
G-01-4수동신 unique 제약(변형별 1표) — 같은 child_id·letter_date에 variant='A' 1행 + variant='B' 1행 동시 insert 성공
G-01-5수동compare_votes 하루 1표 멱등 — 같은 admin_id·child_id·vote_date로 winner='A' 후 winner='B' upsert → 1행만, winner='B'
G-01-6수동compare_votes winner CHECK — winner='X' insert → check 위반으로 거부('A'/'B'/'tie'만)
G-01-7수동멱등 재실행 — SQL 파일 2회 실행 → 에러 없음(if not exists 가드)
DoD
의존없음

G-02 · compareVote.ts — 승자 판정 순수함수(선호율·반복신고율)코드 코드 · 1.5h

목적변형별 피드백 카운트와 어드민 투표를 입력받아 A vs B 선호율·반복 신고율·승자를 결정론으로 계산하는 순수 모듈. 어드민 위젯과 selfcheck 크론이 공유.
요구입력=변형별 {up,down,repeat} 집계 + compare_votes 배열. 출력=변형별 점수·선호율·반복신고율·승자(A/B/tie)·신뢰도(표본수 기반). LLM 0콜, 부수효과 0.
명세
유즈케이스
테스트18개
ID종류케이스 · 검증(입력→기대)
G-02-1단위scoreVariant 가중 — {up:3,down:1,repeat:2} → 3*2-1-2*1.5=2
G-02-2단위scoreVariant 표본0 — {up:0,down:0,repeat:0} → 0
G-02-3단위repeat가 down보다 무겁다 — {up:0,down:0,repeat:2} 점수 < {up:0,down:2,repeat:0} 점수 (repeat 가중 1.5>down 1)
G-02-4단위repeatRate 분자분모 — {up:1,down:1,repeat:2} → 2/4=0.5
G-02-5단위repeatRate 분모0 — {up:0,down:0,repeat:0} → 0(NaN 아님)
G-02-6단위투표 다수결 우선 — A점수>B점수여도 votes=[B,B,A] → winner='B'(어드민 투표 우선)
G-02-7단위투표 무·점수 B 우세 — votes=[], A{up:0,repeat:3}, B{up:5,repeat:0} → winner='B'
G-02-8단위투표 동률→점수 폴백 — votes=[A,B], A점수>B점수 → winner='A'(voteWinner=tie라 점수로)
G-02-9단위미세차 tie — votes=[], 총표본20에서 aScore=10,bScore=10.5 → |diff|0.5<max(1,2)=2 → winner='tie'
G-02-10단위명확차 승자 — votes=[], 총표본20에서 aScore=5,bScore=15 → winner='B'
G-02-11단위confidence low — 총표본 3 → confidence='low'
G-02-12단위confidence mid — 총표본 10 → confidence='mid'
G-02-13단위confidence high — 총표본 20 → confidence='high'
G-02-14단위buildCompareSummary B우세 — B 우세 입력 → 문자열에 'B' 포함·👍 카운트·🔁 비율 포함
G-02-15단위buildCompareSummary 콜드스타트 — 전부 0·votes=[] → tie·low 안전 문자열(throw 없음)
G-02-16단위voteWinner 다수결 동률 처리 — votes=[A,B,tie] → voteWinner='tie'(A1·B1 동률)
G-02-17속성부수효과 0(순수) — 같은 입력 2회 호출 → byte-동일 출력, 입력 객체 불변
G-02-18적대적대: 음수 카운트 방어 — {up:-1,...} 같은 비정상 입력에도 throw 없이 수치 반환(런타임 안전)
DoD
의존G-01

G-03 · 부모 피드백 client upsert에 variant 전달코드 코드 · 0.5h

목적app/page.tsx 부모 편지 카드의 👍/👎/🔁 upsert에 variant를 붙여, 부모가 본 편지가 A인지 B인지 기록한다. (부모는 메인 letter=A만 보므로 'A' 고정, 단 향후 B 노출 대비 변수화.)
요구기존 letter_feedback upsert(app/page.tsx L602)에 variant 추가. 부모 화면은 메인 letter(=A) 노출이므로 variant='A'. onConflict는 새 3키 제약에 맞춤.
명세
유즈케이스
테스트3개
ID종류케이스 · 검증(입력→기대)
G-03-1수동upsert variant 포함 — 홈에서 👍 클릭 → letter_feedback row variant='A'
G-03-2수동onConflict 3키 — 같은 날 👍→👎 토글 → variant='A' 행 1개만, rating='down'으로 덮어씀
G-03-3수동기존 토글 UX 무변경 — 클릭 후 버튼 하이라이트·'고마워요' 멘트 정상 노출
DoD
의존G-01

G-04 · 코칭 크론 — Letter B를 context.altLetter에 저장(아린 compare 모드)코드 코드 · 1h

목적아린(compare 코호트)에 대해 메인 letter=A(v2)는 그대로 두고, 새 설계로 생성한 Letter B를 coach_letters.context.altLetter에 함께 저장한다. 스키마 변경 0, 어드민이 둘을 나란히 읽을 데이터 소스 확보.
요구compare 코호트 판정 시 finalCtx(route.ts L715)에 altLetter={letter,oneliner,design,mirror,materials} 추가. 메인 letter/oneliner는 A(v2 composeLetter) 그대로. 모든 개선은 B에만.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
G-04-1단위compareEnabled 판정 — env.COACH_COMPARE_CHILDREN='arin-id' → compareEnabled(env,'arin-id')=true, 타 id=false
G-04-2단위compareEnabled 미설정 — env 빈 객체 → 모든 id false(기존 경로)
G-04-3단위altLetter context 구조 — altLetterOut 주입 시 finalCtx.altLetter.letter·design.materials 존재
G-04-4단위메인 letter는 A 유지 — compare ON이어도 upsert payload.letter === A(v2) 결과(B로 덮어쓰지 않음)
G-04-5단위B 실패 시 fail-open — altLetterOut=null → finalCtx에 altLetter 키 없음, 메인 A 정상 upsert
G-04-6회귀타 자녀 무영향(회귀) — compare OFF 자녀 → finalCtx에 altLetter 키 부재·기존 컨텍스트 동일
G-04-7수동멱등 upsert — 같은 날 2회 크론 → coach_letters 1행, altLetter 갱신
DoD
의존G-01

G-05 · compareEnabled 코호트 게이트 순수함수코드 코드 · 0.3h

목적아린을 compare(A/B 2통) 모드로 묶는 env 게이트를 v3Enabled 패턴으로 추가한다. 컷오버·롤백을 env 토글로.
요구coachDaily.ts v3Enabled(L20)와 동일 형태의 순수 판정. COACH_COMPARE=1 전체 또는 COACH_COMPARE_CHILDREN=id 카나리아. 기존 v3 코호트와 독립.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
G-05-1단위전체 ON — {COACH_COMPARE:'1'} → 임의 id true
G-05-2단위카나리아 ID 매칭 — {COACH_COMPARE_CHILDREN:'a,b'} → 'a' true·'c' false
G-05-3단위공백 트림 — {COACH_COMPARE_CHILDREN:' a , b '} → 'a' true
G-05-4단위미설정 전원 false — {} → 임의 id false
G-05-5적대빈 문자열 방어 — {COACH_COMPARE_CHILDREN:''} → false(빈 토큰 filter)
G-05-6단위v3Enabled와 독립 — COACH_V3=1만 설정 → compareEnabled는 false(키 분리)
DoD
의존없음

G-06 · 어드민 쓰레드 — A/B 나란히 렌더 + 변형 라벨UI UI · 1.5h

목적app/admin/[childId] 편지 버블에서 메인 letter(A)와 context.altLetter(B)를 나란히/대비해 보여줘, 이사님이 같은 날 두 편지를 직접 비교한다.
요구context.altLetter가 있는 편지(아린)는 A 버블 아래에 B 버블을 'A(v2)'·'B(새설계)' 라벨로 추가. 없으면 기존 단일 렌더. 회귀 없음.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
G-06-1수동altLetter 있을 때 2버블 — context.altLetter 있는 편지 → 'A·v2'·'B·새설계' 두 버블 노출
G-06-2수동altLetter 없을 때 단일 — altLetter 키 없는 편지(타 자녀) → 버블 1개(회귀)
G-06-3수동B 재료 요약 노출 — B 버블에 design.materials(추천 식재료) 칩 노출
G-06-4수동B 검증 칩 — altLetter.verify.ok=true → '✅ 검증통과' 칩, false → '⚠️ 검증위반' 칩
G-06-5수동oneliner 누락 안전 — altLetter.oneliner=null → 본문만 렌더, 크래시 없음
G-06-6수동VoteButtons 마운트 — B 버블 아래 A/B 투표 버튼 노출(G-08)
DoD
의존G-04 · G-08

G-07 · feedback/letter API — 변형별 부모 피드백 서버 라우트(옵션 채널)코드 코드 · 0.8h

목적부모 피드백을 RLS client upsert 외에 서버에서도 variant 포함 검증·기록할 수 있는 라우트. 향후 B 노출 실험·서버 측 집계 트리거에 사용.
요구POST /api/feedback/letter {child_id,letter_date,rating,variant}. 인증=로그인 부모(본인 자녀), rating·variant 화이트리스트 검증, upsert onConflict 3키. AGENTS.md: node_modules/next/dist/docs 규약 확인 후 작성.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
G-07-1통합정상 upsert — 인증 부모·본인자녀·rating='up'·variant='B' → 200, letter_feedback에 행
G-07-2통합미인증 401 — no auth → 401
G-07-3통합타인 자녀 403 — 본인 소유 아닌 child_id → 403, 행 미생성
G-07-4적대rating 화이트리스트 — rating='love' → 400
G-07-5적대variant 화이트리스트 — variant='C' → 400
G-07-6통합onConflict 3키 덮어쓰기 — 같은 키 up→down 2회 → 1행, rating='down'
G-07-7적대누락 필드 400 — letter_date 누락 → 400(throw 아닌 응답)
DoD
의존G-01

G-08 · VoteButtons — 이사님 A/B 일일 선호 투표 client 컴포넌트UI UI · 1h

목적어드민 쓰레드·compare 페이지에서 이사님이 그날 어느 편지가 좋은지(A/B/무승부) 한 번에 찍는 client 버튼. compare_votes upsert.
요구client 컴포넌트, A/B/tie 토글, 선택 즉시 compare_votes upsert(admin_id=auth.uid·하루1표 덮어쓰기). 어드민 인증 전제.
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
G-08-1수동A 투표 upsert — 'A 좋음' 클릭 → compare_votes winner='A'
G-08-2수동B로 변경 덮어쓰기 — A→B 클릭 → 같은 날 1행, winner='B'
G-08-3수동무승부 — '무승부' → winner='tie'
G-08-4수동선택 하이라이트 — 클릭한 버튼만 강조 스타일
G-08-5수동비어드민 차단 — RLS로 admin_id=auth.uid 불일치 시 쓰기 거부(콘솔 에러·UI graceful)
DoD
의존G-01

G-09 · /admin/compare — 승자 집계 위젯 페이지UI UI · 1.5h

목적아린(compare 코호트) 전체 기간의 A vs B 선호율·반복신고율·이사님 투표 집계를 한 화면에 보여줘 컷오버 판정 근거를 만든다.
요구compare 코호트 자녀별로 letter_feedback(variant별 집계)+compare_votes를 compareVote.decideWinner로 요약. 승자 배지·신뢰도·일자별 투표 타임라인.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
G-09-1수동승자 배지 — B 우세 집계 → 'B 우세' 배지·신뢰도 노출
G-09-2수동선호율 표시 — variant별 👍/👎/🔁 카운트·반복신고율% 노출
G-09-3수동투표 타임라인 — compare_votes 일자별 winner 목록 최신순
G-09-4수동콜드스타트 graceful — 피드백 0·투표 0 → '데이터 부족' 안내·크래시 없음
G-09-5수동테이블 미생성 안전 — compare_votes 없을 때 빈 패널·에러 없음
G-09-6수동비어드민 차단 — 비관리자 접근 → 🔒 게이트
G-09-7수동B 발행 일수 — altLetter 있는 coach_letters 날짜 수가 정확히 집계
DoD
의존G-02 · G-08

G-10 · 어드민 사이드바에 'A/B 비교' 진입점 추가UI UI · 0.2h

목적app/admin/layout.tsx AdminSidebar에 /admin/compare 링크를 추가해 이사님이 매일 비교 판정에 들어갈 동선을 만든다.
요구기존 사이드바 메뉴(대시보드·커리큘럼·크론 등)와 동일 패턴으로 'A/B 비교' 항목 추가. 회귀 없음.
명세
유즈케이스
테스트2개
ID종류케이스 · 검증(입력→기대)
G-10-1수동링크 노출 — 어드민 사이드바에 'A/B 비교' 항목 노출·/admin/compare로 이동
G-10-2회귀기존 메뉴 무영향 — 대시보드·커리큘럼·크론 등 기존 항목 그대로
DoD
의존G-09

G-11 · selfcheck 크론 — 변형별 집계 + B vs A 승자 alert코드 코드 · 1h

목적야간 selfcheck가 letter_feedback을 변형별로 집계하고 compareVote로 'B가 A를 이기는지'를 cron_runs.meta.alerts에 1줄 기록해, 이사님이 매일 보지 않아도 추세를 추적한다.
요구coach-selfcheck/route.ts의 fbByChild 집계를 variant별로 확장. compare 코호트 자녀는 decideWinner 요약을 alert에 추가. 기존 반복/거울 alert 무변경.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
G-11-1단위variant별 집계 — letter_feedback [A:up,B:down] → fbByChild[cid].A.up=1·B.down=1
G-11-2단위compare 자녀 B 요약 alert — compareEnabled 자녀·B 우세 입력 → flags에 'A/B' 요약 포함
G-11-3단위비compare 자녀 요약 없음 — compareEnabled false 자녀 → flags에 'A/B' 요약 미포함
G-11-4회귀기존 반복 alert 무변경 — maxSim≥0.45 → 기존 '반복 N%' flag 그대로
G-11-5단위compare_votes 미생성 graceful — compare_votes 테이블 없음 → throw 없이 votes=[]로 요약
G-11-6단위피드백0·투표0 노이즈0 — 전부 0 → 'A/B' alert 미추가
G-11-7회귀fb.down 전체합 호환 — 기존 fb.down(A+B 합) 경보 동작 유지
DoD
의존G-01 · G-02 · G-05

G-12 · compare-vote.test.ts 통합 + 엣지 fixture 회귀테스트 테스트 · 0.5h

목적compareVote 순수함수와 게이트의 엣지·적대·회귀 케이스를 vitest fixture로 묶어 prebuild 게이트에 등록하고, 실데이터에서 발견될 엣지를 red→green으로 누적한다.
요구tests/compare-vote.test.ts에 G-02·G-05 케이스를 vitest로 구현 + 아린 실데이터 유사 fixture(A 수렴·B 다양)로 승자 판정 회귀 보호.
명세
유즈케이스
테스트3개
ID종류케이스 · 검증(입력→기대)
G-12-1단위전체 스위트 그린 — npm test → compare-vote.test.ts 전 케이스 pass
G-12-2회귀아린 시나리오 회귀 — B 다양·A 수렴 fixture → decideWinner.winner='B'
G-12-3회귀prebuild 게이트 — 테스트 실패 시 build 중단(prebuild 게이트)
DoD
의존G-02 · G-05

EPIC H — 검증 하네스·회귀·적대 테스트 (Letter B 품질 게이트) D4

Letter B 개선 A~I의 결정론층(재료 회전·4기준 랭킹·조합 정합·품질 스캔)을 다일 리플레이·적대·회귀 테스트로 박제해 'prebuild 게이트 그린=좋은 편지'가 성립하게 만든다. 아린 실데이터 6통 회귀·괴식 조합 차단·14일 수렴 0·merged vs v2 골든을 모두 게이트화한다.

📁 신규 web/tests/coach-materials.test.ts web/tests/coach-quality.test.ts web/tests/coach-merged.test.ts web/tests/compare-replay.test.ts web/tests/fixtures/arin-golden-b.json · 수정 web/tests/fixtures/real-arin.json web/lib/replayMetrics.ts web/lib/replayRunner.ts web/package.json

H-01 · adversarial fixture — 괴식/OK 조합 골든 셋(comboGolden.json)테스트 테스트 · 0.75h

목적개선 A(섞기 조합 정합성) 테스트의 단일 진실 fixture를 만든다 — kit-dish-matrix.json·food-graph.json에 실재하는 점수를 인용한 괴식/OK 조합 케이스 배열.
요구기대(차단/통과)와 근거점수를 명시한 조합 골든 케이스를 fixture로 고정해, 조합 정합 검증기(A-EPIC 산출)의 회귀·오탐방지 테스트가 같은 데이터를 공유한다.
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
H-01-1단위fixture 무결성: block·ok 배열 비어있지 않음 — comboGolden.block.length>=3 && comboGolden.ok.length>=3 → true
H-01-2회귀block 케이스 점수는 실제 matrix에서 ≤1 — 각 block.matrixScore가 kit-dish-matrix.json scores[dish][ingredient]와 일치하고 ≤1
H-01-3회귀ok 케이스 점수는 실제 matrix에서 ≥2 — 각 ok.matrixScore가 kit-dish-matrix.json scores[dish][ingredient]와 일치하고 ≥2
H-01-4적대짜파게티는 matrix dish 키 없음(pair 폴백 케이스 보존)kit-dish-matrix.json scores['짜파게티']===undefined → comboGolden에 via:'pair' 케이스로 존재
H-01-5회귀미역국+당근 골든이 block에 존재(괴식 박제) — comboGolden.block에 {dish:'미역국'|'미역', ingredient:'당근'} 항목 존재
DoD
의존A-EPIC(조합 정합 검증기 export)

H-02 · 괴식 조합 적대 테스트 — comboFit 검증기(coach-materials.test.ts)테스트 테스트 · 1h

목적개선 A의 조합 정합 검증기(잘먹는음식+결핍식재료를 kit-dish-matrix 점수+food-graph pair로 점수화→낮으면 금지)가 괴식을 막고 OK 조합을 통과시키는지 박제. LLM이 조합을 지어내지 못하게 코드가 후보를 거른다(6/8 미역국+당근 사고).
요구A-EPIC 검증 함수에 H-01 골든을 입력해 block 케이스는 false(부적합), ok 케이스는 true(적합), 임계 경계(score 2)에서 정확히 분기함을 검증한다.
명세
유즈케이스
테스트10개
ID종류케이스 · 검증(입력→기대)
H-02-1적대미역국+당근 차단(괴식 박제) — comboFit('미역국','당근').ok===false (matrix score 1)
H-02-2단위볶음밥+당근 통과 — comboFit('볶음밥','당근').ok===true (score 3)
H-02-3단위카레+당근 통과 — comboFit('카레','당근').ok===true (score 3)
H-02-4속성임계 경계: score===2는 통과, score===1은 차단 — comboGolden 전 항목에서 matrixScore>=2 ⇔ ok===true
H-02-5적대matrix 미수록 dish는 pair 폴백 — comboFit('짜파게티','당근')은 foodGraph pair 존재 시 ok===true, via==='pair'
H-02-6적대pair도 없는 무관 조합은 차단 — comboFit('미역국','초콜릿')처럼 matrix<2 & pair 없음 → ok===false
H-02-7단위오탐방지: ok 골든 다수 통과(정합 조합을 막지 않음) — comboGolden.ok 전 항목 ok===true
H-02-8적대block 골든 다수 차단(괴식 일괄) — comboGolden.block 전 항목 ok===false
H-02-9적대점수 미상 ingredient(매트릭스에도 그래프에도 없음)는 보수적 차단 — comboFit('미역국','존재하지않는식재료').ok===false
H-02-10속성대칭성: 입력 순서 무관(dish×ingredient 고정 방향) — comboFit는 첫 인자=dish·둘째=ingredient로만 평가, ingredient를 dish 위치에 넣어도 throw 없이 ok=false
DoD
의존H-01 · A-EPIC(comboFit 검증기 export)

H-03 · 재료 결정론 회전·3일 무재사용 불변식 테스트테스트 테스트 · 1h

목적개선 B(재료=코드가 매일 회전·3일내 재사용 금지, 문장만 LLM)의 회전 함수가 결정론이고 3일 창에서 같은 추천 식재료를 반복하지 않음을 박제. 'LLM이 매일 독립 최적화→당근→미역국 수렴'을 코드가 막는 증거.
요구B-EPIC 재료 회전 함수(같은 결핍군에서 날짜 시드로 식재료를 회전)에 14일 연속 입력을 주면 (1)같은 (날짜,자녀) 입력은 byte-동일 출력(결정론) (2)연속 3일 창에 같은 식재료 0건 (3)풀 소진 시에도 라운드로빈으로 다양성 유지.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
H-03-1단위결정론: 동일 입력 두 번 = 동일 식재료 — rotateMaterial({target:'비타민A채소',seed:100,recent3:[]}) 두 호출 결과 === 동일
H-03-2속성3일 창 무재사용: 14일 연속에서 results[i]∉results[i-1..i-2] — daySeed 0..13 회전 결과 배열에서 모든 i에 대해 window(i-2,i-1) 미포함
H-03-3속성풀 4종 고른 순회(편중 없음) — 비타민A채소 14일 회전에서 4종 각각 ≥2회 등장
H-03-4단위recent3 명시 제외 존중 — rotateMaterial({target,seed,recent3:['당근','시금치','근대']})는 '단호박' 반환(나머지 1종)
H-03-5적대풀 1종 군은 throw 없이 그 1종 반환 — 단일 식재료 target 입력 → 같은 식재료 반환·throw 0
H-03-6적대seed 음수/큰 수 정규화(인덱스 안전) — seed=-7·seed=1e9 입력 → 풀 내 유효 식재료 반환(undefined 0)
H-03-7회귀연속 입력의 byte-동일 0(수렴 방지 핵심 지표) — 14일 추천 식재료 배열에 인접 중복(results[i]===results[i-1]) 0건
H-03-8단위다른 결핍군은 독립 회전(군 간 간섭 없음) — 비타민A채소·기타채소 동시 14일 회전이 서로 영향 없음(각자 자기 풀 순회)
DoD
의존B-EPIC(재료 회전 함수 export)

H-04 · 4기준 가중 랭킹 골든 케이스 테스트테스트 테스트 · 1h

목적개선 C(추천 4기준 가중 랭킹+근거 노출)가 ①영양 시급도 ②급식빈도 상위% ③잘먹는음식 궁합(pair) ④잘먹는채소 사촌(bridge)을 실제로 반영하는지 골든으로 박제. 현 pickFoodReco의 seed%length 블라인드 회전(4기준 0반영)·freqMap 죽은 코드를 깨는 회귀.
요구C-EPIC 가중 랭킹 함수에 통제된 입력(freqMap·liked·signals)을 주면 4기준이 각각 순위를 움직임을 증명: freqMap 상위 식재료가 가산점, pair/bridge가 가산점, red 결핍군 우선, 근거문구가 식재료에 붙는다.
명세
유즈케이스
테스트10개
ID종류케이스 · 검증(입력→기대)
H-04-1단위급식빈도: 당근(184) > 단호박(0) 동일군 순위 — rankFoodRecos(비타민A채소, freqMap{당근:184,단호박:0})에서 당근 index < 단호박 index
H-04-2단위freqMap 미주입 시 kit-matrix count 폴백(throw 0) — freqMap:{} 입력 → 결과 비어있지 않고 score 산출(폴백 동작)
H-04-3단위궁합 가산: liked pair 후보가 점수 상승 — liked:['김치'] 추가 시 김치와 pair인 후보의 score가 liked:[] 대비 증가
H-04-4단위사촌 가산: liked bridge 후보가 점수 상승 — liked에 bridge 앵커 추가 시 사촌 후보 score 증가
H-04-5단위영양 시급도: red군 > amber군 — signals에 비타민A채소=red·기타채소=amber → red군 식재료가 amber군보다 상위
H-04-6단위근거문구 노출: reasons에 급식빈도 사유 — 당근 결과의 reasons에 '상위'·'%' 또는 freq 근거 문자열 포함
H-04-7회귀결정론: 동일 입력 = 동일 랭킹 — 같은 {target,signals,liked,freqMap} 두 호출 결과 배열 동일
H-04-8속성4기준 동시 작용: 모두 0이면 풀 순서대로(블라인드 회전 아님 확인) — freqMap{},liked[],signals 균일 → 점수가 식재료별로 다르게 산출되거나 안정 정렬(랜덤 0)
H-04-9적대오탐방지: 매운 음식 근거 식재료 제외(isSpicyDish 정합) — popularDishesFor 경유 근거에 매운 음식명(isSpicyDish=true) 미포함
H-04-10회귀single-source freqMap 배선 회귀: route.ts가 본경로에 freqMap 전달(죽은 코드 부활 증거) — C-EPIC 랭킹 호출 시 freqMap 인자가 정의되어 4기준에 반영(빈 객체와 채워진 객체 결과가 다름)
DoD
의존C-EPIC(랭킹 함수 export)

H-05 · 품질 스캔 테스트 — 은유 과용·나열·모호기간·재료밖(coach-quality.test.ts)테스트 테스트 · 1.5h

목적개선 G(검증 비대칭 닫기 — LLM 출력 품질축 결정론 스캔)의 핵심. '테스트 그린인데 이사님은 별로'를 깨는 4스캐너(은유 클리셰 과용·어제 X 먹었어요 나열·모호 기간어·재료 밖 음식명)를 검출+오탐방지로 박제.
요구G-EPIC 품질 스캐너(예 `scanQuality(letter, {materials})`→{violations:string[]})에 위반 편지를 주면 해당 위반을 잡고, 깨끗한 편지는 통과(오탐 0)함을 다수 케이스로 박제.
명세
유즈케이스
테스트14개
ID종류케이스 · 검증(입력→기대)
H-05-1단위은유 과용 검출(통장+계단 2개) — scanQuality('편식은 통장 같아요. 한 걸음씩 계단을 올라요',{}).violations에 'metaphor' 포함
H-05-2적대은유 1개 자연 비유는 통과(오탐방지) — scanQuality('오늘은 작은 한 걸음을 권해요',{}) → metaphor 위반 없음
H-05-3단위동일 은유 반복도 과용으로 검출 — scanQuality('통장에 쌓이듯, 통장처럼 모이듯',{}).violations에 metaphor
H-05-4단위끼니 나열 패턴 검출 — scanQuality('어제 당근·브로콜리·밥을 먹었어요',{}).violations에 'list'
H-05-5적대나열 아님(행동 권유)은 통과 — scanQuality('오늘 저녁 당근을 볶음밥에 넣어보세요',{}) → list 위반 없음
H-05-6단위모호 기간어 '요즘' 단독 검출 — scanQuality('요즘 채소가 부족해요',{}).violations에 'vagueTime'
H-05-7적대수치 동반 기간은 통과(오탐방지) — scanQuality('최근 7일 중 비타민A 채소가 3일이었어요',{}) → vagueTime 위반 없음
H-05-8단위'이번주'·'최근' 단독도 검출 — scanQuality('이번주는 좀 아쉬웠어요',{}).violations에 vagueTime
H-05-9적대재료 밖 음식명 검출 — scanQuality('시금치파스타를 해주세요',{materials:['당근','볶음밥']}).violations에 'outOfMaterials'
H-05-10단위재료 안 음식명은 통과 — scanQuality('볶음밥에 당근을 넣어보세요',{materials:['당근','볶음밥']}) → outOfMaterials 위반 없음
H-05-11적대빈 편지·null 안전 — scanQuality('',{}) 및 scanQuality(null,{}) → throw 0, violations 배열 반환
H-05-12단위복합 위반 동시 검출(여러 violations) — 은유+나열+모호기간 한 편지 → violations.length>=3
H-05-13회귀깨끗한 모범 편지는 violations 빈 배열(종합 오탐방지) — 수치·구체행동·재료내 음식·은유 0인 편지 → violations==[]
H-05-14속성LLM 0콜(순수 함수) — scanQuality 호출은 동기 반환(Promise 아님)·외부 fetch 0
DoD
의존G-EPIC(품질 스캐너 export)

H-06 · 무검증 채널 차단 테스트 — 거울·추천 합본 1패스(H개선)테스트 테스트 · 0.75h

목적개선 H(무검증 채널 차단 — 거울·추천 문장을 본문과 합본해 검증 1패스, 슬롯값 포함)를 박제. 근본원인 ②'슬롯값·거울이 발행 직전 가드를 우회=무검증 채널'을 닫는다.
요구H-EPIC 합본 검증(본문+거울+추천 문장을 한 텍스트로 합쳐 detBad·scanQuality·comboFit를 적용)이 거울/추천 문장 안의 위반도 잡아냄을 박제. 본문만 깨끗하고 거울이 괴식이면 전체가 막혀야 한다.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
H-06-1적대거울 속 괴식 검출(합본) — verifyComposite({body:'식탁에 함께 앉아보세요', mirror:'미역국에 당근을 넣어 보세요', materials:[...]}).ok===false
H-06-2회귀본문만 검증하면 통과하던 것이 합본으로 차단(회귀=무검증 채널 닫힘) — detBad(body)=false인데 verifyComposite=false(거울 위반 때문)
H-06-3단위추천 문장의 모호기간 검출 — verifyComposite({body:OK, mirror:'요즘 채소가 비어요', materials}).violations에 vagueTime
H-06-4단위전부 깨끗 → ok(오탐방지) — 본문·거울·추천 모두 위반 없음 → verifyComposite.ok===true
H-06-5적대거울 null 안전 — verifyComposite({body:OK, mirror:null, materials}) → throw 0, 본문만 검증
H-06-6단위슬롯값(추천 식재료 토큰)도 재료 대조 통과 — materials에 든 식재료만 거울에 등장 시 outOfMaterials 위반 0
DoD
의존H-05 · H-EPIC(합본 검증 함수 export)

H-07 · 수렴 방지 14일 리플레이 — 추천 식재료 다양성·byte 동일 0테스트 테스트 · 1.25h

목적'재료까지 LLM 자유면 6통이 당근→미역국으로 수렴'을 막는 B의 효과를 시계열로 박제. 같은 입력 14일을 돌려 추천 식재료 다양성(고유 식재료 수)·인접 byte 동일 0·3일 무재사용을 리플레이 단위로 검증.
요구고정 가정(아린 패턴 모사 또는 합성 1가정)의 동일 입력 14일에 대해, Letter B 재료 선택 시계열이 (1)고유 추천 식재료 ≥N종 (2)인접일 동일 0 (3)3일 창 재사용 0 임을 한 가정에서 통째로 검증.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
H-07-1속성14일 고유 추천 식재료 ≥3종 — 비타민A채소 red 고정 14일 → new Set(추천).size>=3
H-07-2회귀인접일 동일 추천 0건(수렴 방지 핵심) — results[i]===results[i-1] 카운트 0 (i=1..13)
H-07-3속성3일 창 재사용 0건 — 모든 i에서 results[i]∉{results[i-1],results[i-2]}
H-07-4회귀리플레이 결정론(byte-동일 시계열) — 같은 가정 14일 두 번 실행 → 추천 배열 JSON.stringify 동일
H-07-5속성풀 소진 라운드로빈(4종을 4일 주기로 순회) — 4종 풀에서 14일 중 각 식재료 등장 횟수 max-min ≤2(편중 없음)
H-07-6단위다른 결핍군 가정도 수렴 0(일반화) — 기타채소 red 고정 14일도 인접 동일 0
DoD
의존H-03 · B-EPIC(재료 회전 함수)

H-08 · 아린 실데이터 회귀 — 6통 재현·수렴 0(real-arin fixture)테스트 테스트 · 1.5h

목적브리프 핵심 ①: 아린 실데이터(5/16~6/13 capture)로 Letter B 6통을 재현해 (1)괴식 0 (2)품질 위반 0 (3)수렴(블록/조합/도입/추천 식재료) 0임을 박제. 하이브리드가 v3 조립본을 6:0으로 이긴 실증의 회귀 잠금.
요구tests/fixtures/real-arin.json rows로 Letter B 파이프(decide-재료회전-comboFit-품질스캔-합본검증)를 LLM 0콜 결정론층으로 6~14일 리플레이해, 괴식 조합 0·품질 위반 0·comboRepeat7d 0·추천 식재료 인접 동일 0·도입 수렴 0을 단언.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
H-08-1회귀아린 6통 괴식 조합 0건 — 6통 추천 dish×ingredient 전부 comboFit.ok===true
H-08-2회귀아린 6통 추천 식재료 인접 동일 0(수렴 방지) — 6통 추천 식재료 배열 인접 중복 0
H-08-3속성아린 6통 3일 창 추천 재사용 0 — 모든 i에서 추천[i]∉{추천[i-1],추천[i-2]}
H-08-4회귀아린 거울·추천 문장 품질 위반 0 — 6통 각 buildMealMirror 출력 scanQuality.violations==[]
H-08-5적대P10: 급식(daycare)만 먹은 식재료는 liked 후보 아님 — daycare-only 식재료(예 두부구이 등 급식 메뉴)가 mirror의 '잘 먹는' 근거로 안 쓰임
H-08-6단위아린 fixture 무결성(rows·ingredients 존재)real-arin.json rows.length>0 && rows 일부에 ingredients 배열 존재
H-08-7단위6통 발행일 정렬(6/8~6/13)·빈 추천 0 — 발행일 6개·각 일자 추천 식재료 non-null
H-08-8회귀실데이터 결정론(재실행 동일) — 아린 리플레이 두 번 → 추천 시계열 동일
DoD
의존H-02 · H-03 · H-05 · H-09 · D-EPIC(liked 판정)

H-09 · Letter B 결정론층 리플레이 헬퍼(replayRunner 확장 runBFamily)코드 코드 · 1.5h

목적H-07·H-08·H-10이 공유할 Letter B 전용 다일 리플레이 헬퍼를 lib/replayRunner.ts에 추가. 기존 runV3FamilyFull(v3 조립식)과 별개로, Letter B의 결정론층(재료 회전→comboFit→코드 거울/추천 문장→품질 스캔 입력)만 LLM 0콜로 통주한다.
요구ReplayFamily(기존 타입 재사용) + Letter B 결정론 산출(추천 식재료·조합·거울·품질 위반)을 일자별로 반환하는 순수 헬퍼를 추가해, 테스트가 14일 시계열 불변식을 측정할 수 있게 한다.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
H-09-1단위runBFamily는 days 배열 반환(빈 가정도 throw 0) — runBFamily(저기록 가정) → {days:[]} 또는 부분 발행, throw 0
H-09-2속성LLM 0콜(순수) — runBFamily 호출에 callClaude/fetch 0 — 동기·결정론
H-09-3단위BReplayDay 형태 검증 — 각 day에 date·target·material·combo·mirror·quality 필드 존재
H-09-4회귀기존 runV3FamilyFull 무영향(회귀) — runV3FamilyFull(synthetic fam) 결과가 기존 replay.test.ts I-05 게이트 그대로 통과
H-09-5적대ingredients 없는 rows도 수용(synthetic CRow) — ingredients 미포함 rows 입력 → throw 0(빈 material 가능)
H-09-6회귀결정론: 동일 fam 두 번 = 동일 days — runBFamily 두 호출 결과 JSON 동일
DoD
의존H-02 · H-03 · H-05

H-10 · 30가정 통주 게이트 — Letter B 괴식 0·수렴 0·품질 0(bCutoverGate)테스트 테스트 · 1h

목적기존 I-05(v3 조립 30가정 게이트)의 Letter B 버전. synthetic-families.json 30가정×14일을 runBFamily로 통주해 가정별 괴식 0·인접 수렴 0·3일 재사용 0·품질 위반 0을 전수 게이트화(prebuild 편입).
요구30가정 각각 bReplayMetrics가 괴식 0·인접동일 0·3일재사용 0·품질위반 0을 만족(미달 가정 목록이 빈 배열)해야 통과. 기존 I-05 v3 게이트와 병존.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
H-10-1단위규모: 30가정·발행 300통+ — families.length===30 && 총 발행 days>300
H-10-2회귀가정별 괴식 0 전수 — perFam.flatMap(f=>bReplayMetrics(f.days).miscombo>0?[f.id]:[]) === []
H-10-3회귀가정별 인접 수렴 0 전수 — 모든 가정 adjacentSame===0
H-10-4속성가정별 3일 재사용 0 전수 — 모든 가정 materialRepeat3d===0
H-10-5회귀가정별 품질 위반 0 전수 — 모든 가정 qualityViolations===0
H-10-6속성추천 식재료 군 다양성 sanity — 전체 추천 식재료 고유 종 수 ≥8(한 군 고착 아님)
H-10-7회귀bCutoverGate 전체 통과(미달 목록 빈 배열) — perFam.flatMap(bCutoverGate) === []
DoD
의존H-09 · H-12

H-11 · merged vs v2 골든 — 은유·환각·기간·구체성 우열(coach-merged.test.ts)테스트 테스트 · 1.25h

목적브리프 ⑦: 하이브리드(merged=Letter B)가 v3 조립본을 6:0으로 이긴 실증을 결정론 채점으로 박제. 같은 입력에서 B 산출의 품질 점수(은유 억제·환각 0·기간 수치·구체성)가 v3 조립본 골든보다 우월함을 골든 비교로 검증.
요구고정 입력(아린 6/8~6/13)에서 (a)v3 조립본 골든(arin-golden-b.json에 박제) (b)Letter B 결정론 산출에 4축 채점기(metaphorCount·hallucinationCount·hasNumericPeriod·specificityScore)를 적용해, B가 v3보다 metaphor↓·hallucination=0·numericPeriod=true·specificity↑임을 단언.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
H-11-1회귀B 환각 0(재료밖+모호기간 위반 없음) — score(B).hallucination===0
H-11-2단위B 기간 수치 동반(numericPeriod) — score(B).numericPeriod===true
H-11-3회귀B 은유 ≤ v3 은유 — score(B).metaphor <= score(v3).metaphor
H-11-4회귀B 구체성 > v3 구체성 — score(B).specificity > score(v3).specificity
H-11-5회귀6통 종합 우열(B가 4축 합산 우세) — 6통 평균 score(B) 종합 > score(v3) — 모든 축에서 B 비열위
H-11-6적대채점 공정성: 동일 규칙 양쪽 적용(v3 부당 감점 0) — 동일 깨끗 문장을 v3·B 슬롯에 넣으면 동일 점수
H-11-7단위채점기 결정론(같은 입력 동일 점수) — score 두 호출 동일
H-11-8회귀v3 골든이 알려진 약점 반영(은유/환각 ≥1)arin-golden-b.json v3Assembled가 적어도 1축에서 B에 열위(6:0 실증 일관)
DoD
의존H-05 · H-12

H-12 · 골든 fixture — arin-golden-b.json(v3 조립본·B 기대치)테스트 테스트 · 0.75h

목적H-11·H-08·H-10이 공유할 아린 골든 기대치 fixture. 과거 v3 조립본 6통 텍스트(약점 박제)와 Letter B 기대 재료·조합 시계열을 한 파일에 고정한다.
요구아린 6/8~6/13에 대한 (a)v3Assembled 6통 텍스트(과거 조립본 캡처) (b)B 기대 추천 식재료·조합·거울 입력을 fixture로 박제해, 골든 회귀가 단일 진실에서 측정되게 한다.
명세
유즈케이스
테스트4개
ID종류케이스 · 검증(입력→기대)
H-12-1단위fixture 구조 무결성(dates·v3Assembled·bExpected 길이 6)arin-golden-b.json의 dates.length===6 && v3Assembled.length===6 && bExpected.length===6
H-12-2회귀bExpected는 runBFamily(real-arin)와 일치(결정론 골든) — runBFamily(real-arin) 6통 산출 === bExpected
H-12-3단위v3Assembled가 비어있지 않음(약점 박제 대상 존재) — 각 v3Assembled[i] 텍스트 길이>0
H-12-4단위날짜 정렬·중복 0 — dates 오름차순·고유
DoD
의존H-09 · real-arin fixture(기존)

H-13 · replayMetrics 확장 — bReplayMetrics·bCutoverGate(B축 지표)코드 코드 · 1h

목적기존 lib/replayMetrics.ts(v3 조립 지표)에 Letter B의 결정론 품질 지표(괴식건수·인접동일·3일재사용·품질위반)와 게이트를 additive로 추가한다. H-07/H-08/H-10의 측정 토대.
요구BReplayDay[]를 받아 miscombo·adjacentSame·materialRepeat3d·qualityViolations·materialDiversity를 산출하는 순수 bReplayMetrics와, 0 게이트를 적용하는 bCutoverGate(미달 문자열[])를 추가한다.
명세
유즈케이스
테스트10개
ID종류케이스 · 검증(입력→기대)
H-13-1단위miscombo 카운트(괴식 1건 입력→1) — combo.ok=false 1건 days → bReplayMetrics.miscombo===1
H-13-2단위adjacentSame 카운트 — material ['당근','당근','시금치'] → adjacentSame===1
H-13-3속성materialRepeat3d 3일 창 — ['당근','시금치','근대','당근'](4일) → 당근 3일창 재등장 1건
H-13-4단위qualityViolations 합산 — quality [['metaphor'],['list','vagueTime']] → qualityViolations===3
H-13-5단위materialDiversity 고유 종 — ['당근','시금치','당근'] → diversity===2
H-13-6회귀bCutoverGate 전부 0이면 빈 배열 — 무위반 days → bCutoverGate===[]
H-13-7단위bCutoverGate 미달 사유 문자열 반환 — miscombo 1건 → bCutoverGate에 '괴식'·'조합' 포함 문자열 1개+
H-13-8회귀기존 replayMetrics/cutoverGate 무영향(회귀) — 기존 replay.test.ts I-05가 그대로 그린
H-13-9적대빈 days 안전 — bReplayMetrics([]) → 전 지표 0·throw 0
H-13-10속성순수·LLM 0콜 — bReplayMetrics 동기 반환·외부 호출 0
DoD
의존없음

H-14 · A/B 대조군 불변식 — Letter A는 v2 그대로(무변경 회귀)테스트 테스트 · 0.75h

목적불변 원칙 ⑤(모든 개선은 Letter B에만·A 무변경)을 박제. compare 모드에서 Letter A 산출(planFor+composeLetter 경로)이 개선 A~I 도입 전후로 동일함을 회귀로 잠가, A/B 비교의 정당성을 보장.
요구동일 입력에서 Letter A 결정론 부분(planFor의 plan/scenario/signature)이 Letter B 코드 추가에 영향받지 않음을 검증하고, compare 저장 형태(context.altLetter에 B·메인 letter/oneliner=A)의 스키마 불변(스키마 변경 0)을 단언.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
H-14-1회귀Letter A planFor 결정론 불변 — planFor(고정 signals,seed,cidHash).plan.signature가 알려진 골든값과 동일
H-14-2단위compare 저장 형태: 메인=A·altLetter=B — compare ctx에서 letter===A.letter && context.altLetter.letter===B.letter
H-14-3회귀스키마 변경 0(context jsonb에만 altLetter) — altLetter는 context 하위 키 — coach_letters 컬럼 추가 0(타입/형태 단언)
H-14-4단위compare 모드 아닌 자녀는 altLetter 없음 — 비대상 자녀 ctx.context.altLetter===undefined
H-14-5적대A는 개선 A~I 적용 안 됨(comboFit·scanQuality 미경유) — Letter A 경로에 comboFit/scanQuality 호출 없음(B 전용 가드)
H-14-6단위아린(43942d34...)이 compare 대상 — v3Enabled/compare 게이트가 아린 child_id를 B 대상으로 인식
DoD
의존compare-EPIC(저장 형태) · H-02 · H-05

H-15 · prebuild 게이트 편입 — 신규 테스트 4파일 + 데이터 정합(I) 가드운영 운영 · 0.5h

목적불변 원칙 ①(머지 전 npm test 그린=prebuild 게이트). H-EPIC 신규 4파일(coach-materials·coach-quality·coach-merged·compare-replay)이 vitest 수집·prebuild에서 돌고, 개선 I(GROUP_INGREDIENTS 데이터 정합)도 빌드타임 가드로 박제됨을 확인.
요구npm test가 신규 4파일을 수집·실행하고 그린이며, package.json prebuild가 test를 게이트로 거는지 확인. 개선 I의 '대표 식재료=급식빈도 있는 식재료' 데이터 정합이 테스트로 잠긴다.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
H-15-1E2E신규 4파일 vitest 수집 — npm test 실행 시 coach-materials·coach-quality·coach-merged·compare-replay 4파일 테스트가 리포트에 등장
H-15-2회귀개선 I: GROUP_INGREDIENTS 대표 식재료 급식빈도 정합 — 비타민A채소 등 끼니군 각 대표 식재료가 popularDishesFor 비어있지 않거나 freq>0(단호박 freq 0은 정비 대상으로 플래그)
H-15-3회귀실측 빈도 핀: 당근>>단호박 — freqMap/kit-matrix 기준 당근 빈도 > 단호박(0)
H-15-4수동prebuild가 test를 게이트로 검package.json prebuild 또는 CI가 vitest 실패 시 빌드 차단(확인)
H-15-5E2E전체 테스트 그린·시간 예산 내 — npm test 0 fail·LLM 0콜로 수초 내 완료
H-15-6회귀기존 220개+I-05 게이트 무손상(회귀) — H-EPIC 추가 후 기존 7파일 테스트 전부 그대로 그린
DoD
의존H-02 · H-05 · H-08 · H-10 · H-11 · H-14 · I-EPIC(GROUP_INGREDIENTS 정비)

EPIC I — 데이터 정합성·근거 보강 (급식빈도 상위%·GROUP_INGREDIENTS 정비·괴식 조합 점검·근거 문구) D4

Letter B의 '재료 결정론'이 실측 근거 위에 서도록 데이터층을 정비한다: 식재료별 급식빈도+상위% 테이블을 산출하고, 단호박(0회) 같은 죽은 대표 식재료를 빈도 있는 것으로 교체하며, 괴식 조합(미역국×당근)을 kit-dish-matrix로 차단·검증하고, 식재료→영양역할·상위% 근거 문구를 결정론으로 생성한다.

📁 신규 web/scripts/build-ingredient-freq.py web/public/ingredient-freq.json web/lib/ingredientFreq.ts web/lib/comboGuard.ts web/lib/recoEvidence.ts web/tests/ingredient-freq.test.ts web/tests/group-ingredients.test.ts web/tests/combo-guard.test.ts web/tests/reco-evidence.test.ts · 수정 web/lib/coachRecos.ts · 문서 web/lib/coachRecos-data.audit.md

I-01 · 식재료별 급식빈도+상위% 테이블 산출 스크립트 build-ingredient-freq.py코드 코드 · 1.5h

목적엔진이 쓰는 freqMap(public/ingredient-recipes.json)은 '식재료→레시피명'(dish 단위)이라 '식재료 자체가 급식에 몇 번 나오나'(빈도 가중 랭킹의 ②급식빈도)를 직접 못 준다. 식재료 단위 등장 빈도와 그 상위 백분위(percentile)를 산출하는 별도 데이터를 만든다(C 에픽 4기준 랭킹·F 수치·D5 근거 문구의 공통 데이터 소스).
요구learned_menus(또는 ingredient-recipes 집계)에서 식재료별 등장 빈도를 집계하고, 전체 식재료 분포 대비 상위 백분위(상위 N%)를 계산해 public/ingredient-freq.json으로 출력한다. 산출 식재료명은 도감 표준명(ingredients-light.json)과 100% 일치해야 한다.
명세
유즈케이스
테스트11개
ID종류케이스 · 검증(입력→기대)
I-01-1단위산출 JSON 키 전부 도감 표준명public/ingredient-freq.json의 모든 키 ∈ ingredients-light.json nm 집합 → 차집합 길이 0
I-01-2단위당근 상위% < 근대 상위% — freq[당근].topPct < freq[근대].topPct (당근이 더 흔함). 실측 당근~2%·근대~39% 방향 일치
I-01-3단위단호박 0회 → 미수록 또는 freq 0 — freq[단호박]가 없거나 freq[단호박].freq===0 (브리프 단호박 급식 0회)
I-01-4단위요거트 0회 → 미수록 또는 freq 0 — freq[요거트]가 없거나 freq===0
I-01-5단위치즈 freq>0 · topPct 산출 — freq[치즈].freq>0 이고 0<topPct<=100 (브리프 치즈18·상위27%)
I-01-6단위rank 단조성 — freq 내림차순으로 rank가 1,2,3… 단조 증가, freq 큰 식재료 rank가 더 작다
I-01-7속성topPct 범위 — 모든 식재료 0 < topPct <= 100
I-01-8적대양념 제외 — freq에 '마늘'·'소금'·'간장' 등 SEASONING 키 0개
I-01-9통합폴백 경로 동작 — learned_menus 소스 부재(mock 빈 결과) → ingredient-recipes 합산 폴백으로 비어있지 않은 JSON 산출
I-01-10단위빈도 합산 정합 — 폴백 경로에서 식재료 freq = 그 식재료의 ingredient-recipes 레시피 freq 합과 일치(±0)
I-01-11단위중복 키 없음 — 산출 JSON 키 집합에 중복 0(Object 키라 자명하나 norm 충돌로 덮어쓰기 미발생 확인)
DoD
의존외부:learned_menus 또는 public/ingredient-recipes.json 존재

I-02 · 식재료 freq 로더 lib/ingredientFreq.ts (순수 함수·topPct 조회)코드 코드 · 0.5h

목적I-01이 만든 ingredient-freq.json을 엔진이 안전하게 읽도록 순수 로더를 둔다. C 에픽 랭킹과 D5 근거 문구가 식재료의 급식빈도·상위%를 동일 API로 쓰게 한다(죽은 코드 방지).
요구식재료명을 받아 { freq, rank, topPct } 또는 미수록 시 null을 반환하는 순수 함수와, 상위% 임계(예: 상위20% 이내) 판정 헬퍼를 제공한다. fs/HTTP 불사용(빌드타임 import).
명세
유즈케이스
테스트9개
ID종류케이스 · 검증(입력→기대)
I-02-1단위수록 식재료 조회 — freqOf('당근') !== null 이고 freq>0
I-02-2단위미수록 0회 식재료 null — freqOf('단호박') === null 그리고 freqOf('요거트') === null
I-02-3적대topPctOf 0회 null — topPctOf('단호박') === null (0회를 100%로 위장하지 않음)
I-02-4단위isCommon 임계 경계 — isCommon('당근',20)===true, isCommon('근대',20)===false, isCommon('근대',40)===true
I-02-5적대isCommon은 freq 0 배제 — isCommon('단호박', 100)===false (미수록은 흔함 아님)
I-02-6적대존재하지 않는 임의 문자열 — freqOf('존재안함xyz')===null, topPctOf 동일
I-02-7적대빈 문자열·공백 — freqOf('')===null, freqOf(' ')===null
I-02-8단위순수성(부수효과 없음) — 동일 입력 2회 호출 결과 동일·전역상태 불변
I-02-9속성rank·topPct 동행 검증 — freqOf(a).rank < freqOf(b).rank ⇒ topPctOf(a) <= topPctOf(b)
DoD
의존I-01

I-03 · GROUP_INGREDIENTS 정비 — 빈도 있는 대표 식재료만 (단호박 강등·계란 중복 제거)코드 코드 · 1h

목적인계서 I[데이터 정합성]: GROUP_INGREDIENTS 대표가 급식빈도 없는 식재료(단호박 0회)를 선두에 두면, C 에픽 빈도 가중 랭킹에서 그 식재료가 탈락하거나 0근거 추천이 된다. 대표 목록을 빈도 있는 식재료 중심으로 재정비한다.
요구lib/coachRecos.ts의 GROUP_INGREDIENTS 각 그룹 대표 목록을, ingredient-freq 기준 빈도가 있는 식재료를 우선 배치하도록 정비한다(0회 식재료는 후순위 또는 제외). 단 도감/영양 커버리지를 깨지 않도록 보수적으로(영양상 중요하나 급식빈도 0인 식재료는 제거 대신 후순위).
명세
유즈케이스
테스트10개
ID종류케이스 · 검증(입력→기대)
I-03-1단위비타민A채소 선두는 빈도 있는 식재료 — GROUP_INGREDIENTS['비타민A채소'][0]는 freqOf!==null (단호박이 선두 아님)
I-03-2단위단호박 제거 아닌 후순위 — '단호박' ∈ GROUP_INGREDIENTS['비타민A채소'] (영양 유지) 이고 인덱스 > 당근/시금치 인덱스
I-03-3단위계란 중복 제거 — GROUP_INGREDIENTS['고기·계란']에 '달걀'은 있고 '계란'은 없다(또는 별칭 정규화로 1개만)
I-03-4단위모든 그룹 비어있지 않음 — GROUP_INGREDIENTS 7개 그룹 각 length>=1
I-03-5단위모든 대표 도감 표준명 — 전 그룹 대표 식재료 ∈ ingredients-light.json nm (오타·비표준 0)
I-03-6단위각 그룹 최소 1개는 freq 있음 — 각 그룹에 freqOf!==null인 대표가 >=1개(전부 0회 그룹 없음)
I-03-7회귀영양 필수 식재료 보존 회귀 — '브로콜리'(기타채소)·'검은콩'(콩류)·'연어'(생선)·'멸치' 정비 후에도 존재(영양 커버리지 미파괴)
I-03-8회귀STAPLE 곡물 보존 — '현미'·'귀리'·'잡곡'(곡물) STAPLE_FORMS 대상이라 정비 후에도 존재
I-03-9회귀weeklyExposureTarget 회귀 — 정비 후 challenge 비지 않음 — weeklyExposureTarget(비타민A채소 red 신호, liked=[당근]) → challenge에 시금치/근대 남아 null 아님
I-03-10단위중복 식재료 없음 — 각 그룹 내 식재료 배열에 중복 0(Set 크기===length)
DoD
의존I-02

I-04 · 괴식 조합 점검·차단 lib/comboGuard.ts (kit-dish-matrix + food-graph)코드 코드 · 1.5h

목적인계서 A[최우선·괴식]: '잘먹는음식+결핍식재료' 조합을 점수화해 낮으면 금지. 실증 괴식='미역국에 당근'(kit-dish-matrix 미역국×당근 score=1·cells=2). LLM이 조합 지어내게 두지 않도록, 검증 통과 조합만 LLM 후보로 넘기는 순수 게이트를 만든다.
요구(음식, 식재료) 또는 (식재료, 식재료) 조합의 정합 점수를 kit-dish-matrix scores(0~3)와 food-graph pair로 산출하고, 임계 미만이면 금지(false) 반환하는 순수 함수를 제공한다. 미역국×당근은 금지, 볶음밥·카레×당근은 허용이 되어야 한다.
명세
유즈케이스
테스트13개
ID종류케이스 · 검증(입력→기대)
I-04-1적대괴식 미역국×당근 차단 — dishIngredientFit('미역국','당근').ok === false (score 1 < 2)
I-04-2단위볶음밥×당근 허용 — dishIngredientFit('볶음밥','당근').ok === true (score 3)
I-04-3단위카레×당근 허용 — dishIngredientFit('카레','당근').ok === true (score 3)
I-04-4단위비빔밥·덮밥×당근 허용 — dishIngredientFit('비빔밥','당근').ok && dishIngredientFit('덮밥','당근').ok
I-04-5적대미수록 조합 금지 — dishIngredientFit('미역국','연어')처럼 scores 미수록 → ok===false (undefined를 통과로 위장 금지)
I-04-6단위임계 경계 score=2 허용 — score===2인 조합(예 국×당근=2) ok===true, score===1은 false
I-04-7단위validCombos 화이트리스트 — validCombos(['미역국','볶음밥','카레'],['당근']) → [{볶음밥,당근},{카레,당근}] (미역국 제외)
I-04-8단위ingredientPairFit 테이블 근거만 — ingredientPairFit('당근','두부').ok===true (food-graph pair), ingredientPairFit('당근','존재안함').ok===false
I-04-9속성ingredientPairFit 비대칭 무관(무방향) — ingredientPairFit('당근','두부').ok === ingredientPairFit('두부','당근').ok (food-graph 무방향)
I-04-10적대dish를 ingredientPairFit에 넣으면 금지 — ingredientPairFit('볶음밥','당근').ok===false (볶음밥은 food-graph 노드 아님 — 경계 강제)
I-04-11적대빈/공백 입력 — dishIngredientFit('','당근').ok===false, dishIngredientFit('미역국','').ok===false
I-04-12단위validCombos 빈 입력 — validCombos([],[]) === [], validCombos(['미역국'],['당근'])===[]
I-04-13회귀전 dishes×당근 괴식 스냅샷 회귀 — score<2인 dish(미역국1·빵토스트1·쌈1·요거트간식1·김치찌개1)는 모두 ok===false로 일관
DoD
의존없음

I-05 · food-graph pair 정합 점검 — dish+당근은 pair 아닌 kit-matrix 사실 문서화·보강 검토코드 코드 · 0.75h

목적인계서 A: '짜파게티/볶음밥/카레+당근 pair 존재 확인·없으면 보강'. 실측 점검 결과 짜파게티·볶음밥·카레는 food-graph 노드가 아니라(식재료 그래프) dish+ingredient 적합도는 kit-dish-matrix에만 있다. 이 정합을 코드·문서로 못 박아 '엉뚱한 보강'(dish를 graph에 억지 삽입)을 막는다.
요구food-graph는 ingredient×ingredient(pair/bridge)만 표현함을 회귀 테스트로 고정하고, dish+ingredient 정합은 kit-dish-matrix 경로(I-04)로 처리됨을 검증한다. 당근의 핵심 pair(두부·달걀·감자 등)가 실제로 존재하는지 확인하고, 결핍 시 보강 후보를 audit.md에 기록한다.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
I-05-1단위당근 핵심 pair 존재 — neighborsOf('당근')에서 kind==='pair' nm 집합 ⊇ {두부,달걀,감자} (잘 먹는 것 곁들임 근거)
I-05-2회귀dish는 graph 노드 아님food-graph.json nodes에 '짜파게티'·'볶음밥'·'카레' 0개(식재료 그래프 경계 고정)
I-05-3통합dish+당근은 kit-matrix로 커버 — comboGuard.dishIngredientFit('볶음밥','당근').ok && ('카레','당근').ok (graph 부재를 kit-matrix가 보완)
I-05-4회귀graph 엣지 카운트 드리프트 감지 — edges 중 kind==='pair' 362±0, 'bridge' 187±0, 합 549 (데이터 변동 시 의도 확인)
I-05-5회귀노드 수 고정food-graph.json nodes.length === 198 (도감 동기화 의도 확인)
I-05-6적대미역 pair에 당근 없음 — neighborsOf('미역')의 pair nm에 '당근' 미포함(미역국 당근 괴식과 정합 — pair로 곁들임 추천 안 됨)
I-05-7속성무방향 대칭 — neighborsOf('당근')에 '두부' pair 있으면 neighborsOf('두부')에 '당근' pair 존재
I-05-8단위bridge≠pair 분리 — neighborsOf('당근')에서 비트·파스닙은 bridge, 두부·감자는 pair (kind 혼동 없음)
DoD
의존I-04

I-06 · 근거 문구 생성 규칙 lib/recoEvidence.ts (식재료→영양역할+급식 상위%)코드 코드 · 1h

목적인계서 C[이사님 핵심]: 근거문구를 LLM 재료로('당근=급식 상위2%·베타카로틴이 눈·면역'). 식재료를 받아 ①영양 역할(NUTRIENT_FOODS/NUTRI_MAP 기반) ②급식 상위%(I-02) 두 사실을 결합한 결정론 근거 문구를 생성한다. LLM은 이 문구를 재료로 받아 작문만 한다(사실 코드 못박기).
요구식재료명을 받아 '급식 상위 N%·{영양역할}' 형태의 검증가능한 근거 문구를 결정론으로 생성한다. 급식빈도 0(단호박)이면 상위% 문구를 생략하고, 영양 역할만 또는 제철 근거로 대체한다(0회를 '상위 100%'로 위장 금지). 영양 역할은 NUTRIENT_FOODS 역인덱스(식재료가 어떤 영양소의 대표인가)로 산출.
명세
유즈케이스
테스트12개
ID종류케이스 · 검증(입력→기대)
I-06-1단위당근 근거 문구 — evidenceFor('당근').nutrients에 '비타민A' 포함, text에 '상위' 그리고 '비타민A' 포함
I-06-2적대0회 식재료 상위% 위장 금지 — evidenceFor('단호박').freqPct===null 이고 text에 '상위 100%'·'상위' 절 미포함
I-06-3단위멸치 칼슘 역할 — evidenceFor('멸치').nutrients에 '칼슘' 포함(NUTRIENT_FOODS 칼슘 대표)
I-06-4단위연어 오메가3·비타민D — evidenceFor('연어').nutrients ⊇ {비타민D 또는 오메가3} (NUTRIENT_FOODS 매핑)
I-06-5적대영양·빈도 모두 없는 식재료 빈 text — NUTRIENT_FOODS 미등장 & freq 없는 식재료 → text===''(허위 근거 0)
I-06-6단위역할 라벨 매핑 — 비타민A→'눈' 또는 '면역', 칼슘→'뼈' 라벨이 text에 반영
I-06-7적대text는 재료 밖 음식명 미포함 — evidenceFor('당근').text에 임의 dish명(미역국 등) 미포함(사실만·G 검증 대비)
I-06-8단위순수성 — 동일 입력 2회 호출 동일 출력·전역 불변
I-06-9단위freqPct null이면 nutrients만으로 text 구성 — evidenceFor('브로콜리')(freq 0이나 NUTRIENT_FOODS 등장) → text 비어있지 않고 '상위' 절 없음
I-06-10단위중복 영양소 제거 — 한 식재료가 여러 영양소 대표여도 nutrients 배열 중복 0(Set)
I-06-11적대빈/공백 입력 — evidenceFor('').text==='', evidenceFor(' ').text===''
I-06-12통합수치 정합(I-02 일치) — evidenceFor('당근').freqPct === topPctOf('당근') (단일 진실원)
DoD
의존I-02

I-07 · freqMap·ingredient-freq 실배선(coachRecos가 죽은 코드 안 되게) — 데이터 정합 통합 테스트테스트 테스트 · 0.75h

목적인계서 C: freqMap이 route.ts에서 로드만 되고 본경로(pickFoodReco) 미전달=죽은 코드. I 에픽의 데이터(ingredient-freq·정비된 GROUP_INGREDIENTS·comboGuard·recoEvidence)가 실제 함수 시그니처로 흘러가는지 통합 검증으로 못 박는다(C 에픽이 배선을 구현, 여기선 데이터층 계약 테스트).
요구I-01~I-06 산출물이 서로 정합함을 통합 테스트로 검증한다: GROUP_INGREDIENTS 대표가 ingredient-freq/recoEvidence/comboGuard와 모순 없이 동작하고, freqMap(public/ingredient-recipes.json)과 ingredient-freq.json이 동일 식재료에 대해 일관된 방향을 가진다.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
I-07-1통합고아 식재료 0 — GROUP_INGREDIENTS 전 대표가 ingredients-light/food-graph/NUTRI_MAP 중 1+에 등장 → 미등장 0개
I-07-2통합빈도 대표 근거 일관 — 비타민A채소에서 freqOf!==null인 대표(당근) → evidenceFor(당근).text에 '상위' 포함
I-07-3통합추천 빈손 0 — weeklyExposureTarget→challenge 식재료별 popularDishesFor 결과 length>=1 또는 STAPLE_FORMS 해당
I-07-4적대추천 파이프 괴식 0 — 비타민A채소 타깃에서 validCombos(추천dishes, ['당근'])에 미역국 등 score<2 dish 0개
I-07-5회귀freqMap 소스 경로 고정app/api/cron/coach/route.ts가 '/ingredient-recipes.json' 문자열 포함(데이터 소스 경로 의도 고정)
I-07-6통합ingredient-freq vs ingredient-recipes 방향 일관 — 두 소스 모두 있는 식재료(당근·치즈)에서 ingredient-freq topPct 작은 식재료가 ingredient-recipes freq도 큰 경향(역전 0건은 아니어도 당근>근대 일관)
I-07-7적대단호박 회전 시 환각 방지 — target=비타민A채소·seed가 단호박을 가리킬 때라도 evidenceFor(단호박).text에 '상위' 절 0(허위 빈도 미생성)
I-07-8회귀정비 전후 A 대조군 불변 — Letter A 경로(기존 pickFoodReco)는 정비된 GROUP_INGREDIENTS로도 동일 시드·동일 입력에서 결정론 출력 유지(A 무변경 원칙 — 단 상수 변경이 A 출력을 바꾸면 별도 검토 플래그)
DoD
의존I-01 · I-02 · I-03 · I-04 · I-06

EPIC J — 컷오버·운영·롤백·모니터·문서 (A/B 2통 하이브리드) D5

아린 A/B 2통(A=v2 대조군·B=하이브리드 처치군)의 승자 판정→전 자녀 승격 절차와 즉시 롤백(env 제거)을 정의하고, 자가진단 크론·cron_runs.issues에 품질 위반·괴식 차단·2통 LLM 비용 지표를 추가해 모니터링하며, 문서(plan-engine·scenarios·WBS 상태칩)를 현행화하고 엣지 복리 규칙을 운영 규약으로 못 박는다.

📁 신규 web/lib/coachCompare.ts web/tests/coach-compare.test.ts · 수정 web/app/api/cron/coach/route.ts web/app/api/cron/coach-selfcheck/route.ts web/app/admin/cron/page.tsx web/app/admin/[childId]/page.tsx web/lib/coachDaily.ts · 문서 coaching-plan-engine.html coaching-scenarios.html coaching-v3-build-plan.html web/docs/coach-compare-runbook.md

J-01 · compare 모드 env 게이트 — compareEnabled + 즉시 롤백 계약코드 코드 · 0.5h

목적아린에게만 A/B 2통을 켜고, env 제거 한 번으로 A만 남게(즉시 롤백) 하는 단일 진입 게이트를 결정론 순수 함수로 둔다. v3Enabled가 'v3 순수 조립'을 켜는 것과 분리해, compare 모드는 v3 조립을 대체(아린은 더이상 v3 블록 편지를 발행하지 않고 A=v2·B=하이브리드).
요구COACH_COMPARE_CHILDREN(또는 기존 COACH_V3_CHILDREN 재활용)에 자녀 id가 있으면 compare=true. compare=true면 그 자녀는 v3 순수 조립 경로를 타지 않는다. env 미설정/빈값=false(=A만, 기존 v2 단통 발행).
명세
유즈케이스
테스트9개
ID종류케이스 · 검증(입력→기대)
J-01-1단위빈 env=false — compareEnabled({},'cidA')→false
J-01-2단위화이트리스트 매칭 — compareEnabled({COACH_COMPARE_CHILDREN:'cidA,cidB'},'cidB')→true
J-01-3단위화이트리스트 비매칭 — compareEnabled({COACH_COMPARE_CHILDREN:'cidA'},'cidZ')→false
J-01-4단위전체 ON — compareEnabled({COACH_COMPARE:'1'},'아무거나')→true
J-01-5단위공백 트림 — compareEnabled({COACH_COMPARE_CHILDREN:' cidA , cidB '},'cidA')→true
J-01-6단위빈 토큰 무시 — compareEnabled({COACH_COMPARE_CHILDREN:',,'} ,'cidA')→false(filter(Boolean)로 빈 토큰 제거)
J-01-7적대COACH_COMPARE 비'1'값은 무시 — compareEnabled({COACH_COMPARE:'true'},'cidA')→false(엄격히 '1'만 전체 ON)
J-01-8회귀롤백=env제거 — COACH_COMPARE_CHILDREN 키 자체 부재({})→false(undefined.split 폭발 없이 ||'' 가드)
J-01-9적대오탐방지: childId 부분문자열 — compareEnabled({COACH_COMPARE_CHILDREN:'cidABCD'},'cidAB')→false(includes는 배열 요소 정확일치, 부분문자열 매칭 아님)
DoD
의존env(COACH_COMPARE_CHILDREN 설정)

J-02 · 승자 판정 기준 함수 — judgeWinner(피드백 N일·선호율 임계)코드 코드 · 1h

목적아린 A/B에서 어떤 편지가 이겼는지를 letter_feedback(rating up/down/repeat) 집계로 판정하는 결정론 함수. '며칠·선호율 임계'를 코드로 못 박아 '느낌'이 아니라 데이터로 승격 결정. 사상서: B가 피드백에서 이기면 전 자녀 승격.
요구A·B 각각의 up/down/repeat 카운트를 입력받아 verdict('B-win'|'A-win'|'inconclusive'|'insufficient')와 근거 수치를 반환. 표본(피드백 총수)이 임계 미만이면 'insufficient'. 선호율(up-down-repeat의 순점수 또는 up비율) 차이가 임계 미만이면 'inconclusive'.
명세
유즈케이스
테스트12개
ID종류케이스 · 검증(입력→기대)
J-02-1단위B 명확 승 — judgeWinner({up:1,down:2,repeat:1},{up:6,down:0,repeat:0})→verdict 'B-win'(scoreB=6,scoreA=-2,margin=8)
J-02-2단위A 명확 승 — judgeWinner({up:6,down:0,repeat:0},{up:0,down:3,repeat:2})→verdict 'A-win'
J-02-3단위표본 부족 — judgeWinner({up:1,down:0,repeat:0},{up:1,down:0,repeat:0})→verdict 'insufficient'(총 2건<minDays 7)
J-02-4단위마진 부족 — judgeWinner({up:4,down:0,repeat:0},{up:5,down:0,repeat:0})→verdict 'inconclusive'(margin 1<2)
J-02-5단위repeat 감점 반영 — judgeWinner({up:5,down:0,repeat:0},{up:5,down:0,repeat:4})→scoreB=1·verdict 'A-win'(repeat 4 감점으로 B가 짐)
J-02-6단위down 감점 반영 — judgeWinner({up:5,down:4,repeat:0},{up:5,down:0,repeat:0})→verdict 'B-win'
J-02-7단위커스텀 임계 — judgeWinner({up:0,down:0,repeat:0},{up:3,down:0,repeat:0},{minDays:3,minMargin:3})→verdict 'B-win'(총3>=3·margin3>=3)
J-02-8적대전부 0 — judgeWinner({up:0,down:0,repeat:0},{up:0,down:0,repeat:0})→verdict 'insufficient'(0건)
J-02-9단위reason 수치 포함 — judgeWinner({up:1,down:0,repeat:0},{up:8,down:0,repeat:0}).reason에 'B' 및 점수 숫자 문자열 포함
J-02-10단위음수 점수 동률 inconclusive — judgeWinner({up:0,down:4,repeat:0},{up:0,down:4,repeat:0})→총8>=7이나 margin0→'inconclusive'
J-02-11단위경계: 정확히 minMargin — judgeWinner({up:0,down:0,repeat:0},{up:7,down:5,repeat:0})→scoreB=2·margin=2(>=2)→'B-win'(>= 경계 포함)
J-02-12단위경계: 정확히 minDays — 총 피드백이 정확히 7건이고 margin 충분→'insufficient' 아님(>= 7 충족)
DoD
의존J-01

J-03 · 전 자녀 승격 절차 — promoteToProduction 런북 + 코드 컷오버 경로운영 운영 · 1h

목적B가 이기면(judgeWinner 'B-win') 하이브리드를 전 자녀 기본 발행 경로로 올리는 절차를 못 박는다. merged(B)를 메인 letter로 승격. 컷오버는 SQL/데이터 파괴 없이 env+코드 분기로만(점심 사고 교훈: 무검증 컷오버 금지).
요구승격 시 (1) 신규 자녀는 메인 letter=하이브리드(B 설계) 발행, (2) 아린은 A/B 2통 유지(대조군 추적 계속하거나 종료 선택), (3) 비용·품질 회귀 없는지 컷오버 직후 모니터. 절차는 문서+코드 분기 두 곳에 기록.
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
J-03-1단위하이브리드 단통 게이트 — hybridEnabled({COACH_HYBRID_CHILDREN:'cidA'},'cidA')→true·메인 letter=하이브리드(altLetter 없음)
J-03-2회귀미설정 시 v2 유지 — compare·hybrid 둘 다 false인 자녀→기존 v2 단통 발행(회귀 없음)
J-03-3통합compare와 hybrid 동시 시 우선순위 — 한 자녀가 양 화이트리스트에 동시 포함→compare(2통) 우선(아린 대조군 보호)·문서 명시대로 동작
J-03-4수동런북 SQL 없음 검증 — runbook 컷오버 절차에 SQL/DDL 단계 0개·env+코드 분기만(점심 사고 교훈 반영) 확인
J-03-5통합점진 확대 자녀 한정 — COACH_HYBRID_CHILDREN='cidA'면 cidB는 여전히 v2(전체 컷오버 전 격리)
DoD
의존J-01 · J-02

J-04 · 즉시 롤백 — env 제거 경로 + 폴백 안전망 회귀 테스트운영 운영 · 0.5h

목적B/하이브리드가 사고를 내면 한 동작(env 제거)으로 즉시 A(v2)로 되돌아가게 보장. v3Enabled가 이미 '미설정=기존 경로'인 패턴(coachDaily.ts line 20 주석 '즉시 롤백=env 끄기')을 compare/hybrid에도 동일 적용. 어떤 실패도 throw 없이 v2 폴백.
요구COACH_COMPARE_CHILDREN/COACH_HYBRID* env 제거→다음 크론부터 100% A(v2)만. 또한 B 생성 도중 예외 발생 시 A는 정상 발행돼야 함(B 실패가 A를 망치지 않음). route.ts의 v3 경로처럼 B 생성은 try/catch로 감싸 throw 없이 폴백.
명세
유즈케이스
테스트5개
ID종류케이스 · 검증(입력→기대)
J-04-1회귀env 제거=2통 미진입 — compareEnabled false면 altLetter 생성 코드 미실행·coach_letters.context.altLetter 부재
J-04-2적대B 실패 시 A 보존 — B 생성 함수가 throw→catch로 잡혀 A letter는 정상 upsert·errors 미증가(B만 issues 기록)
J-04-3단위B 실패 issues 기록 — B 예외 시 issues 배열에 'compare B 생성 실패' 포함 문자열 1건 push
J-04-4회귀롤백 후 v2 byte 동일 — compare off 상태 발행 = compare 도입 전 v2 발행과 동일 경로(planFor+composeLetter, 메인 letter 무변경)
J-04-5통합폴백 시 알림톡 A 기준 — B 실패해도 sendCoachLetterPreview는 A oneliner로 발송(부모 알림 정상)
DoD
의존J-01 · J-03

J-05 · 2통 A/B 발행·altLetter 저장 + 자가 비교 지표 cron_runs.meta 적재코드 코드 · 1h

목적compare 자녀에 대해 A(v2 무변경)·B(하이브리드) 두 편지를 같은 트랜잭션에서 만들고, 메인 letter=A·context.altLetter={letter,oneliner,design,mirror,materials,verify...}로 저장. 발행 시점에 A↔B 자가 유사도·B 검증결과를 cron_runs.meta에 적재해 비교 모니터 기반을 만든다.
요구compare=true 자녀: A=planFor+composeLetter(기존 그대로)로 letter/oneliner 생성, B=하이브리드 설계(개선 A~I)로 별도 생성. coach_letters upsert 시 letter=A·oneliner=A·context.altLetter=B 페이로드. 스키마 변경 0(context jsonb에 적재). cron_runs.meta.compare에 {abSim, bVerifyFail, bGrostBlocked, bRegen} 누적.
명세
유즈케이스
테스트8개
ID종류케이스 · 검증(입력→기대)
J-05-1단위altLetter 저장 구조 — compare 자녀 finalCtx.altLetter에 letter·oneliner·design·mirror 키 존재
J-05-2단위메인 letter=A — compare 발행 시 coach_letters.letter===A(v2 composeLetter 출력)·B는 letter 컬럼에 안 들어감
J-05-3회귀비compare 자녀 altLetter 부재 — compare=false 자녀 context에 altLetter 키 없음(2통 미생성)
J-05-4단위abSim 누적 — abSim 0.2·0.4 두 자녀→meta.compare.abSimSum=0.6·Count=2(평균 0.3 계산 가능)
J-05-5단위B 검증실패 카운트 — B verify.ok=false 1건→meta.compare.bVerifyFail=1
J-05-6회귀기존 meta 키 보존 — compare 추가 후에도 meta.letters·meta.issues·meta.topReds 기존 키 정상 적재(어드민 cron 회귀 없음)
J-05-7통합스키마 변경 0 — altLetter는 context jsonb 내부 — coach_letters 컬럼 추가 DDL 없음
J-05-8단위B 괴식차단 카운트 — B 생성서 괴식조합 차단 신호 수신→meta.compare.bGhostBlocked 증가
DoD
의존J-01 · J-04

J-06 · 자가진단 크론에 품질 지표 추가 — coach-selfcheck B 품질·괴식·폴백 감시운영 운영 · 1h

목적coach-selfcheck route.ts(현재 반복점수·oneliner중복·거울누락·피드백만 봄)에 B 전용 품질축을 추가해, 'B가 v2처럼 좋은가'를 매일 결정론으로 자가 점검. 인계서 근본원인 ①(검증이 품질축 0줄)을 운영 모니터 레벨에서도 닫는다.
요구coach-selfcheck가 compare 자녀의 context.altLetter를 읽어 (1) A↔B 유사도(설계 수렴 감지) (2) B의 검증위반/괴식차단/폴백 누적(cron_runs.meta.compare 또는 context에서) (3) A/B 피드백 비교(judgeWinner)를 cron_runs.meta.alerts에 한국어로 기록. 결정론·LLM 0콜 유지(현 라우트 계약).
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
J-06-1단위A/B 수렴 경보 — altLetter.letter가 main letter와 유사도 0.5→flags에 'A/B 수렴 50%' 포함
J-06-2단위수렴 임계 미만 무경보 — abSim 0.2→A/B 수렴 flag 없음(오탐 방지)
J-06-3적대altLetter 부재 graceful — compare 아닌 자녀(altLetter 없음)→selfcheck 예외 없이 기존 4지표만 점검
J-06-4단위B 검증위반 경보 — altLetter.verify.ok=false→flags에 'B 검증위반' 포함
J-06-5회귀LLM 0콜 유지 — selfcheck에 ANTHROPIC 호출 0(결정론 계약 — callClaude import 없음)
J-06-6통합compareSummary 적재 — compare 자녀 1명→cron_runs.meta.compareSummary에 verdict 1건
J-06-7회귀기존 4지표 회귀 없음 — 반복점수·oneliner중복·거울누락·피드백 alerts 로직 무변경(기존 alerts 정상)
DoD
의존J-02 · J-05

J-07 · 어드민 모니터 UI — cron 보고서 A/B 패널 + childId A/B 나란히 비교UI UI · 1h

목적이사가 한눈에 A/B를 비교·판정하도록 어드민에 2통 비교 패널을 추가. cron 보고서엔 비용·수렴·품질 지표, 자녀 상세엔 A·B 편지 나란히 + judgeWinner verdict + 피드백 비교. '사람 제보'가 아니라 화면에서 승자 판단.
요구app/admin/cron/page.tsx: meta.compare(abSim 평균·bVerifyFail·bGhostBlocked·LLM콜수)·비용 지표를 Stat로 노출. app/admin/[childId]/page.tsx: compare 자녀면 A·B 편지 본문을 좌우/상하로 나란히 + 디자인 라벨 + judgeWinner verdict + A/B 피드백 카운트.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
J-07-1단위cron 패널 조건부 렌더 — meta.compare 부재→A/B Stat 미표시(기존 화면 회귀 없음)
J-07-2수동cron 패널 값 표시 — meta.compare.abSimAvg=0.19→'A/B유사도 0.19' Stat 노출
J-07-3수동childId A/B 나란히 — compare 자녀 상세에 A·B 편지 두 박스·design 배지 표시
J-07-4수동verdict 배지 — 피드백 충분 시 'B-win' 배지·부족 시 'insufficient' 배지 표시
J-07-5회귀비compare 자녀 단통 — altLetter 없는 자녀 상세는 기존처럼 편지 1개만(B 박스 없음)
J-07-6회귀관리자 게이트 유지 — isAdmin 아니면 두 페이지 모두 기존대로 차단(권한 회귀 없음)
DoD
의존J-02 · J-05

J-08 · 2통 LLM 비용 모니터 — costEstimate + cron_runs.meta 비용 적재운영 코드 · 0.5h

목적compare 자녀는 A(v2)+B(하이브리드)로 LLM 콜이 ~2배. 비용 폭주를 조기 탐지하려 모델별 콜 수를 집계해 cron_runs.meta.cost에 적재하고, 비용 추정 순수 함수로 일일 ₩ 환산. callClaude가 Haiku 기본·intro/recap만 Sonnet(coach.ts line 644·COACH_INTRO_MODEL) 패턴 기반.
요구크론 1회 실행의 (haikuCalls, sonnetCalls)를 누적하고 costEstimate(calls)로 추정 비용을 산출. compare 자녀는 A·B 둘 다 카운트(2배 가시화). meta.cost={haikuCalls,sonnetCalls,estKRW,compareExtraCalls}.
명세
유즈케이스
테스트7개
ID종류케이스 · 검증(입력→기대)
J-08-1단위비용 추정 단조 — costEstimate({haikuCalls:10,sonnetCalls:0}) < costEstimate({haikuCalls:10,sonnetCalls:5})(Sonnet가 더 비쌈)
J-08-2단위0콜=0원 — costEstimate({haikuCalls:0,sonnetCalls:0})→0
J-08-3단위compare 2배 가시화 — compare 자녀 1명 B 1콜→meta.cost.compareExtraCalls=1
J-08-4단위커스텀 단가 — costEstimate({haikuCalls:1,sonnetCalls:0},{haikuKRW:10,sonnetKRW:100})→10
J-08-5단위Sonnet 가중 — costEstimate({haikuCalls:0,sonnetCalls:1},{haikuKRW:10,sonnetKRW:100})→100
J-08-6통합meta.cost 적재 — 크론 1실행→cron_runs.meta.cost.estKRW 숫자 적재
J-08-7회귀모델 상수 재사용 — 단가 키가 COACH_MODEL_HAIKU 등 상수 참조(모델명 문자열 하드코딩 0)
DoD
의존J-05

J-09 · 문서 동기화 — plan-engine·scenarios 현행 절 + WBS 상태칩 갱신 규약문서 문서 · 1h

목적하이브리드 A/B 설계를 정본 문서에 반영해 '코드와 문서 단일진실'을 유지. coaching-plan-engine.html·coaching-scenarios.html에 하이브리드 절 추가, coaching-v3-build-plan.html(WBS)에 EPIC J 상태칩(⬜→✅)·진척 대시보드를 갱신. 인계서 사상('두뇌=코드/Sonnet, 손=LLM·재료=결정론')을 명문화.
요구세 문서에 하이브리드 A/B 컷오버·승격·롤백·모니터 절을 추가하고, WBS 규약(완료 시 상태칩 ⬜→✅+커밋 해시·진척 대시보드 동시 갱신)을 EPIC J 원자에 적용. 폐기/오래된 v3-순수-조립 서술은 '대체됨' 배너로 정정.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
J-09-1수동하이브리드 절 존재 — coaching-plan-engine.html에 '하이브리드'·'A/B'·'재료=결정론' 키워드 절 추가됨
J-09-2수동WBS EPIC J 상태칩 — coaching-v3-build-plan.html에 J-01~J-10 atom id div + span.st 상태칩 존재
J-09-3수동진척 대시보드 J 행 — WBS 진척 대시보드에 EPIC J 진행률 행 추가
J-09-4수동scenarios 한계·전환 절 — coaching-scenarios.html에 v3 수렴 한계·하이브리드 전환 서술 추가
J-09-5수동폐기 서술 정정 — v3 순수 조립을 '기본 경로'로 단정하던 stale 문구에 '하이브리드로 대체' 정정 배너
J-09-6수동커밋 해시 정합 — 완료 atom의 상태칩 ✅에 커밋 해시 슬롯 채워짐(규약 line 55 준수)
DoD
의존J-03 · J-08

J-10 · 엣지 복리 규칙 운영 규약 — 발견→fixture+테스트(red→green)→수정 루프 명문화운영 운영 · 0.5h

목적A/B 운영 중 새 엣지(괴식 조합·B 수렴·검증 우회 등)를 발견하면 즉시 fixture+회귀 테스트로 박제하고 수정하는 '복리' 규약을 코드·문서·테스트 인프라에 못 박는다. 불변 원칙 ③(엣지 발견 시 fixture+테스트 red→green+수정). 한 번 잡은 사고는 두 번 안 나게.
요구신규 엣지 발견 시 절차: ①재현 fixture를 tests/fixtures에 추가 ②red 테스트 작성(현재 코드로 실패) ③최소 수정으로 green ④커밋에 사고 ID. 이 절차를 runbook과 WBS에 규약으로 기재하고, 첫 적용 사례(괴식 'A: 미역국에 당근'·B 수렴 6통 당근→미역국)를 회귀 fixture로 선등록.
명세
유즈케이스
테스트6개
ID종류케이스 · 검증(입력→기대)
J-10-1회귀괴식 fixture 회귀 — ghost-mirim-carrot fixture로 조합검증 호출→'미역국+당근' 금지 판정(score 낮음)
J-10-2회귀OK 조합 통과 — '짜파게티+당근'·'볶음밥+당근'·'카레+당근'은 조합검증 통과(오탐 방지)
J-10-3회귀수렴 fixture 탐지 — converge-carrot-mirim fixture(B 6통 당근→미역국)→A/B 또는 연속 유사도 가드가 수렴 플래그
J-10-4수동red→green 절차 문서화 — runbook에 fixture→red→수정→green→커밋ID 4단계 절차 명시
J-10-5회귀prebuild 게이트 연동 — 신규 회귀 테스트가 npm test(prebuild)에 포함돼 머지 전 그린 강제
J-10-6수동불변 원칙 동기화 — WBS 불변 원칙 절에 '엣지 복리(fixture+red→green)' 규약 1줄 추가
DoD
의존J-05 · J-09

부록

① 마일스톤 매핑

마일스톤제목EPIC완료 게이트(exit)
D1재료 엔진 + 데이터 정합성 (selectDailyMaterials·급식빈도)A, IselectDailyMaterials(args) 순수함수 동작 + 4기준 가중 랭킹·3일 무재사용 회전·괴식 조합 검증·온보딩 분기 통과. GROUP_INGREDIENTS가 실측 급식빈도 식재료로 정비(단호박 강등·요거트 0회 처리)되고 lib/ingredientFreq.ts·build-ingredient-freq.py로 상위% 산출. freqMap·ingredient-freq가 죽은 코드가 아님을 통합 테스트로 증명(I-07). A-12 회귀 fixture(아린 6통 괴식·수렴 사례) red→green. npm test 그린.
D2두뇌 가이드 + merged 작문 + 품질·무검증 채널 검증B, C, DbuildTeachingGuide가 decideDailyV3 결정→stepBehavior·unit_ko·lever·arcStage·why·doNotRestate·weeklyImpressionSoft를 결정론 합성(고정 블록 조립 아님). composeLetter groundingMode='merged'가 재료(결정론)·거울(byte 고정 금지)·두뇌가이드·온보딩 분기를 LLM 재료로 주입하고 letterQualityBad(은유·나열·모호기간·재료밖) + verifyFacts 합본(거울·재료 1패스)이 발행 전 가드로 작동, 위반 시 재생성 루프. Letter A 경로 byte-무변경 보장 단위 테스트 통과. coach-guide·coach-grounding·coach-quality 스위트 그린.
D3크론 2통 발행 + 앱 2카드 + 어드민 A/BE, F, G아린(43942d34) compare 모드에서 매일 Letter A(v2 무변경)+Letter B(하이브리드) 2통 동시 발행, context.altLetter 저장(스키마 변경 0·메인 letter/oneliner=A). 앱 홈 CompareLetterCard 두 카드 나란히 + variant별 피드백 upsert. /admin/compare 승자 위젯(선호율·반복신고율)·VoteButtons. 다른 자녀=단일 카드 무영향 E2E 통과. 2일 연속 리플레이에서 A≠B·진도 영속·재료 회전 확인. 라이브 무영향·롤백 경로 확인.
D4검증 하네스 · 회귀 · 적대 테스트 (Letter B 품질 게이트)H신규 4파일(coach-materials·coach-quality·coach-merged·compare) prebuild 게이트 편입. 괴식 골든셋(comboGolden) 0 통과·재료 3일 무재사용 불변식·14일 다양성(byte 동일 0)·아린 6통 수렴 0 재현·30가정 통주 게이트(괴식 0·수렴 0·품질 0)·merged vs v2 골든(은유·환각·기간·구체성 우열)·Letter A 대조군 무변경 불변식 전부 그린. 프로젝트 테스트 합계 500+ 충족.
D5컷오버 · 운영 · 롤백 · 모니터 · 문서 (A/B 하이브리드)JcompareEnabled env 게이트 + 즉시 롤백 계약·폴백 안전망 회귀. judgeWinner(피드백 N일·선호율 임계)로 B가 A를 이기면 promoteToProduction 런북으로 전 자녀 승격. coach-selfcheck가 B 품질·괴식·폴백·2통 비용을 cron_runs.meta에 적재·감시. plan-engine·scenarios 문서 현행 절 + WBS 상태칩 갱신. 엣지 복리 규칙(발견→fixture+테스트 red→green→수정) 명문화.

② 의존성 그래프 (핵심 16개)

③ 리스크 톱10

#리스크영향완화
1괴식 조합 검증(A-03 comboMatrix)의 오검(false negative)으로 LLM이 새 괴식을 짓는다. kit-dish-matrix는 음식×식재료 0~3 정성채점이라 미수록 조합('미역국+당근'류)은 점수 공백→통과로 새는 위험. 6/8 괴식 사고의 재발 경로.미수록 조합은 통과가 아니라 '금지(보수적 기본값)'로 처리. food-graph pair(레시피 동시출현 증거)가 0이면 후보 탈락. H-01/H-02 골든셋에 알려진 괴식(미역국+당근)과 OK(짜파게티+당근·볶음밥+당근·카레+당근)을 박제하고 prebuild 게이트로 회귀 차단. LLM에 조합 생성권을 주지 않고 검증 통과 조합만 후보로 직렬화(C-09 verifyComboSafety).
2품질 스캔(C-12/D-05) 오탐(false positive)으로 정상 편지가 무한 재생성→데드라인 초과→Letter B 폴백. 은유 클리셰 사전·나열 패턴이 자연스러운 표현까지 위반 처리하면 2통 중 B가 매일 빈약한 폴백으로 떨어져 A/B 비교가 무의미.품질축은 '과용' 임계(은유 N개 이상·동일 클리셰 반복)로만 위반 판정, 1회 등장은 허용. 재생성은 최대 2회 캡(기존 verifyLetter 패턴 재사용) 후 마지막 후보 채택. H-05 테스트에 정상/위반 양쪽 fixture를 넣어 오탐 0을 회귀로 박제. E-08 시간예산에서 A 우선·B 잔여예산 게이트로 데드라인 보호.
32통 발행으로 LLM 비용·시간 2배. 아린뿐이라 절대량은 작지만 compare 코호트를 넓히면 비용 폭증, B가 품질 재생성 루프로 호출 수를 더 늘림(생성+검증+재생성+퇴고).compare 코호트를 아린 단일(COACH_COMPARE_CHILDREN 기본=COACH_V3_CHILDREN 재활용)로 고정해 컷오버 전까지 비용 상한 확정. E-08 데드라인 게이트로 B는 잔여예산 내에서만, 초과 시 폴백. J-08 costEstimate를 cron_runs.meta에 적재해 호출 수·토큰 모니터. 컷오버(J-03) 시 A는 폐기되므로 전 자녀 승격 후엔 1통으로 복귀(비용 정상화).
4재료 결정론 회전(A-05)이 경직돼 추천이 매일 기계적으로 돌아 부자연(당근→치즈→시금치 강제). '수렴 방지'가 '맥락 무시 회전'으로 역전되어 결핍과 무관한 식재료를 들이밀 위험.회전은 4기준 가중 랭킹(A-04) 상위 후보 풀 '안에서'만 돌린다(블라인드 회전이 아님). 3일 무재사용은 풀이 충분할 때만 강제, 풀이 작으면 완화. 문장은 LLM 자유라 같은 재료라도 표현이 갈림. H-07 14일 리플레이로 다양성 지표(고유 재료 수)와 맥락 적합(타깃 그룹 일치)을 동시 측정.
5Letter A 대조군 오염: merged 작문 개선이 공유 함수(composeLetter·buildLetterUser·verifyLetter)를 건드려 A 경로까지 byte가 바뀜. 그러면 A/B 비교가 '구 v2 vs 신 v2'가 되어 인계서 개선 효과를 측정 불가.groundingMode 분기를 명시적 인자로 받아 기본값='v2'(무변경)·B만 'merged'. A 경로는 기존 planFor+composeLetter를 그대로 호출(E-04 격리). H-14 불변식 테스트: 동일 입력에서 Letter A 출력이 v2와 byte-동일(또는 동일 srcHash)인지 회귀. 신규 코드는 별도 모듈(coachGuide.ts·coachGrounding.ts·coachCompare.ts)로 분리해 A 경로 import 0.
6무검증 채널 누락 재발: 거울·추천이 본문과 별도로 직렬화되면서 검증 1패스(H개선)를 우회. 슬롯값·거울이 발행 직전 가드를 빠져나가 환각·괴식이 사용자에게 노출(근본원인 ②).C-11 verifyFacts merged 확장·D-06 합본 입력 빌더가 거울·추천·슬롯값을 본문과 합쳐 단일 검증 패스로 처리. H-06 테스트가 '거울에만 있는 환각이 검증에 걸리는지'를 명시 회귀. 직렬화 함수(serializeMaterials)와 검증 합본 함수가 같은 allowed 목록을 공유하도록 단일 소스화.
7freqMap 실배선(A-11/E-03) 실패 또는 데이터 부재(/ingredient-recipes.json fetch 실패)로 빈도 가중이 0이 되어 C 4기준 랭킹이 다시 죽은 코드로 회귀. graceful 폴백이 너무 관대하면 정비된 빈도가 무시됨.freqMap 부재 시 kit-matrix count 폴백(기존 popularDishesFor 패턴)을 빈도 프록시로 사용하되, I-01 build-ingredient-freq.py로 정적 ingredient-freq를 빌드 산출물로 동봉해 런타임 fetch 실패와 무관하게 상위%를 보장. I-07 통합 테스트가 '빈도가 랭킹에 실제 반영되는지'(가중치≠0)를 assert.
8온보딩/무기록 분기(A-09·B-07·C-05·C-08) 누락으로 기록<3일 자녀에 환각 분석('요즘 채소 비어요')이 나간다. 아린은 이력이 있어 테스트에서 안 잡히고, 신규 자녀 승격 후 터지는 잠복 버그.buildOnboardingDecision(C-08)을 순수 함수로 분리해 기록<3일이면 분석 경로 진입 자체를 차단, '빠뜨린 입력 안내 + 배경 없는 즉효 팁'으로 분기. B-07이 decision=null/lowData=true를 두뇌 가이드 계약에 명시. H-08 아린 회귀 외에 별도 lowData fixture를 추가해 신규 자녀 시나리오를 박제.
9SQL 단계(F-04·G-01 variant 컬럼·compare_votes 테이블)를 실행 확인 없이 코드가 앞서나가 점심 사고(크론 사망) 재발. variant 컬럼 부재 시 피드백 upsert가 전량 실패해 A/B 신호 수집 불가.불변원칙④ 준수: SQL 실행 확인을 게이트로 박고 다음 원자 차단. 컬럼 부재 시 graceful degrade(variant 없으면 기존 단일 피드백 경로 유지)로 라이브 무영향. G-03 client upsert는 variant 컬럼 존재를 런타임 방어. 어드민 집계(G-07·G-11)는 테이블 없으면 안전 0행 표시.
10Next/빌드 브레이킹: 신규 모듈(coachGuide·coachGrounding·coachCompare·altLetter·comboGuard·ingredientFreq·recoEvidence)과 컴포넌트(CompareLetterCard·VoteButtons·/admin/compare) 다수 추가로 import 순환·타입 불일치·prebuild 게이트 깨짐. 220→500+ 테스트 추가가 vitest 실행시간·CI를 압박.결정론 로직은 전부 순수 함수 모듈로 분리(불변원칙②)해 단독 테스트·import 단방향 유지. tsc·build·npm test를 각 D 마일스톤 exit 게이트로 강제(불변원칙①). 테스트는 순수함수 원자에 집중(10~25 케이스)해 빠르게 유지, 통합/UI는 3~10으로 절제. 신규 4파일을 H-15에서 prebuild에 점진 편입.

④ 테스트 인덱스 (총 893개)

EPIC별 / 종류별 분포. 각 테스트 ID는 원자 카드 안에 검증식과 함께 명시(예 A-03-1).

EPICABCDEFGHIJ합계
테스트 수 136 84 94 101 84 60 78 114 71 71 893
종류단위통합회귀적대E2E수동속성합계
개수4098915813984842893

⑤ 용어

용어정의
Letter A (대조군)아린에게 매일 발행하는 2통 중 첫 통. 기존 v2 그대로(planFor+composeLetter, 무변경). 메인 coach_letters.letter/oneliner에 저장되며 어떤 개선도 적용하지 않는 비교 기준점.
Letter B (처치군)2통 중 둘째 통. 인계서 개선 A~I를 전부 적용한 하이브리드 설계. coach_letters.context.altLetter에 저장. 어드민 피드백(👍/👎/🔁)에서 A를 이기면 전 자녀로 승격.
merged 모드 (groundingMode)composeLetter의 새 작문 분기. 재료=결정론 코드, 사실=카드, 거울=데이터 주입(byte 고정 금지), 문장·도입·톤만 LLM 자유. groundingMode='v2'(기본·A)와 'merged'(B)를 명시 인자로 분기해 A 경로 무변경 보장.
재료 결정론 (B 개선)'무엇을 추천할지'(식재료·조합)는 코드가 매일 회전(3일 내 재사용 금지), '어떻게 쓸지'(문장)만 LLM에 맡기는 분담. 데이터+재료를 다 LLM에 주면 6통이 '당근→미역국'으로 수렴한 실증의 보완책.
두뇌 가이드 (TeachingGuide)buildTeachingGuide 산출. decideDailyV3 결정→unit_ko·lever·stepBehavior·arcStage·why(근거)·doNotRestate·weeklyImpressionSoft를 결정론 합성한 '가이드'. 고정 블록 조립이 아니라 LLM이 직접 쓸 재료로 전달.
품질 스캔 (qualityScan / letterQualityBad)발행 전 LLM 출력에 적용하는 품질축 결정론 스캐너. (1)은유 클리셰 과용(통장·계단·사슬 등) (2)'어제 X 먹었어요' 나열 (3)모호 기간어 (4)재료 밖 음식명을 스캔, 위반 시 재생성. '게이트 그린≠좋은 편지'를 깨는 핵심.
무검증 채널 (H 개선)거울·추천·슬롯값이 본문과 별도라 발행 직전 가드를 우회하던 경로. verifyFacts merged 확장·합본 입력 빌더로 본문과 1패스 검증해 닫는다. 근본원인 ②의 수술 대상.
괴식 조합 검증 (comboFit / verifyComboSafety)'잘먹는음식+결핍식재료' 조합을 kit-dish-matrix(음식×식재료 0~3 정성채점)+food-graph pair로 점수화해 낮으면 금지. 검증 통과 조합만 LLM 후보로 직렬화. 미수록 조합은 보수적으로 금지(미역국+당근 차단).
식단 거울 (mirror)buildMealMirror 산출. 실제 식단을 부모에게 비추는 사실 문장. merged 모드에서 데이터로 주입하되 byte 고정은 금지(템플릿 느낌 원인). compileFactCards가 cards·forbidParts와 함께 반환.
사실 카드 (FactCard)compileFactCards가 반환하는 검증된 사실 단위(거부·환경·빈도 등). LLM이 사실을 인용할 때는 raw timeseries가 아니라 카드만 참조(C-06 강등)해 환각·날조를 차단.
4기준 가중 랭킹 (rankIngredients)추천 식재료를 ①영양 시급도 ②급식빈도 상위% ③잘먹는음식 궁합(food-graph pair) ④잘먹는채소 사촌(bridge) 가중 합산으로 정렬. 현 pickFoodReco의 seed%length 블라인드 회전(4기준 0반영)을 교체.
liked 판정 (D 개선)deriveLikedIngredients. 진짜 '잘 먹는' 식재료 = place≠daycare(집) + 집 2일 이상 + 거부 없음. 급식·간식은 '차려진 것'이라 선호 신호가 아니며, refused는 후순위로 강등.
결핍 기간 수치화 (deficiencyWindow / F 개선)'요즘/최근/이번주' 모호어를 금지하고 '최근 7일 중 비타민A 채소가 3일'처럼 주간 등장일 수치로 표현. 부족 임계도 코드로 정의해 LLM 재료에 수치를 제공.
compare 모드아린 코호트(COACH_COMPARE_CHILDREN, 기본=COACH_V3_CHILDREN 재활용)에서 v3 순수 조립을 대체하고 A/B 2통을 발행하는 크론 분기. 아린은 더 이상 v3 블록 조립을 하지 않고 A(v2)+B(merged)만 생성.
승자 판정 (judgeWinner / compareVote)어드민 A/B 피드백 N일 누적에서 선호율·반복신고율로 B가 A를 이겼는지 판정하는 순수 함수. 임계 통과 시 promoteToProduction 런북으로 전 자녀 승격 트리거.

⑥ 불변 원칙