diff --git a/saju-lab/app/calculator/compatibility.py b/saju-lab/app/calculator/compatibility.py new file mode 100644 index 0000000..5889418 --- /dev/null +++ b/saju-lab/app/calculator/compatibility.py @@ -0,0 +1,170 @@ +"""사주 궁합 — 두 사주 일주(일간 오행 + 일지) 매칭 → 점수 + breakdown. + +saju-web/app/compatibility/result/page.tsx의 `calculateCompatibility()` 로직과 1:1 매핑. + +알고리즘 (saju-web TS): + score = 50 (base) + # 일간 오행 관계 + if element1 == element2: score += 10 # same + elif relation in (produce, produced): score += 25 # 상생 + else: score -= 10 # 상극 (overcome/overcomed) + # 일지 관계 + if 六合 (sixHarmony): score += 20 + if 三合 (threeHarmony): score += 15 + if 沖 (conflict): score -= 20 + score = clamp(score, 0, 100) + +breakdown은 saju-lab 자체 확장 — 각 카테고리별 (score_delta, description) 제공. +""" +from __future__ import annotations + +from typing import Any, Dict + +from .constants import FIVE_ELEMENTS + + +# ─── 오행 상생/상극 맵 (saju-web과 동일) ────────────────────────────── +_PRODUCE_MAP = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"} +_OVERCOME_MAP = {"木": "土", "火": "金", "土": "水", "金": "木", "水": "火"} + + +# ─── 지지 6합/6충 (saju-web과 동일) ─────────────────────────────────── +_SIX_HARMONY: dict[str, str] = { + "子": "丑", "丑": "子", + "寅": "亥", "亥": "寅", + "卯": "戌", "戌": "卯", + "辰": "酉", "酉": "辰", + "巳": "申", "申": "巳", + "午": "未", "未": "午", +} + +# 삼합 그룹 (saju-web threeHarmonyGroups) +_THREE_HARMONY_GROUPS = [ + {"申", "子", "辰"}, + {"寅", "午", "戌"}, + {"亥", "卯", "未"}, + {"巳", "酉", "丑"}, +] + +_CONFLICT: dict[str, str] = { + "子": "午", "午": "子", + "丑": "未", "未": "丑", + "寅": "申", "申": "寅", + "卯": "酉", "酉": "卯", + "辰": "戌", "戌": "辰", + "巳": "亥", "亥": "巳", +} + + +# ─── 헬퍼 ───────────────────────────────────────────────────────── +def _get_element_relation(el1: str, el2: str) -> str: + """오행 관계 판별 — 'same' | 'produce' | 'produced' | 'overcome' | 'overcomed'. + + saju-web getElementRelation()과 1:1 매핑. + """ + if el1 == el2: + return "same" + if _PRODUCE_MAP.get(el1) == el2: + return "produce" # el1 → el2 (el1이 el2를 생함) + if _PRODUCE_MAP.get(el2) == el1: + return "produced" # el2 → el1 (el1이 el2로부터 생을 받음) + if _OVERCOME_MAP.get(el1) == el2: + return "overcome" # el1 → el2 (el1이 el2를 극함) + return "overcomed" # el2 → el1 (el1이 el2로부터 극을 받음) + + +def _get_branch_relation(b1: str, b2: str) -> dict: + """지지 관계 — {six_harmony, three_harmony, conflict}. + + saju-web getBranchRelation()과 1:1 매핑. + """ + is_three = any(b1 in g and b2 in g and b1 != b2 for g in _THREE_HARMONY_GROUPS) + return { + "six_harmony": _SIX_HARMONY.get(b1) == b2, + "three_harmony": is_three, + "conflict": _CONFLICT.get(b1) == b2, + } + + +def _day_master_element_delta(el1: str, el2: str) -> tuple[int, str, str]: + """일간 오행 매칭 delta + relation tag + description.""" + relation = _get_element_relation(el1, el2) + if relation == "same": + return 10, "same", f"일간 동일 오행 ({el1}={el2}) — 안정적이나 자극은 약함" + if relation in ("produce", "produced"): + return 25, relation, f"일간 상생 관계 ({el1}↔{el2}) — 서로 도움" + # overcome / overcomed + return -10, relation, f"일간 상극 관계 ({el1}↔{el2}) — 갈등 가능" + + +def _branch_interaction_delta(b1: str, b2: str) -> tuple[int, dict, str]: + """일지 6합/3합/충 delta + flags + description.""" + rel = _get_branch_relation(b1, b2) + delta = 0 + parts: list[str] = [] + if rel["six_harmony"]: + delta += 20 + parts.append(f"일지 6합 ({b1}+{b2})") + if rel["three_harmony"]: + delta += 15 + parts.append(f"일지 3합 ({b1}+{b2})") + if rel["conflict"]: + delta -= 20 + parts.append(f"일지 충 ({b1}↔{b2})") + if not parts: + parts.append(f"일지 중립 ({b1}, {b2})") + return delta, rel, " · ".join(parts) + + +# ─── 메인 ───────────────────────────────────────────────────────── +def calculate_compatibility(saju_a: Dict[str, Any], saju_b: Dict[str, Any]) -> Dict[str, Any]: + """두 사주 궁합 점수 (0~100) + breakdown. + + Args: + saju_a: calculate_saju() 결과 — `saju["day"]["stem"|"branch"|"element"]` 사용 + saju_b: 두 번째 사주 + + Returns: + { + "score": int (0~100), + "breakdown": { + "day_master_element": {"score": int, "relation": str, "description": str}, + "branch_interaction": {"score": int, "flags": dict, "description": str}, + "base": 50, + } + } + """ + day_a = saju_a["day"] + day_b = saju_b["day"] + + el1 = day_a.get("element") or FIVE_ELEMENTS[day_a["stem"]] + el2 = day_b.get("element") or FIVE_ELEMENTS[day_b["stem"]] + b1 = day_a["branch"] + b2 = day_b["branch"] + + elem_delta, elem_relation, elem_desc = _day_master_element_delta(el1, el2) + branch_delta, branch_flags, branch_desc = _branch_interaction_delta(b1, b2) + + raw = 50 + elem_delta + branch_delta + score = max(0, min(100, raw)) + + return { + "score": score, + "breakdown": { + "base": 50, + "day_master_element": { + "score": elem_delta, + "relation": elem_relation, + "element_a": el1, + "element_b": el2, + "description": elem_desc, + }, + "branch_interaction": { + "score": branch_delta, + "flags": branch_flags, + "branch_a": b1, + "branch_b": b2, + "description": branch_desc, + }, + }, + } diff --git a/saju-lab/tests/test_compatibility.py b/saju-lab/tests/test_compatibility.py new file mode 100644 index 0000000..0f68cce --- /dev/null +++ b/saju-lab/tests/test_compatibility.py @@ -0,0 +1,174 @@ +"""compatibility.py — 두 사주 궁합 점수 + breakdown 검증. + +saju-web/app/compatibility/result/page.tsx의 calculateCompatibility() 로직과 1:1 매핑. +알고리즘: + score = 50 (base) + + (same: +10 / produce|produced: +25 / overcome|overcomed: -10) + + (six_harmony: +20) + + (three_harmony: +15) + + (conflict: -20) + clamp(0, 100) +""" +from app.calculator.core import calculate_saju +from app.calculator.compatibility import ( + calculate_compatibility, + _get_element_relation, + _get_branch_relation, +) + + +# ─── 단위 헬퍼 테스트 ───────────────────────────────────────────── +def test_element_relation_same(): + assert _get_element_relation("金", "金") == "same" + + +def test_element_relation_produce(): + # 木 → 火 (목생화): el1 produces el2 + assert _get_element_relation("木", "火") == "produce" + + +def test_element_relation_produced(): + # 火 ← 木: el1 is produced by el2 + assert _get_element_relation("火", "木") == "produced" + + +def test_element_relation_overcome(): + # 金 → 木 (금극목): el1 overcomes el2 + assert _get_element_relation("金", "木") == "overcome" + + +def test_element_relation_overcomed(): + # 木 ← 金: el1 is overcomed by el2 + assert _get_element_relation("木", "金") == "overcomed" + + +def test_branch_six_harmony(): + # 寅亥 6합 + rel = _get_branch_relation("寅", "亥") + assert rel["six_harmony"] is True + assert rel["conflict"] is False + + +def test_branch_three_harmony(): + # 亥卯未 3합 — 亥+卯 + rel = _get_branch_relation("亥", "卯") + assert rel["three_harmony"] is True + + +def test_branch_conflict(): + # 子午 충 + rel = _get_branch_relation("子", "午") + assert rel["conflict"] is True + assert rel["six_harmony"] is False + + +def test_branch_same_not_three_harmony(): + # 같은 지지는 3합 아님 (서로 다른 지지여야 합) + rel = _get_branch_relation("未", "未") + assert rel["three_harmony"] is False + assert rel["six_harmony"] is False + assert rel["conflict"] is False + + +# ─── 통합: calculate_compatibility ──────────────────────────────── +def test_score_range_0_to_100(): + """모든 결과가 0~100 범위 + breakdown 키 존재.""" + a = calculate_saju(1990, 5, 15, 14, "male") + b = calculate_saju(1992, 8, 8, 18, "female") + result = calculate_compatibility(a, b) + assert 0 <= result["score"] <= 100 + assert "breakdown" in result + bd = result["breakdown"] + assert "day_master_element" in bd + assert "branch_interaction" in bd + assert "base" in bd + assert bd["base"] == 50 + + +def test_same_saju_same_element_no_branch_interaction(): + """1990-5-15 male+female (둘 다 辛未/金) — same element, 같은 일지(중립) → 50+10+0 = 60.""" + a = calculate_saju(1990, 5, 15, 14, "male") + b = calculate_saju(1990, 5, 15, 14, "female") + result = calculate_compatibility(a, b) + assert result["score"] == 60 + assert result["breakdown"]["day_master_element"]["relation"] == "same" + assert result["breakdown"]["day_master_element"]["score"] == 10 + assert result["breakdown"]["branch_interaction"]["score"] == 0 + + +def test_six_harmony_plus_produce_high_score(): + """1985-1-1 (辛卯/金) + 1989-7-10 (壬戌/水) + → 金生水 (produce) +25, 卯戌 6합 +20 → 50+25+20 = 95. + """ + a = calculate_saju(1985, 1, 1, 12, "male") + b = calculate_saju(1989, 7, 10, 12, "male") + result = calculate_compatibility(a, b) + assert result["score"] == 95 + bd = result["breakdown"] + assert bd["day_master_element"]["relation"] == "produce" + assert bd["day_master_element"]["score"] == 25 + assert bd["branch_interaction"]["flags"]["six_harmony"] is True + assert bd["branch_interaction"]["score"] == 20 + + +def test_conflict_plus_overcome_low_score(): + """1988-12-1 (辛巳/金) + 1992-3-21 (丁亥/火) + → 火→金 (overcomed) -10, 巳亥 충 -20 → 50-10-20 = 20. + """ + a = calculate_saju(1988, 12, 1, 8, "male") + b = calculate_saju(1992, 3, 21, 12, "female") + result = calculate_compatibility(a, b) + assert result["score"] == 20 + bd = result["breakdown"] + assert bd["day_master_element"]["relation"] in ("overcome", "overcomed") + assert bd["day_master_element"]["score"] == -10 + assert bd["branch_interaction"]["flags"]["conflict"] is True + assert bd["branch_interaction"]["score"] == -20 + + +def test_three_harmony_plus_overcome(): + """1990-5-15 (辛未/金) + 1992-3-21 (丁亥/火) + → 火→金 (overcomed) -10, 亥未 3합 +15 → 50-10+15 = 55. + """ + a = calculate_saju(1990, 5, 15, 14, "male") + b = calculate_saju(1992, 3, 21, 12, "female") + result = calculate_compatibility(a, b) + assert result["score"] == 55 + bd = result["breakdown"] + assert bd["day_master_element"]["score"] == -10 + assert bd["branch_interaction"]["flags"]["three_harmony"] is True + assert bd["branch_interaction"]["score"] == 15 + + +def test_score_clamp_at_zero(): + """극단 케이스: 상극(-10) + 충(-20) = 50-30 = 20. clamp 동작 검증을 위한 sanity.""" + a = calculate_saju(1988, 12, 1, 8, "male") + b = calculate_saju(1992, 3, 21, 12, "female") + result = calculate_compatibility(a, b) + assert result["score"] >= 0 + assert result["score"] <= 100 + + +def test_breakdown_descriptions_non_empty(): + """breakdown의 description 필드가 비어있지 않음.""" + a = calculate_saju(1990, 5, 15, 14, "male") + b = calculate_saju(1995, 8, 20, 12, "female") + result = calculate_compatibility(a, b) + bd = result["breakdown"] + assert isinstance(bd["day_master_element"]["description"], str) + assert len(bd["day_master_element"]["description"]) > 0 + assert isinstance(bd["branch_interaction"]["description"], str) + assert len(bd["branch_interaction"]["description"]) > 0 + + +def test_symmetry_score_only(): + """A vs B와 B vs A의 score는 동일 (saju-web과 동일하게 대칭). + + note: relation 태그(produce vs produced)는 비대칭일 수 있으나 + delta 점수는 동일하므로 최종 score는 항상 같다. + """ + a = calculate_saju(1985, 1, 1, 12, "male") + b = calculate_saju(1989, 7, 10, 12, "male") + r_ab = calculate_compatibility(a, b) + r_ba = calculate_compatibility(b, a) + assert r_ab["score"] == r_ba["score"]