diff --git a/saju-lab/app/calculator/analysis.py b/saju-lab/app/calculator/analysis.py new file mode 100644 index 0000000..325d178 --- /dev/null +++ b/saju-lab/app/calculator/analysis.py @@ -0,0 +1,446 @@ +"""사주 종합 분석 — 오행/신강신약/용신/세운/perform_full_analysis. + +saju-web/lib/ai-interpretation.ts 와 1:1 매핑. + +핵심 함수: +- calculate_detailed_element_balance: 가중치 오행 점수 (천간 1.0, 본기 1.0, 중기 0.5, 여기 0.3) +- calculate_element_score: 비율 (%) +- analyze_day_master_strength: 신강/신약/중화 + score(정수) + reasons(텍스트 리스트) +- estimate_yongshin: 용신/희신/기신 + explanation +- calculate_seun: 올해(주어진 year) 세운 + 일주/년월시 충/합 매핑 +- perform_full_analysis: 위 모두 + branch_interactions + shinsal + gongmang + hidden_stems + +출력 키는 snake_case. pillar 값은 한글(`세운`/`년주`/`월주`/`일주`/`시주`). +""" +from __future__ import annotations + +import math +from typing import Optional + +from .constants import ( + FIVE_ELEMENTS, + FIVE_ELEMENTS_KR, + HIDDEN_STEMS, +) +from .shinsal import ( + get_hidden_stems, + get_all_hidden_stems, + analyze_branch_interactions, + calculate_shinsal, + calculate_gongmang, +) +from .core import get_year_ganzi + + +# ─── 상생/상극 맵 (TS PRODUCE_MAP / OVERCOME_MAP 동일) ───────────── +_PRODUCE_MAP: dict[str, str] = { + "木": "火", "火": "土", "土": "金", "金": "水", "水": "木", +} + +_OVERCOME_MAP: dict[str, str] = { + "木": "土", "火": "金", "土": "水", "金": "木", "水": "火", +} + + +def _get_producing_element(elem: str) -> str: + """`elem`을 생하는 오행 (= 인성). TS getProducingElement 동일.""" + for k, v in _PRODUCE_MAP.items(): + if v == elem: + return k + return "" + + +def _get_overcoming_me(elem: str) -> str: + """`elem`을 극하는 오행 (= 관살). TS getOvercomingMe 동일.""" + for k, v in _OVERCOME_MAP.items(): + if v == elem: + return k + return "" + + +def _js_round(x: float) -> int: + """JavaScript Math.round 호환 — half-away-from-zero (양수에서는 half-up). + + Python의 round()는 banker's rounding(half-to-even)을 쓰므로 호환을 위해 우회. + 오행 점수는 항상 ≥ 0 이므로 floor(x + 0.5) 로 충분. + """ + if x >= 0: + return math.floor(x + 0.5) + return -math.floor(-x + 0.5) + + +# ─── 오행 밸런스 ────────────────────────────────────────────────── +def calculate_detailed_element_balance(saju: dict) -> dict: + """가중치 적용 오행 점수. + + - 천간 stem: 1.0 per pillar + - 지지 hidden_stems: 본기 1.0, 중기 0.5, 여기 0.3 + - 결과는 소수점 둘째 자리로 반올림 (TS Math.round(x*100)/100). + """ + balance: dict[str, float] = {"木": 0.0, "火": 0.0, "土": 0.0, "金": 0.0, "水": 0.0} + + # 천간 + stems = [saju["year"]["stem"], saju["month"]["stem"], saju["day"]["stem"]] + if saju.get("hour") is not None: + stems.append(saju["hour"]["stem"]) + + for stem in stems: + elem = FIVE_ELEMENTS.get(stem) + if elem in balance: + balance[elem] += 1.0 + + # 지지 지장간 + branches = [saju["year"]["branch"], saju["month"]["branch"], saju["day"]["branch"]] + if saju.get("hour") is not None: + branches.append(saju["hour"]["branch"]) + + weights = [1.0, 0.5, 0.3] + for branch in branches: + hidden = get_hidden_stems(branch) + for i, hs in enumerate(hidden): + elem = FIVE_ELEMENTS.get(hs) + if elem in balance: + w = weights[i] if i < len(weights) else 0.3 + balance[elem] += w + + # 소수점 둘째 자리 반올림 (TS Math.round(x*100)/100) + for k in list(balance.keys()): + balance[k] = _js_round(balance[k] * 100) / 100 + + return balance + + +def calculate_element_score(saju: dict) -> dict: + """오행 비율 (%). TS: Math.round((value / total) * 100).""" + balance = calculate_detailed_element_balance(saju) + total = sum(balance.values()) + + scores: dict[str, int] = {} + for elem, value in balance.items(): + if total > 0: + scores[elem] = _js_round((value / total) * 100) + else: + scores[elem] = 0 + return scores + + +# ─── 신강/신약 분석 ─────────────────────────────────────────────── +def analyze_day_master_strength(saju: dict) -> dict: + """신강/신약/중화 판단 — TS analyzeDayMasterStrength 와 1:1 동일. + + score 가산/감산 규칙: + 1. 월령 득령 + - 월지 본기 == 일간 오행: +3 ("월령 득령: 월지 X(Y)의 본기가 일간과 같은 ...") + - 월지 본기 == 인성: +2 ("월령 득령: 월지 X(Y)의 본기가 일간을 생하는 ...") + - 기타: -2 ("월령 실령: 월지 X(Y)의 본기가 일간을 돕지 않음") + 2. 통근 (지지 지장간 중 일간/인성 오행 포함된 지지 개수) + - >=3: +2 / >=2: +1 / 그 외: -1 + 3. 투출 (년주/월주/시주 천간 중 일간/인성 오행 개수, 일주 제외) + - >=2: +2 / ==1: +1 / 0: -1 + 4. 오행 비율 (조력 > 약화 * 1.3 또는 반대) + - +1 / -1 + + 결과: score >= 3 → 신강, score <= -2 → 신약, else 중화. + """ + day_stem = saju["day_stem"] + day_element = FIVE_ELEMENTS[day_stem] + producing_element = _get_producing_element(day_element) + reasons: list[str] = [] + score = 0 + + # 1. 월령 득령 + month_branch = saju["month"]["branch"] + month_branch_kr = saju["month"]["branch_kr"] + month_hidden = get_hidden_stems(month_branch) + month_main_element = FIVE_ELEMENTS[month_hidden[0]] + + if month_main_element == day_element: + score += 3 + reasons.append( + f"월령 득령: 월지 {month_branch_kr}({month_branch})의 본기가 일간과 같은 " + f"{FIVE_ELEMENTS_KR[day_element]}으로 강한 힘을 받음" + ) + elif month_main_element == producing_element: + score += 2 + reasons.append( + f"월령 득령: 월지 {month_branch_kr}({month_branch})의 본기가 일간을 생하는 " + f"{FIVE_ELEMENTS_KR[producing_element]}으로 힘을 받음" + ) + else: + score -= 2 + reasons.append( + f"월령 실령: 월지 {month_branch_kr}({month_branch})의 본기가 일간을 돕지 않음" + ) + + # 2. 통근 (4주 지지 모두 검사) + all_branches = [saju["year"]["branch"], saju["month"]["branch"], saju["day"]["branch"]] + if saju.get("hour") is not None: + all_branches.append(saju["hour"]["branch"]) + + root_count = 0 + for branch in all_branches: + hidden = get_hidden_stems(branch) + for h in hidden: + h_elem = FIVE_ELEMENTS.get(h) + if h_elem == day_element or h_elem == producing_element: + root_count += 1 + break + + if root_count >= 3: + score += 2 + reasons.append(f"통근 강함: {root_count}개 지지에서 일간의 뿌리를 찾음") + elif root_count >= 2: + score += 1 + reasons.append(f"통근 보통: {root_count}개 지지에서 일간의 뿌리를 찾음") + else: + score -= 1 + reasons.append(f"통근 약함: {root_count}개 지지에서만 일간의 뿌리를 찾음") + + # 3. 투출 (년주/월주 천간 + 시주 천간, 일주 제외) + all_stems = [saju["year"]["stem"], saju["month"]["stem"]] + if saju.get("hour") is not None: + all_stems.append(saju["hour"]["stem"]) + + helping_stem_count = 0 + for stem in all_stems: + stem_elem = FIVE_ELEMENTS.get(stem) + if stem_elem == day_element or stem_elem == producing_element: + helping_stem_count += 1 + + if helping_stem_count >= 2: + score += 2 + reasons.append( + f"투출 강함: 천간에 비겁/인성이 {helping_stem_count}개 있어 일간을 도움" + ) + elif helping_stem_count == 1: + score += 1 + reasons.append("투출 보통: 천간에 비겁/인성이 1개 있음") + else: + score -= 1 + reasons.append("투출 없음: 천간에 일간을 돕는 비겁/인성이 없음") + + # 4. 오행 비율 (조력 vs 약화) + balance = calculate_detailed_element_balance(saju) + helping_score = balance.get(day_element, 0.0) + balance.get(producing_element, 0.0) + draining_score = sum( + v for k, v in balance.items() + if k != day_element and k != producing_element + ) + + if helping_score > draining_score * 1.3: + score += 1 + reasons.append( + f"오행 비율: 비겁+인성({helping_score:.1f}) > 식상+재관({draining_score:.1f}) " + f"→ 일간 세력 우세" + ) + elif draining_score > helping_score * 1.3: + score -= 1 + reasons.append( + f"오행 비율: 식상+재관({draining_score:.1f}) > 비겁+인성({helping_score:.1f}) " + f"→ 일간 세력 열세" + ) + + if score >= 3: + result = "신강" + elif score <= -2: + result = "신약" + else: + result = "중화" + + return {"result": result, "score": score, "reasons": reasons} + + +# ─── 용신 추정 ──────────────────────────────────────────────────── +def estimate_yongshin(saju: dict, strength: dict) -> dict: + """용신/희신/기신 추정 — TS estimateYongShin 와 1:1 동일. + + 신강: + 용신/희신 = 식상/재성/관살 중 balance 가장 낮은 2개 (점수 오름차순) + 기신 = 일간 오행 (비겁) + 신약: + 용신/희신 = 인성/비겁 중 balance 낮은 2개 + 기신 = 관살 + 중화: + 용신/희신 = 모든 오행 중 balance 낮은 2개 + 기신 = balance 가장 높은 오행 + """ + day_element = FIVE_ELEMENTS[saju["day_stem"]] + balance = calculate_detailed_element_balance(saju) + + producing_me = _get_producing_element(day_element) # 인성 + my_product = _PRODUCE_MAP[day_element] # 식상 + my_overcome = _OVERCOME_MAP[day_element] # 재성 + overcome_me = _get_overcoming_me(day_element) # 관살 + + def kr(e: str) -> str: + return FIVE_ELEMENTS_KR.get(e, e) + + result = strength["result"] + + if result == "신강": + candidates = [ + {"elem": my_product, "score": balance.get(my_product, 0.0), "name": "식상"}, + {"elem": my_overcome, "score": balance.get(my_overcome, 0.0), "name": "재성"}, + {"elem": overcome_me, "score": balance.get(overcome_me, 0.0), "name": "관살"}, + ] + candidates.sort(key=lambda x: x["score"]) + yong = candidates[0] + hee = candidates[1] + + return { + "yong_shin": yong["elem"], "yong_shin_kr": kr(yong["elem"]), + "hee_shin": hee["elem"], "hee_shin_kr": kr(hee["elem"]), + "gi_shin": day_element, "gi_shin_kr": kr(day_element), + "explanation": ( + f"신강한 사주로 일간의 힘이 넘치므로 {yong['name']}({kr(yong['elem'])}) " + f"기운을 용신으로 삼아 기운을 설기(泄氣)하거나 제어해야 합니다. " + f"{hee['name']}({kr(hee['elem'])})이 희신으로 보조합니다." + ), + } + elif result == "신약": + candidates = [ + {"elem": producing_me, "score": balance.get(producing_me, 0.0), "name": "인성"}, + {"elem": day_element, "score": balance.get(day_element, 0.0), "name": "비겁"}, + ] + candidates.sort(key=lambda x: x["score"]) + yong = candidates[0] + hee = candidates[1] + + return { + "yong_shin": yong["elem"], "yong_shin_kr": kr(yong["elem"]), + "hee_shin": hee["elem"], "hee_shin_kr": kr(hee["elem"]), + "gi_shin": overcome_me, "gi_shin_kr": kr(overcome_me), + "explanation": ( + f"신약한 사주로 일간의 힘이 부족하므로 {yong['name']}({kr(yong['elem'])}) " + f"기운을 용신으로 삼아 일간을 돕고 힘을 보충해야 합니다. " + f"{hee['name']}({kr(hee['elem'])})이 희신으로 보조합니다." + ), + } + else: + # 중화 — Object.entries 순서 (TS): 木, 火, 土, 金, 水 + entries = [(e, balance[e]) for e in ["木", "火", "土", "金", "水"]] + # Array.prototype.sort 는 안정 정렬(Node v12+). Python sort 도 안정 정렬이므로 동일. + entries.sort(key=lambda x: x[1]) + yong = entries[0] + hee = entries[1] + gi = entries[-1] + + return { + "yong_shin": yong[0], "yong_shin_kr": kr(yong[0]), + "hee_shin": hee[0], "hee_shin_kr": kr(hee[0]), + "gi_shin": gi[0], "gi_shin_kr": kr(gi[0]), + "explanation": ( + f"중화에 가까운 사주로 오행이 비교적 균형을 이루고 있습니다. " + f"가장 부족한 {kr(yong[0])}({yong[0]}) 기운을 보충하면 더욱 좋아집니다." + ), + } + + +# ─── 세운 ───────────────────────────────────────────────────────── +_SEUN_CHUNG_PAIRS: list[tuple[str, str]] = [ + ("子", "午"), ("丑", "未"), ("寅", "申"), + ("卯", "酉"), ("辰", "戌"), ("巳", "亥"), +] + +_SEUN_YUKAP_PAIRS: list[tuple[str, str, str]] = [ + ("子", "丑", "土"), ("寅", "亥", "木"), ("卯", "戌", "火"), + ("辰", "酉", "金"), ("巳", "申", "水"), ("午", "未", "火"), +] + + +def calculate_seun(year: int, saju: dict) -> dict: + """올해(주어진 year) 세운 정보 + 일주/년월시 지지와의 충/합 매핑. + + TS calculateSeun 와 1:1 동일. interactions 는 충(沖) 먼저 -> 합(合) 순서. + """ + ganzi = get_year_ganzi(year) + element = FIVE_ELEMENTS[ganzi["stem"]] + element_kr = FIVE_ELEMENTS_KR[element] + + seun_branch = ganzi["branch"] + seun_branch_kr = ganzi["branch_kr"] + + pillar_branches: list[dict] = [ + {"branch": saju["year"]["branch"], "branch_kr": saju["year"]["branch_kr"], "pillar": "년주"}, + {"branch": saju["month"]["branch"], "branch_kr": saju["month"]["branch_kr"], "pillar": "월주"}, + {"branch": saju["day"]["branch"], "branch_kr": saju["day"]["branch_kr"], "pillar": "일주"}, + ] + if saju.get("hour") is not None: + pillar_branches.append({ + "branch": saju["hour"]["branch"], + "branch_kr": saju["hour"]["branch_kr"], + "pillar": "시주", + }) + + interactions: list[dict] = [] + + # 충 (沖) + for a, b in _SEUN_CHUNG_PAIRS: + for pb in pillar_branches: + if (seun_branch == a and pb["branch"] == b) or (seun_branch == b and pb["branch"] == a): + interactions.append({ + "type": "충(沖)", + "branches": [seun_branch, pb["branch"]], + "branches_kr": [seun_branch_kr, pb["branch_kr"]], + "pillars": ["세운", pb["pillar"]], + "description": ( + f"세운 {seun_branch_kr}와 {pb['pillar']} {pb['branch_kr']}가 충 " + f"→ 해당 영역에 변동과 변화가 예상됨." + ), + }) + + # 합 (六合) + for a, b, elem in _SEUN_YUKAP_PAIRS: + for pb in pillar_branches: + if (seun_branch == a and pb["branch"] == b) or (seun_branch == b and pb["branch"] == a): + interactions.append({ + "type": "합(合)", + "branches": [seun_branch, pb["branch"]], + "branches_kr": [seun_branch_kr, pb["branch_kr"]], + "pillars": ["세운", pb["pillar"]], + "description": ( + f"세운 {seun_branch_kr}와 {pb['pillar']} {pb['branch_kr']}가 합 " + f"→ 해당 영역에 조화와 좋은 인연이 기대됨." + ), + "result_element": elem, + }) + + return { + "stem": ganzi["stem"], + "branch": ganzi["branch"], + "stem_kr": ganzi["stem_kr"], + "branch_kr": ganzi["branch_kr"], + "element": element, + "element_kr": element_kr, + "year": year, + "interactions": interactions, + } + + +# ─── 통합 분석 ──────────────────────────────────────────────────── +def perform_full_analysis(saju: dict, current_year: int) -> dict: + """모든 분석 통합 — TS performFullAnalysis 와 1:1 동일. + + 반환 키는 snake_case. + """ + element_balance = calculate_detailed_element_balance(saju) + element_scores = calculate_element_score(saju) + day_master_strength = analyze_day_master_strength(saju) + yong_shin = estimate_yongshin(saju, day_master_strength) + branch_interactions = analyze_branch_interactions(saju) + shinsal = calculate_shinsal(saju) + gongmang = calculate_gongmang(saju["day_stem"], saju["day"]["branch"]) + seun = calculate_seun(current_year, saju) + hidden_stems = get_all_hidden_stems(saju) + + return { + "element_balance": element_balance, + "element_scores": element_scores, + "day_master_strength": day_master_strength, + "yong_shin": yong_shin, + "branch_interactions": branch_interactions, + "shinsal": shinsal, + "gongmang": gongmang, + "seun": seun, + "hidden_stems": hidden_stems, + } diff --git a/saju-lab/tests/test_analysis.py b/saju-lab/tests/test_analysis.py new file mode 100644 index 0000000..8c3afdc --- /dev/null +++ b/saju-lab/tests/test_analysis.py @@ -0,0 +1,182 @@ +"""analysis.py — 오행 점수/신강신약/용신/세운/perform_full_analysis 검증. + +fixtures/reference_saju.json 의 30 case (TS) 와 Python 구현 1:1 일치 검증. +""" +import json +from pathlib import Path + +import pytest + +from app.calculator.core import calculate_saju +from app.calculator.analysis import ( + calculate_detailed_element_balance, + calculate_element_score, + analyze_day_master_strength, + estimate_yongshin, + calculate_seun, + perform_full_analysis, +) + + +REF_PATH = Path(__file__).parent / "fixtures" / "reference_saju.json" +REF = json.loads(REF_PATH.read_text(encoding="utf-8")) + + +def _camel_to_snake(name: str) -> str: + out = [] + for ch in name: + if ch.isupper(): + out.append("_" + ch.lower()) + else: + out.append(ch) + return "".join(out) + + +def _normalize(d): + """TS camelCase → Python snake_case (deep). 값은 변경하지 않음.""" + if isinstance(d, dict): + return {_camel_to_snake(k): _normalize(v) for k, v in d.items()} + if isinstance(d, list): + return [_normalize(x) for x in d] + return d + + +def _canonical(item): + """dict/list 비교용 — 안정 정렬을 위한 JSON 직렬화.""" + return json.dumps(item, ensure_ascii=False, sort_keys=True) + + +# ─── 1. element_balance reference 매칭 ─────────────────────────────── +@pytest.mark.parametrize( + "case", + REF, + ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}", +) +def test_element_balance_match_reference(case): + inp = case["input"] + expected = case["expected"]["analysis"]["elementBalance"] + + saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]) + actual = calculate_detailed_element_balance(saju) + + for elem in ["木", "火", "土", "金", "水"]: + assert abs(actual[elem] - expected[elem]) < 0.01, ( + f"{inp}: element_balance.{elem} differ: {actual[elem]} vs {expected[elem]}" + ) + + +# ─── 2. element_scores reference 매칭 ──────────────────────────────── +@pytest.mark.parametrize( + "case", + REF, + ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}", +) +def test_element_score_match_reference(case): + inp = case["input"] + expected = case["expected"]["analysis"]["elementScores"] + + saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]) + actual = calculate_element_score(saju) + + for elem, expected_pct in expected.items(): + actual_pct = actual.get(elem, 0) + assert actual_pct == expected_pct, ( + f"{inp}: element_scores.{elem}: {actual_pct} vs {expected_pct}" + ) + + +# ─── 3. day_master_strength reference 매칭 ─────────────────────────── +@pytest.mark.parametrize( + "case", + REF, + ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}", +) +def test_day_master_strength_match_reference(case): + inp = case["input"] + expected = case["expected"]["analysis"]["dayMasterStrength"] + + saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]) + actual = analyze_day_master_strength(saju) + + assert actual["result"] == expected["result"], ( + f"{inp}: result mismatch: {actual['result']} vs {expected['result']}" + ) + assert actual["score"] == expected["score"], ( + f"{inp}: score mismatch: {actual['score']} vs {expected['score']}" + ) + assert actual["reasons"] == expected["reasons"], ( + f"{inp}: reasons mismatch:\n actual={actual['reasons']}\n expected={expected['reasons']}" + ) + + +# ─── 4. yong_shin reference 매칭 ───────────────────────────────────── +@pytest.mark.parametrize( + "case", + REF, + ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}", +) +def test_yong_shin_match_reference(case): + inp = case["input"] + expected_raw = case["expected"]["analysis"]["yongShin"] + expected = _normalize(expected_raw) + + saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]) + strength = analyze_day_master_strength(saju) + actual = estimate_yongshin(saju, strength) + + assert actual["yong_shin"] == expected["yong_shin"] + assert actual["yong_shin_kr"] == expected["yong_shin_kr"] + assert actual["hee_shin"] == expected["hee_shin"] + assert actual["hee_shin_kr"] == expected["hee_shin_kr"] + assert actual["gi_shin"] == expected["gi_shin"] + assert actual["gi_shin_kr"] == expected["gi_shin_kr"] + assert actual["explanation"] == expected["explanation"] + + +# ─── 5. seun reference 매칭 ───────────────────────────────────────── +@pytest.mark.parametrize( + "case", + REF, + ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}", +) +def test_seun_match_reference(case): + inp = case["input"] + expected_raw = case["expected"]["analysis"]["seun"] + expected = _normalize(expected_raw) + + saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]) + actual = calculate_seun(2026, saju) + + assert actual["stem"] == expected["stem"] + assert actual["branch"] == expected["branch"] + assert actual["stem_kr"] == expected["stem_kr"] + assert actual["branch_kr"] == expected["branch_kr"] + assert actual["element"] == expected["element"] + assert actual["element_kr"] == expected["element_kr"] + assert actual["year"] == expected["year"] + # interactions — 순서 무관 비교 + actual_iter = actual.get("interactions", []) + expected_iter = expected.get("interactions", []) + assert sorted(map(_canonical, actual_iter)) == sorted(map(_canonical, expected_iter)), ( + f"{inp}: seun interactions mismatch:\n actual={actual_iter}\n expected={expected_iter}" + ) + + +# ─── 6. perform_full_analysis 통합 ─────────────────────────────────── +@pytest.mark.parametrize( + "case", + REF, + ids=lambda c: f"{c['input']['year']}-{c['input']['month']:02d}-{c['input']['day']:02d}", +) +def test_perform_full_analysis_keys(case): + """perform_full_analysis 가 모든 expected 키를 반환하는지 확인.""" + inp = case["input"] + saju = calculate_saju(inp["year"], inp["month"], inp["day"], inp.get("hour"), inp["gender"]) + actual = perform_full_analysis(saju, 2026) + + # 통합 결과에 필수 키 모두 존재 + for key in [ + "element_balance", "element_scores", "day_master_strength", "yong_shin", + "branch_interactions", "shinsal", "gongmang", "seun", "hidden_stems", + ]: + assert key in actual, f"{inp}: missing key {key}"