# 함수 설계서: `recommendVariant` (#204) > 부모 설계서: [README.md](./README.md) · 상태: Draft > 작성: [AI] Architect · 구현: `lib/domain/recommend/recommend_variant.dart::recommendVariant` (TBD) > 테스트: `test/domain/recommend/recommend_variant_test.dart` (TBD) ## 1. 시그니처 ```dart VariantPick? recommendVariant( Habit habit, CheckInContext ctx, ); class CheckInContext { final String? location; // 예: '집', '짐', '출장' — null 가능 (1 탭 진행 중) final String? condition; // 예: '좋음', '보통', '나쁨' const CheckInContext({this.location, this.condition}); } class VariantPick { final HabitDoseVariant variant; final int score; // 0 ~ 5 final String reason; // 'exact_match' | 'partial' | 'fallback_minimum' | 'fallback_first' } ``` ## 2. 책임 habit 의 dose_variants 중 사용자의 현재 컨텍스트에 가장 잘 맞는 1 개를 점수 함수로 선택해 반환한다. 빈 결과(null) 는 호출자가 별도 처리. ## 3. 입력 | 파라미터 | 타입 | 제약/검증 | 설명 | |----------|------|-----------|------| | `habit` | `Habit` | not null. `dose_variants` 가 0 개여도 OK | habit + variants list 보유 | | `ctx.location` | `String?` | nullable | 사용자 1 탭 결과. null 이면 location 점수 0 | | `ctx.condition` | `String?` | nullable | 동일 | ## 4. 출력 - **반환**: `VariantPick?` — variants 가 비어있으면 `null`. 아니면 항상 non-null. - **부수효과**: 없음. **순수 함수**. ## 5. 동작 / 알고리즘 ``` 1. variants = habit.dose_variants 2. if variants.isEmpty: return null 3. scored = variants.map(v => (v, scoreVariant(v, ctx))) 4. scored.sort((a, b) => b.score.compareTo(a.score) ?? a.v.sortOrder.compareTo(b.v.sortOrder)) 5. best = scored.first 6. if best.score > 0: reason = best.score >= 4 ? 'exact_match' : 'partial' return VariantPick(best.v, best.score, reason) 7. # score = 0 — fallback 8. minimum = variants.firstWhere(v => v.is_minimum, orElse: variants.first) 9. return VariantPick(minimum, 0, minimum.is_minimum ? 'fallback_minimum' : 'fallback_first') function scoreVariant(v, ctx): s = 0 if ctx.location != null && v.context_tags.contains(ctx.location): s += 2 if ctx.condition != null && v.condition_tags.contains(ctx.condition): s += 2 if v.is_minimum && ctx.condition == '나쁨': s += 1 return s ``` > **태그 매칭은 case-sensitive exact string**. 한국어 NFC normalize 는 호출자가 책임 (UI 가 chip 텍스트를 그대로 ctx 에 채워주므로 동일 폼). ## 6. 에러 & 실패 모드 | 조건 | 처리 | 반환 | |------|------|------| | `habit.dose_variants` empty | 즉시 null | null | | `ctx` 둘 다 null | 모든 variants score 0 → fallback 동작 | VariantPick(reason=fallback_*) | | score 동률 | `sortOrder` (ascending) 로 안정 정렬 | 정해진 1 개 | | `is_minimum` variant 다수 | 첫 발견 | (sortOrder 순) | ## 7. 엣지케이스 - variants 1 개: 항상 그 1 개 반환 (score 무관). - 모든 variants 가 동일 score: sortOrder 가장 작은 것. - ctx.condition = '나쁨' 인데 is_minimum variant 가 없음: 그냥 일반 점수 비교. - variant 의 context_tags 가 null/빈 배열: location 점수 0. - 본 함수는 stateless. 동일 입력 → 동일 출력 (테스트 결정성). ## 8. 복잡도 / 성능 - 시간: O(N) (N = variants 개수). 실용 N ≤ 10 → < 50 µs (Dart native). - 공간: O(N) (scored list). - 호출 빈도: 체크인 화면에서 location/condition chip 탭 시마다 1 회 (≤ 5 회/체크인). 무시 가능. ## 9. 의존성 - `Habit` 모델 (domain/models). - `HabitDoseVariant` 모델 (domain/models). - Drift / DB 미참조 — 순수 함수. (호출자가 미리 hydrate 한 habit 객체를 받음.) ## 10. 테스트 케이스 - [ ] 정상: variants=[gym(ctx=짐), home(ctx=집)], ctx={집,좋음} → home, score=2, reason='partial' - [ ] exact_match: variants=[gym(ctx=짐,cond=좋음)], ctx={짐,좋음} → gym, score=4, reason='exact_match' - [ ] fallback_minimum: variants=[main, tiny(is_minimum)], ctx={출장,나쁨} 어떤 태그도 매칭 안 됨 → tiny, score=0, reason='fallback_minimum' - [ ] fallback_first: variants=[a, b] is_minimum 둘 다 false, ctx 매칭 0 → a (sortOrder 첫 번째), reason='fallback_first' - [ ] empty: variants=[] → null - [ ] tie-break: score 동률 → sortOrder 작은 것 - [ ] null ctx: ctx={null,null} → fallback 동작 - [ ] '나쁨' 조건 + tiny variant: variants=[main(cond=좋음), tiny(is_minimum)], ctx={집,나쁨} → tiny, score=1 - [ ] case-sensitive: ctx={'짐'}, tag=['gym'] → 매칭 X ## 11. 추적성 - 인수조건: #204 AC-11. - 관련 ADR: [ADR-0001](../../adr/0001-dose-variants.md) (R9 무제한 → score 함수 O(N) OK). - R8 (≤ 60 초): 본 함수 자체는 µs 수준. UI 가 R8 책임.