feat(saju-lab): compatibility.py — 두 사주 궁합 점수 + breakdown
- saju-web/app/compatibility/result/page.tsx의 calculateCompatibility() 1:1 매핑 - 알고리즘: base 50 ± (일간 오행 same/produce/overcome) ± (일지 6합/3합/충), clamp [0,100] - breakdown: day_master_element + branch_interaction (delta + relation/flags + description) - 17 unit tests passed (헬퍼 9개 + 통합 8개), 438/438 total Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
170
saju-lab/app/calculator/compatibility.py
Normal file
170
saju-lab/app/calculator/compatibility.py
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
174
saju-lab/tests/test_compatibility.py
Normal file
174
saju-lab/tests/test_compatibility.py
Normal file
@@ -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"]
|
||||
Reference in New Issue
Block a user