diff --git a/scripts/reference_transparency_contract.py b/scripts/reference_transparency_contract.py index 491e1489..c4b8d24b 100644 --- a/scripts/reference_transparency_contract.py +++ b/scripts/reference_transparency_contract.py @@ -16,9 +16,11 @@ if str(SCRIPT_DIR) not in sys.path: try: from scripts.domain_calculation_service import compute_chart from scripts.timing_precision_contract import build_timing_precision_contract + from scripts.varga import calc_varga except ModuleNotFoundError: # pragma: no cover - direct script execution from domain_calculation_service import compute_chart from timing_precision_contract import build_timing_precision_contract + from varga import calc_varga ROOT = Path(__file__).resolve().parents[1] @@ -35,6 +37,10 @@ FEATURE_WEIGHTS = { "moon_sign": 0.30, "domain_lord_sign": 0.25, "node_axis": 0.10, + "d9_ascendant": 0.10, + "d9_venus": 0.10, + "d10_ascendant": 0.10, + "d10_sun": 0.10, } HIGH_SIMILARITY_THRESHOLD = 0.75 @@ -53,14 +59,45 @@ def _domain_lord_sign(chart: dict[str, Any], domain: str) -> str | None: return _sign(chart, lord) if isinstance(lord, str) else None +def _longitude(chart: dict[str, Any], key: str) -> float | None: + if key == "Ascendant": + value = chart.get("ascendant") if isinstance(chart, dict) else None + else: + planets = chart.get("planets") if isinstance(chart, dict) else None + value = planets.get(key) if isinstance(planets, dict) else None + if not isinstance(value, dict): + return None + try: + return float(value["lon"]) + except (KeyError, TypeError, ValueError): + return None + + +def _varga_sign(chart: dict[str, Any], key: str, division: int) -> str | None: + longitude = _longitude(chart, key) + return calc_varga(longitude, division)["sign"] if longitude is not None else None + + def _features(chart: dict[str, Any], domain: str) -> dict[str, Any]: ascendant = chart.get("ascendant") if isinstance(chart, dict) else None - return { + rahu, ketu = _sign(chart, "Rahu"), _sign(chart, "Ketu") + features = { "ascendant": ascendant.get("sign") if isinstance(ascendant, dict) else None, "moon_sign": _sign(chart, "Moon"), "domain_lord_sign": _domain_lord_sign(chart, domain), - "node_axis": (_sign(chart, "Rahu"), _sign(chart, "Ketu")), + "node_axis": (rahu, ketu) if rahu is not None and ketu is not None else None, } + if domain == "marriage": + features.update({ + "d9_ascendant": _varga_sign(chart, "Ascendant", 9), + "d9_venus": _varga_sign(chart, "Venus", 9), + }) + elif domain == "career": + features.update({ + "d10_ascendant": _varga_sign(chart, "Ascendant", 10), + "d10_sun": _varga_sign(chart, "Sun", 10), + }) + return features @lru_cache(maxsize=64) @@ -97,6 +134,8 @@ def _similarity(user_chart: dict[str, Any], case_chart: dict[str, Any], domain: candidate = _features(case_chart, domain) matching, dissimilar, total = [], [], 0.0 for name, weight in FEATURE_WEIGHTS.items(): + if name not in user or name not in candidate: + continue if user[name] is None or candidate[name] is None: continue total += weight @@ -105,12 +144,22 @@ def _similarity(user_chart: dict[str, Any], case_chart: dict[str, Any], domain: else: dissimilar.append(name) score = round(sum(FEATURE_WEIGHTS[name] for name in matching) / total, 3) if total else 0.0 + compared_vargas = [] + if domain == "marriage" and all(user.get(name) is not None and candidate.get(name) is not None for name in ("d9_ascendant", "d9_venus")): + compared_vargas.append("D9") + if domain == "career" and all(user.get(name) is not None and candidate.get(name) is not None for name in ("d10_ascendant", "d10_sun")): + compared_vargas.append("D10") + uncompared = ["dasha_event_state", "transit_event_state"] + if domain == "marriage" and "D9" not in compared_vargas: + uncompared.insert(0, "D9") + if domain == "career" and "D10" not in compared_vargas: + uncompared.insert(0, "D10") return { "score": score, "matching_factors": matching, "dissimilar_factors": dissimilar, - "feature_scope": "D1 ascendant, Moon, theme-house lord, and Rahu/Ketu axis only", - "uncompared_layers": ["D9", "D10", "dasha_event_state", "transit_event_state"], + "feature_scope": "D1 ascendant, Moon, theme-house lord, Rahu/Ketu axis" + (f", {'/'.join(compared_vargas)}" if compared_vargas else ""), + "uncompared_layers": uncompared, } diff --git a/tests/test_reference_transparency_contract.py b/tests/test_reference_transparency_contract.py index e6f63a6f..b26a3096 100644 --- a/tests/test_reference_transparency_contract.py +++ b/tests/test_reference_transparency_contract.py @@ -15,6 +15,21 @@ def _chart(ascendant: str, moon: str, domain_lord_sign: str) -> dict: "Moon": {"sign": moon}, "Saturn": {"sign": domain_lord_sign}, }, + "houses": {"house_7": {"lord": "Venus"}, "house_10": {"lord": "Saturn"}}, + } + + +def _varga_chart() -> dict: + return { + "ascendant": {"sign": "Leo", "lon": 149.0634}, + "planets": { + "Moon": {"sign": "Pisces", "lon": 344.5165}, + "Saturn": {"sign": "Libra", "lon": 207.9320}, + "Sun": {"sign": "Aquarius", "lon": 312.5175}, + "Venus": {"sign": "Sagittarius", "lon": 267.9412}, + "Rahu": {"sign": "Sagittarius", "lon": 249.2743}, + "Ketu": {"sign": "Gemini", "lon": 69.2743}, + }, "houses": {"house_10": {"lord": "Saturn"}}, } @@ -51,6 +66,7 @@ def test_select_similar_cases_shares_only_high_similarity_same_domain() -> None: assert selected["status"] == "high_similarity_public_references_available" assert [case["case_id"] for case in selected["cases"]] == ["matching_career_case"] assert selected["cases"][0]["similarity"]["score"] == 1.0 + assert "node_axis" not in selected["cases"][0]["similarity"]["matching_factors"] assert selected["cases"][0]["event_source"]["url"] == "https://example.com/event" assert selected["does_not_predict_user_outcome"] is True assert selected["coverage"] == { @@ -112,6 +128,44 @@ def test_pending_health_case_is_context_only_not_calibration() -> None: assert selected["coverage"]["available_event_domains"] == ["health"] +def test_career_similarity_adds_d10_only_when_both_charts_have_longitudes() -> None: + chart = _varga_chart() + cases = [{ + "case_id": "d10_match", "subject": {"name": "Public Example"}, "chart": chart, + "source": {"url": "https://example.com/birth", "source_grade": "primary"}, + "event_outcomes": [{ + "domain": "career", "event_type": "career_breakthrough", "event_date": "2007-01-09", + "outcome": "Public career event", "source": {"url": "https://example.com/event", "source_grade": "primary"}, + }], + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": False}, + }] + + selected = select_similar_public_cases(chart, ["career"], cases=cases) + + similarity = selected["cases"][0]["similarity"] + assert {"d10_ascendant", "d10_sun"}.issubset(similarity["matching_factors"]) + assert "D10" not in similarity["uncompared_layers"] + + +def test_marriage_similarity_adds_d9_only_when_both_charts_have_longitudes() -> None: + chart = _varga_chart() + cases = [{ + "case_id": "d9_match", "subject": {"name": "Public Example"}, "chart": chart, + "source": {"url": "https://example.com/birth", "source_grade": "primary"}, + "event_outcomes": [{ + "domain": "marriage", "event_type": "legal_marriage", "event_date": "2011-04-29", + "outcome": "Public marriage event", "source": {"url": "https://example.com/event", "source_grade": "primary"}, + }], + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": False}, + }] + + selected = select_similar_public_cases(chart, ["marriage"], cases=cases) + + similarity = selected["cases"][0]["similarity"] + assert {"d9_ascendant", "d9_venus"}.issubset(similarity["matching_factors"]) + assert "D9" not in similarity["uncompared_layers"] + + def test_default_manifest_exposes_kahlo_health_as_context_only() -> None: from scripts.domain_calculation_service import compute_chart