① 비개발자용 — 이 모듈이 무엇을 왜 하나
이 모듈은 편식 코칭을 '한 번의 조언'이 아니라 12개 수업으로 짜인 커리큘럼처럼 굴립니다(curriculumUnits.ts). 마치 학교 통지표처럼, 각 아이가 12개 수업(압박 내려놓기, 공복 리듬, 식탁 무대, 새 음식 노출 등) 중 지금 어디까지 와 있는지를 '시작 전 → 진행 중 → 유지 → 졸업'이라는 단계로 추적합니다. 각 수업에는 1·2단계 사다리(쉬운 행동 → 다음 행동)와 '이 정도면 통과'라는 판정 기준(passWhen), '다시 나빠졌다'는 재발 기준(relapseWhen)이 들어 있습니다. 중요한 점은 이 판정에 AI(LLM)를 전혀 쓰지 않는다는 것입니다. 전부 부모가 입력한 식사 기록과 칩(버튼) 답변을 순수 계산으로만 채점합니다.
작동 방식은 이렇습니다. 매일 한 번, 코칭 크론(자동 작업)이 어제까지의 식사 기록을 불러와 각 수업별로 '어제 그 행동을 했는가'를 1(했음)·0(안 했음)·판단보류(기록 없음)로 채점합니다. '기록이 없다'를 '안 했다'로 단정하지 않는 것이 핵심 원칙이라, 표본이 모자라면 함부로 점수를 매기지 않고 보류합니다. 보류가 생기면 코칭 편지 끝에 '어제 식탁 분위기 어땠어요?' 같은 칩 질문을 끼워 부모 답을 받아 표본을 채웁니다(probes). 모든 카운트는 '몇 번'이 아니라 '며칠'로 셉니다(같은 날 여러 끼 기록해도 하루로 묶어 부풀림 방지).
한 수업이 '통과 기준'을 일정 주(holdWeeks) 동안 채우면 다음 단으로 올라가고(전진), 마지막 단까지 채우면 한 주 '유지'를 본 뒤 졸업(mastered)합니다. 반대로 며칠을 코칭했는데도 신호가 0이면 '가르쳤는데 안 됨(정체)'으로 보고 다른 각도(다른 수업)로 피벗합니다. 그리고 너무 오래(18일) 한 수업에 매달려 진전이 없으면, 주당 1회 피벗 제한을 무시하고 강제로 다른 수업으로 전환(hardStall)합니다. 같은 수업을 이틀 연속 채근하지 않게 주당 피벗 횟수도 1회로 제한합니다.
결핍 판정: '잘 먹는 식재료'는 28일 안에 2회 이상 나오고 거부 목록에 없는 식재료로 셉니다(1회짜리나 거부한 건 안 침). 반복노출(노출 적금): 새 음식은 '말없이 식탁에 아주 조금씩' 올리는 걸 1단계로 보고 주 2회 이상 노출을 하한(
exposeWeekly:2), 누적 8회 이상을 다음 단계 기준으로 잡습니다(exposeTotalForStep2:8 — 노출 효과 이론을 수치화). 노출 일수를 평생 누적해 모읍니다(폭주 방지로 최근 60개까지만). 괴식·위험 차단: 질감 수업은 'puree→mashed→finger→table 한 단계만 위로, 거부하면 즉시 후퇴'를 규칙으로 둬 무리한 점프를 막습니다. 어린이집 거부를 집에서 재시도하는 '연계' 수업은 거부 메모를 정제(cleanRefusal)해 부모 메모가 음식으로 오인되는 오염을 막습니다. 강요 금지: 압박·배부름·협상·식탁의 말 수업이 '한 입만/다 먹어/억지로' 강요 신호와 '먹으면 ~줄게' 보상 거래를 잡아내, 그게 0이 되어야 통과로 칩니다.computeProgress)은 최근 28일 vs 직전 28일을 비교해 '잘 먹는 식재료 종 수'가 늘고 거부율이 줄면 '편식이 좋아지는 중이에요'를 부모에게 보여줌.② 구현된 내용 — 함수·데이터흐름·입출력 (실측)
이 모듈은 코드상 세 파일에 산다: curriculumUnits.ts(12유닛 레지스트리·임계 상수·신호 추출기), curriculum.ts(진도 상태머신 — evolveRow·advanceProgress·pickPivot·fallbackPivot), coachWeekly.ts(candidateUnits·applyFocusFatigue·enrichWeeklyPlan의 curriculum 블록 산출).
A. 12유닛 레지스트리 — curriculumUnits.ts:94-416
UNITS: Record<UnitId, UnitDef>(L94) — 12개 유닛 각각이 { id·label·lever·minWeek·steps[]·probes[]·extract()·relapseWhen()·trigger() }. 전부 순수 함수, LLM 0콜, 모든 카운트는 '날짜' 단위 dedup(dates() 헬퍼). UNIT_IDS(L418)는 키 배열. 임계 상수는 전부 TH(L51-70)에 모아 리플레이로 보정: stallDays:6·coachedDaysForStall:3·maintenanceWeeks:1·relapseWindowDays:14·maxPivotsPerWeek:1·hardStallDays:18(이번 세션 신규)·focusMaxStallWeeks:2·exposeWeekly:2·exposeTotalForStep2:8.
| 유닛(id·lever·minWeek) | 사다리 step1 → step2 (passWhen) | trigger 발동 신호 |
|---|---|---|
| pressure-off · mixed · 1 | 압박 메모 0일 → 부정 칩비율 ≤ 0.2 | pressureMemoDays≥2 → 2+ |
| hunger-rhythm · environment · 1 | 끼니 전 간식 0일 → 과식간식일 ≤ 2 | snackHeavy/preMeal ≥2 → 2 |
| table-stage · environment · 1 | 식탁비율 ≥ 0.4 → ≥ 0.6 | envBadPct≥0.4 & count≥4 |
| exposure-savings · food · 2 | 주간 노출 ≥ 2일 → 누적 hitDays ≥ 8 | missing>0 ‖ refused>0 → 1.5+ |
| fullness-respect · mixed · 2 | 완식강요 0일 → 30분초과율 ≤ 0.3 | mtOver30>0.5 & count≥4 |
| parent-model · mixed · 2 | 가족저녁 ≥ 3일 → ≥ 5일 | 0 (Sonnet 재량·폴백만) |
| no-bargain · mixed · 2 | 거래 메모 0일 → 0일+중립디저트 1회 | bargainMemoDays≥1 → 1.5+ |
| table-talk · mixed · 3 | 금지어 0일 → 객체대화 ≥ 0.7 | pressureMemoDays≥1 → 1 |
| sensory-texture · texture · 3 | texModeIdx ≥ 2(finger) → ≥ 3(table) | texLow & count≥3 → 2 |
| food-bridge · food · 3 | 주간 신규 ≥ 1 → ≥ 1(holdWeeks↑) | newFood=0 & eaten>0 → 1.2 |
| autonomy-part · autonomy · 3 | 자기주도 ≥ 0.3 → ≥ 0.5 | selfPct<0.3 & count≥4 → 2+ |
| link-rhythm · food · 3 | 기관거부 집재노출 ≥ 1 → ≥ 2 | 등원 & dcRefused>0 → 1.5 |
각 extract(rows, answers, today, opt)는 7일창 evidence를 산출하되 표본 부족이면 {signalToday:null}(판정 보류)를 반환합니다(minLoggedDays:3 등). probes[]는 표본 부족 시 편지에 띄울 칩 질문 — map에 칩 원문 → 신호 매핑이 들어가 있고, 칩 '원문' 비교가 규약입니다(L102 주석: 매핑 키로 비교하면 영원히 0이 되는 잠복 버그를 G-04에서 적발). CORE_ORDER(L424)는 코어 1→N 단일 소스 — 이번 세션(C)에 minWeek=3 트랙(texture·food-bridge·autonomy·link-rhythm)을 뒤에 편입해 3주차+ 아이의 확장 주 목표가 실제로 열리게 했고, coachWeekly 폴백·curriculum.fallbackPivot이 공용으로 참조합니다.
B. 진도 상태머신 — curriculum.ts
| 함수 (file:line) | 역할 |
|---|---|
evolveRow(p) · L35-93 | 단일 유닛 하루치 진화. def.extract로 snapshot evidence 병합 → 상태별 전이. mastered면 relapseWhen 연속 relapseWindowDays(14) → relapsed(step=최종단-1 재개). maintenance면 maintCoached==0&7주일 유지&pass=true → mastered. active/progressing면 사다리(passStreakDays/7 ≥ holdWeeks → step+1, 최종단 → maintenance). 이번 세션 신규: stallStreakDays(L89) = pass=true·step++이면 0 리셋, 그 외엔 coachedYesterday일 때만 +1(standby/코칭 전 적립 차단=거짓 강등 방지). |
isProgressing / isStalled · L24-31 | 진전 = 최근 stallDays(6) 내 1차 신호. 정체 = 무신호 stallDays+ 그리고 그 기간 코칭 일수 ≥ coachedDaysForStall(3) — '안 가르침'과 '가르쳤는데 안 됨'을 구분. |
advanceProgress(p) · L158-246 | 통합 상태머신(크론이 자녀별 하루 1회 호출 의도). ①mastered 전체 재발 스캔 → ②focus 활성화·진화(preEvolved 이중적립 가드) → ③standby 증거 적립(판정용) → ④오늘 전개 결정(DailyDecision{unit,step,mode,pivotTo}). mode: graduated→celebrate, maintenance→maintain, stepAdvanced→advance, progressing&!limping→deepen, stalled‖limping→pivot/observe. goalsAfter 반환(피벗이 goals.focus를 플립해야 영속 — 정적 goals가 다음날 되돌리는 버그를 B-26 리플레이가 적발). |
limping/hardStall 분기 · L208-224 | 이번 세션 핵심. limping = step 미전진 & passStreakDays==0 & coachedF≥3(가끔 약신호가 들어와 완전 무신호 isStalled엔 안 걸리지만 사다리 진전 0). hardStall = stallStreakDays ≥ TH.hardStallDays(18). hardStall이면 주1회 피벗 캡을 무시하고 pickPivot → null이면 fallbackPivot으로 강제 전환. limping이면 'deepen 위장' 금지하고 observe 고정(21일 deepen 고착 직접 원인 제거). |
pickPivot(goals,progress) · L96-104 | standby goal 중 mastered/maintenance 아닌 첫 유닛. |
fallbackPivot(focusId,progress,week) · L107-119 | 이번 세션 신규. standby가 비어 pickPivot=null일 때 폴백 — CORE_ORDER에서 focus 이후(순환) 첫 '미이수 & minWeek≤주차' 유닛. 콜드스타트(goals standby 비어도) 음식 트랙 전환 보장. 순수·throw 0. |
normalizeGoals / goalsOf · L122-147 | goals 정규화(상위 3·dedup·focus 1개 강제). 구닻(단일 lever) → LEVER_TO_UNIT으로 goal 승격(A-05 컷오버 호환). |
C. 주간 후보·피로캡·plan_detail 산출 — coachWeekly.ts
| 함수 (file:line) | 역할 |
|---|---|
candidateUnits(p) · L142-162 | 유닛 후보 산출 — UNITS[u].trigger(sig) + 재발 +3 / 전주 중단 +1, mastered·maintenance 제외, minWeek > week 제외(E-05 온보딩 게이트). 신호 0이면 CORE_ORDER 미이수 첫 유닛 폴백. 상한 5. |
ONBOARDING_ARC · L166-170 | 이번 세션 신규(C) — 온보딩 3주 아크: 1주차=신뢰 주(cap 1), 2주차=관찰 주(cap 2), 3주차=확장 주(cap 3). goalsCapForWeek(L172)가 위임. '온보딩 3주차 목표 없음'을 명명으로 실재화. |
applyFocusFatigue(goals,history) · L178-189 | 같은 유닛이 직전 2주(focusMaxStallWeeks) 연속 focus였고 그 2주 step 전진 0이면 3주째 강등(차순위 승격). focusHistory 비면 no-op. |
enrichWeeklyPlan(synth,ctx) · L520-571 | 이 모듈의 출력이 plan_detail.curriculum 블록(L560-568)으로 빚어지는 곳. focusUnit·focusLabel·standby[]·stalledOut·graduatedTo·lever 산출. graduatedTo = focus가 직전 주 focus(focusHistory[0])와 다르면 그 유닛(유닛 전환=진척 가시화). stalledOut = goals 중 status='stopped' 유닛. |
거울·macro(인접 산출) · L464-516 | 이번 세션 #5/#6/#7 신규 — mirrorLineFor 3변형 회전 + deficitDishFor 구체 dish 주입, buildMirrorSchedule(단일 결핍 격일 쿨다운=#5 거울 쿨다운, 결핍군 라운드로빈=#6 도입 회전), buildMacroTrack(저체중/성장더딤 탄단지 보강 = #7 macro, 격주 cadence). 진척 레이어와 같은 EnrichContext.goals를 공유하나 진도 판정 본체는 아님. |
D. 상태 전이 매트릭스 (curriculum.ts:6-10 실측)
not_started ─활성화→ active ─신호→ progressing ─사다리(holdWeeks)→ step+1 … 최종단 충족 → maintenance (코칭 중단·관찰) ─유지성공→ mastered └─신호붕괴→ active(재코칭) active|progressing ─정체(stalled ‖ limping)→ pivoted (stop_reason=stalled|limping) mastered ─relapseWhen 연속 14일→ relapsed (step=최종단-1 재개) ─활성화→ active
mealOver30Cap:0.3), WIC 2~3시간 간격(공복 리듬), food chaining(food-bridge 사촌 1축 변형 — ⑦ 네트워크 verifiedCousinsOf 연계), BMI 또래 퍼센타일(macroTrack 저체중/성장더딤).③ 설계 아키텍처 — 데이터 파이프라인 (그림)
입력 테이블 → 가공 함수 → 출력 컨텍스트키의 흐름을 그림으로. 굵은 화살표는 라이브 배선, 점선(⇢)은 미배선(read-only dead-end)입니다.
┌─ 입력 테이블 ──────────┐ 가공 함수(순수·LLM0) 출력 컨텍스트키 │ meal_logs │──┐ │ (slot·menus·refused │ │ ┌──────────────────────┐ │ note·environment· │ ├──▶│ UNITS[u].extract() │── evidence ──┐ │ ate_well·meal_time·│ │ │ (7일창·날짜dedup) │ │ │ autonomy·texture) │ │ └──────────────────────┘ ▼ │ coach_questions │──┤ ┌────────────────────┐ │ (context.unitProbe) │ ├──▶ parseProbeAnswers ──┐ │ evolveRow() │ │ child_unit_progress │──┤ (칩→ProbeAnswer)│───▶│ passWhen/relapse │ │ (status·step· │ │ │ │ stallStreakDays++ │ │ evidence· │ │ │ └─────────┬──────────┘ │ last_signal_at) │──┘ │ ▼ │ weekly_plans.goals │───────────────────────────┘ ┌────────────────────┐ │ (goals·ledger) │ │ advanceProgress() │ └──────────────────────┘ │ limping?hardStall?│ │ pickPivot / │ buildCandSignals(rows) ──▶ candidateUnits ──▶ goals │ fallbackPivot │ (① 신호선택기가 호출) applyFocusFatigue └─────────┬──────────┘ │ ┌─────────────── 출력 ───────────────────────────────────────┘ ▼ ▼ decision{unit,step,mode} goalsAfter[] ⇢⇢ weekly_plans.goals (upsert) │ [배선 약함] ⇢⇢ 편지 부모행동 텍스트 [⚠ DEAD-END: curriculum_progress write ≈0건] growth_logs ──▶ bmiTrend / buildMacroTrack ──▶ macroTrack.phrase (홈·거울) meal_logs (28일) ──▶ computeProgress ──▶ progress(홈 진척카드 verdict) [라이브] enrichWeeklyPlan ──▶ plan_detail.curriculum{focusUnit·standby·stalledOut·graduatedTo·lever}
computeProgress(홈 진척 카드)와 plan_detail.curriculum(어드민 가시화)입니다. 반면 상태머신의 핵심 출력인 decision(오늘의 step·mode)과 curriculum_progress write는 편지에 닿는 배선이 약합니다 — ⑤가 12유닛 통지표를 '측정'하지만 그 step·mode가 편지 본문으로 흐르는 경로가 dead-end에 가깝습니다(§⑤ 천장 참조). 이번 세션에 goalsAfter 영속·plan_detail.curriculum 가시화는 배선됐으나, curriculum_progress 자체의 안정적 write/read 루프는 여전히 약합니다.④ 작동 예시 — 아린(5세) before/after
실제 입력 → 이 모듈 통과 → 출력의 구체 한 케이스. 두 흐름을 보입니다: (가) 효과측정(computeProgress·라이브)과 (나) 상태머신 피벗(advanceProgress·hardStall).
// (가) computeProgress(rows, "2026-06-16") — 홈 진척카드
rows: [
{log_date:"2026-06-15", ingredients:["소고기","감자","계란"],
refused:null, ate_well:true, duration_min:22},
{log_date:"2026-06-14", ingredients:["미역","두부","계란"],
refused:"콩나물", ate_well:true, duration_min:28},
{log_date:"2026-05-20", ingredients:["감자","계란"],
refused:"시금치", ate_well:false, duration_min:41},
{log_date:"2026-05-18", ingredients:["소고기"],
refused:"브로콜리", ate_well:true, duration_min:38}
]
// (나) advanceProgress — focus=table-stage 24일째
goals: [{unit_id:"table-stage", priority:1, status:"focus"},
{unit_id:"exposure-savings", priority:2, status:"standby"}]
progress["table-stage"]: {
status:"active", step:1,
evidence:{ envTablePct7d:0.14, // 임계 0.4 미달
passStreakDays:0,
stallStreakDays:18 }, // ⭐ 18일째
last_signal_at:"2026-06-05" }
coachedDays:{ "table-stage":12 }
pivotsThisWeek: 1 // 주1회 캡 이미 소진
week: 6// (가) computeProgress → progress(홈 카드)
{ hasComparison:true,
metrics:[
{key:"variety", recent:3, prior:1, unit:"종", improved:true},
{key:"refusal", recent:33, prior:100, unit:"%", improved:true},
{key:"enjoy", recent:67, prior:50, unit:"%", improved:true},
{key:"dur", recent:28, prior:40, unit:"분", improved:true}],
improved:4, total:4,
verdict:"편식이 좋아지는 중이에요 👏" }
// (나) advanceProgress → BEFORE(hardStall 전·구코드)
// limping → pivotsThisWeek(1) ≥ cap(1) → pickPivot 호출 안 함
// → mode:"deepen" ❌ 24일째 같은 식탁무대 deepen 고착
// (나) AFTER(이번 세션 hardStall:18)
// stallStreakDays(18) ≥ hardStallDays(18) → 캡 무시
// pickPivot(standby=exposure-savings) → "exposure-savings"
decision: { unit:"exposure-savings", step:1, mode:"pivot",
pivotTo:"exposure-savings" }
goalsAfter: [{unit_id:"table-stage", status:"stopped",
reason:"limping"},
{unit_id:"exposure-savings", status:"focus"}]hardStall:18+fallbackPivot 배선으로 24일 식탁무대 deepen 고착이 코드 레벨에서는 끊깁니다 — limping이 'deepen 위장' 못 하게 막고(L240), 캡이 소진돼도 18일이면 강제 피벗. goalsAfter가 focus를 플립해 다음날 되돌림도 막습니다.exposure-savings(음식)이면 다행이나, standby도 비고 fallbackPivot이 CORE_ORDER 순환에서 다시 table-stage 계열로 회귀하면 '→table-stage' 제자리 피벗이 됩니다(랄프위검 실측: table-stage ↔ exposure-savings 핑퐁, 06-11≡06-18 메타 쌍둥이). 더 근본은 (나)의 출력 decision.step이 24일 내내 1단에 고착 — passWhen이 한 번도 충족 안 되어 step++가 0회(아래 §⑤).⑤ 남은 문제점 · 해야할 일 (랄프위검 47점·2026-06-20 실측 천장)
연속성 평가 47점에서 드러난 이 모듈의 천장을 미화 없이. 각 항목: 증상 → 근본원인 → 해야할일(owner). 성과(EARNED)도 사실이면 명시하되, 실측 고착은 정직하게.
| 증상 | 근본원인 (file:line · 분류) | 해야할일 (owner) |
|---|---|---|
★ step이 24일 내내 1·2단 완전 고착 — 5일째→24일째 전진 0. passWhen이 단 한 번도 충족 안 됨. '다음:' 졸업 마일스톤 0회. 부모가 '제자리걸음'을 가장 직접 체감. |
curriculumUnits.ts:170-171(table-stage passWhen) · curriculum.ts:78-86ROOT · 최우선 passWhen=envTablePct7d≥0.4인데 실측 0.14. evidence 0.4를 채울 양성 신호 입력 자체가 안 들어옴 → passStreakDays 영원히 0 → step++ 영구 0. |
P0-D 신호 입력 게이트 완성(owner: 코칭엔진) — child_daily_state 환경신호 입력 경로를 닫아 table-stage step 전진을 가능케. 지금은 피벗만 되고 졸업 0. 칩 답(ts-env '식탁에서 화면 없이')이 extract 표본으로 실제 적립되는지 end-to-end 검증. |
| table-stage ↔ exposure-savings 졸업 없는 핑퐁 — 진척 아닌 정체. 06-11≡06-18, 06-12≡06-19 7일 간격 메타 쌍둥이(같은 두 유닛 사이 왕복). | curriculum.ts:107-119(fallbackPivot) · L223-239DESIGN_GAP · high pivot이 unit 토글만 수행하고 누적 진도 좌표가 없음. CORE_ORDER 순환이 두 미이수 유닛 사이를 무한 왕복. pivot 도착지가 출발 유닛과 동류면 '진척 좌표 미생성'. |
단일 진행 좌표(누적 진도바) 신설(owner: 코칭엔진) — 유닛 토글이 아니라 'mastered 졸업' 경로를 가진 누적 좌표. fallbackPivot이 같은 stage로 회귀하면 '진척 좌표 미생성'을 버그로 분류(현재는 정상 흐름으로 통과). 핑퐁 카운터를 두어 N회 왕복 시 다른 lever 강제. |
| 정체 해소 메커니즘이 정체를 못 끝내는 역설 — hardStall-18 강제피벗이 발동해도 step이 안 오름. 06-11엔 16일째 조기 발동 관찰(18 미만). | curriculum.ts:222-227 · curriculumUnits.ts:57,89CODE_BUG · high pivot이 step++가 아니라 unit 토글만. hardStallDays:18 카운터 기준이 '유닛 연속 정체일'인지 '전체 일자'인지 혼선 — stallStreakDays는 coachedYesterday일만 +1이라 달력일과 어긋나 16일째 발동 가능. |
hardStall 카운터 기준 명문화(owner: 코칭엔진) — stallStreakDays가 '유닛 연속 코칭 정체일'임을 주석·테스트로 고정하고, 16일 조기발동이 의도인지(코칭일 18≈달력일 21) 회귀 테스트로 확정. pivot이 누적 진도 좌표를 전진시키도록 §2 좌표와 묶기. |
passWhen 충족 = P0-D 신호 의존인데 실데이터 미입력 — child_daily_state 미입력 → 모든 step 전진의 상류가 막힘(천장의 단일 뿌리 = '아린 확신liked 0개'와 동일 계열). |
curriculumUnits.ts:154-179(extract 표본 게이트) · route 배선DESIGN_GAP · 최우선 extract가 표본 부족이면 null(보류)을 정확히 반환하나, 그 보류를 메울 환경/care 5단계 실데이터가 아직 안 쌓임. 측정기는 정상, 입력이 빈 것. |
P0-D부터 착수(owner: 코칭엔진·다음 세션) — care 5단계·환경칩 실데이터가 쌓이면 대부분 자연해소. 그 전 단독 수정: advanceProgress 결과를 curriculum_progress에 안정 write(현재 ≈0건)하고 편지 부모행동 텍스트로 step.behavior를 흘려보내는 read 루프 완성. |
hardStallDays:18+fallbackPivot+limping observe 고정으로 '21일 deepen 고착'의 코드 직접 원인은 제거(stalledTarget 21일보다 한 사다리 빨리 끊음). ⒝ CORE_ORDER에 확장 트랙 편입(C)으로 3주차+ 확장 주 목표가 실제로 열림. ⒞ ONBOARDING_ARC로 '온보딩 3주차 목표 없음' 명명 실재화. ⒟ stallStreakDays coachedYesterday 게이트로 거짓 강등 차단. ⒠ computeProgress 효과측정은 라이브로 부모 홈에 검증된 진척 렌더. ⒡ plan_detail.curriculum{focusUnit·standby·stalledOut·graduatedTo} 어드민 가시화 배선.