Files
life-helper/docs/design/204-flutter-bootstrap/fn-recommend-variant.md
joungmin b8e563176b [Architect] #204 design spec — Flutter bootstrap + 18 tables
Phase 1 설계서 작성 완료. docs/design/204-flutter-bootstrap/ 13 개 파일:
- README.md (12 섹션 모두 채움, 함수 19 개 명세, AC 16 항)
- 01-project-structure.md (feature-first + layer-first 하이브리드)
- 02-drift-schema-catalog.md (Catalog 7 테이블 Dart 정의)
- 03-drift-schema-user.md (User 11 테이블 + R1~R10 강제 매트릭스)
- 04-migrations.md (schemaVersion v1 + 인덱스 17 개)
- 05-seed-data.md (assets/seed/*.json + first-run import)
- 06-ux-contracts.md (체크인 R8 ≤ 60 초 흐름)
- fn-recommend-variant / fn-compute-streak / fn-weekly-minimum-ratio
- fn-validate-frame-level / fn-active-habit-quota / fn-seed-importer

핵심 결정: dose_variants 는 별도 habit_dose_variant 테이블로 정규화 (FK
무결성 + recommendVariant SQL 단순성). ADR-0002 승격 권장.

Refs #204

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-11 16:57:31 +09:00

4.8 KiB

함수 설계서: recommendVariant (#204)

부모 설계서: README.md · 상태: Draft 작성: [AI] Architect · 구현: lib/domain/recommend/recommend_variant.dart::recommendVariant (TBD) 테스트: test/domain/recommend/recommend_variant_test.dart (TBD)

1. 시그니처

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 (R9 무제한 → score 함수 O(N) OK).
  • R8 (≤ 60 초): 본 함수 자체는 µs 수준. UI 가 R8 책임.