Files
Jyotisha/scripts/marriage_match.py
T
jesse-uxGrok (agent loop)Cursor
cf063834ca feat(synastry): store the upstream seven-item marriage match
Move the bde5d615 engine and full-synastry skill in unchanged, save both profiles without billing or a page, and retire the old synastry module.

Checked targeted pytest, tsc 0, lint 0 errors, service tests 6/6, and test:db 73 pass / 13 fail matching the Windows known list.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 16:44:05 +08:00

468 lines
20 KiB
Python

#!/usr/bin/env python3
"""User-facing romantic/marriage compatibility contract.
This module deliberately does not reuse the historical ``calc_kuja_dosha``
score. That function combines Mars with five pressure planets and contains a
male/female asymmetric comparison. It remains available for legacy reports,
but is not a valid default for a new relationship product.
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any, Dict, Iterable, Mapping, Optional
from ashtakoot import (
FRIENDSHIP,
NADI,
SIGN_LORDS,
SIGNS,
calculate_ashtakoot,
get_nakshatra,
)
PRODUCT_VERSION = "marriage_match_product_v1"
DEFAULT_PROFILE = "practitioner_consensus_v1"
VALID_RELATIONSHIP_TYPES = {"romantic_or_marriage", "romantic", "marriage"}
KUJA_PLANET = "Mars"
PAPASAMYA_PLANETS = ("Mars", "Saturn", "Sun", "Rahu", "Ketu")
# These profiles are deliberately qualitative. The consensus did not support
# an invented severity percentage or a sex-specific tolerance threshold.
RULE_PROFILES = {
"practitioner_consensus_v1": {
"label": "Practitioner consensus v1",
"kuja_houses": (1, 2, 4, 7, 8, 12),
"reference_frames": ("lagna", "moon", "venus"),
"exact_house_sign_exceptions": {
2: {"Gemini", "Virgo"},
4: {"Aries", "Scorpio"},
7: {"Capricorn", "Cancer"},
8: {"Sagittarius", "Pisces"},
12: {"Taurus", "Libra"},
},
"leo_aquarius_exception": "variant_only",
"own_or_exalted": "mitigates",
"jupiter_contact": "mitigates",
"moon_contact": "mitigates",
"papasamya_planets": PAPASAMYA_PLANETS,
"gender_asymmetry": False,
"notes": [
"Kuja is Mars-only; Papasamya is a separately reported five-planet pressure layer.",
"Lagna, Moon and Venus are shown separately; they are not converted into an opaque weighted score.",
"Own/exalted Mars and Jupiter or Moon contact mitigate a signal in this default profile; they do not erase the raw placement.",
"Age, caste and sex-asymmetric comparison rules are not used for a user-facing conclusion.",
],
},
"raman_muhurtha_v1": {
"label": "B. V. Raman Muhurtha variant",
"kuja_houses": (2, 4, 7, 8, 12),
"reference_frames": ("lagna", "moon", "venus"),
"exact_house_sign_exceptions": {
2: {"Gemini", "Virgo"},
4: {"Aries", "Scorpio"},
7: {"Capricorn", "Cancer"},
8: {"Sagittarius", "Pisces"},
12: {"Taurus", "Libra"},
},
"leo_aquarius_exception": "cancels",
"own_or_exalted": "mitigates",
"jupiter_contact": "mitigates",
"moon_contact": "mitigates",
"papasamya_planets": PAPASAMYA_PLANETS,
"gender_asymmetry": False,
"notes": [
"Raman-style house set omits the first house. It is a selectable lineage, not the product default.",
"The traditional Leo/Aquarius exception is applied only in this variant profile.",
],
},
}
def available_profiles() -> Dict[str, Dict[str, Any]]:
"""Return a presentation-safe copy of available rule profiles."""
return deepcopy(RULE_PROFILES)
def _profile(profile_id: str) -> Dict[str, Any]:
if profile_id not in RULE_PROFILES:
raise ValueError("Unknown marriage-match rule profile: %s" % profile_id)
return RULE_PROFILES[profile_id]
def _lon(value: Any) -> Optional[float]:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value) % 360.0
if isinstance(value, Mapping):
for key in ("longitude", "degree_raw", "degree", "lon"):
if isinstance(value.get(key), (int, float)):
return float(value[key]) % 360.0
return None
def _sign_index(value: Any) -> Optional[int]:
if isinstance(value, str) and value in SIGNS:
return SIGNS.index(value)
longitude = _lon(value)
return int(longitude // 30) if longitude is not None else None
def _planet_data(chart: Mapping[str, Any], planet: str) -> Mapping[str, Any]:
planets = chart.get("planets", {}) if isinstance(chart, Mapping) else {}
value = planets.get(planet, {}) if isinstance(planets, Mapping) else {}
return value if isinstance(value, Mapping) else {}
def _planet_lon(chart: Mapping[str, Any], planet: str) -> Optional[float]:
value = _planet_data(chart, planet)
longitude = _lon(value)
return longitude if longitude is not None else _lon(chart.get(planet + "_lon"))
def _reference_lon(chart: Mapping[str, Any], reference: str) -> Optional[float]:
if reference == "lagna":
for value in (chart.get("ascendant"), chart.get("lagna"), chart.get("asc_lon")):
longitude = _lon(value)
if longitude is not None:
return longitude
return None
return _planet_lon(chart, "Moon" if reference == "moon" else "Venus")
def _reference_sign(chart: Mapping[str, Any], reference: str) -> Optional[int]:
value = _reference_lon(chart, reference)
if value is not None:
return int(value // 30)
if reference == "lagna":
# Aries is index 0, which is a valid reference sign rather than a
# missing value. Do not use boolean fallback here.
ascendant_sign = _sign_index(chart.get("ascendant"))
return ascendant_sign if ascendant_sign is not None else _sign_index(chart.get("lagna"))
return _sign_index(_planet_data(chart, "Moon" if reference == "moon" else "Venus"))
def _planet_sign(chart: Mapping[str, Any], planet: str) -> Optional[int]:
lon = _planet_lon(chart, planet)
return int(lon // 30) if lon is not None else _sign_index(_planet_data(chart, planet))
def _house_from(reference_sign: Optional[int], planet_sign: Optional[int]) -> Optional[int]:
if reference_sign is None or planet_sign is None:
return None
return ((planet_sign - reference_sign) % 12) + 1
def _same_sign_contact(chart: Mapping[str, Any], planet_a: str, planet_b: str) -> bool:
a, b = _planet_sign(chart, planet_a), _planet_sign(chart, planet_b)
return a is not None and a == b
def _mars_dignity(chart: Mapping[str, Any]) -> str:
sign_idx = _planet_sign(chart, "Mars")
if sign_idx is None:
return "unknown"
sign = SIGNS[sign_idx]
if sign == "Capricorn":
return "exalted"
if sign in {"Aries", "Scorpio"}:
return "own"
if sign == "Cancer":
return "debilitated"
return "other"
def _frame_kuja(chart: Mapping[str, Any], reference: str, profile: Mapping[str, Any]) -> Dict[str, Any]:
mars_sign_idx = _planet_sign(chart, "Mars")
ref_sign_idx = _reference_sign(chart, reference)
house = _house_from(ref_sign_idx, mars_sign_idx)
frame = {
"reference": reference,
"reference_sign": SIGNS[ref_sign_idx] if ref_sign_idx is not None else None,
"mars_sign": SIGNS[mars_sign_idx] if mars_sign_idx is not None else None,
"mars_house": house,
"raw_signal": house in set(profile["kuja_houses"]) if house is not None else None,
"status": "not_available" if house is None else "not_triggered",
"mitigations": [],
}
if house is None or not frame["raw_signal"]:
return frame
mars_sign = frame["mars_sign"]
exact_exceptions = profile["exact_house_sign_exceptions"]
if mars_sign in exact_exceptions.get(house, set()):
frame["status"] = "cancelled_by_profile_exception"
frame["mitigations"].append("house_sign_exception")
return frame
if mars_sign in {"Leo", "Aquarius"}:
mode = profile["leo_aquarius_exception"]
if mode == "cancels":
frame["status"] = "cancelled_by_profile_exception"
frame["mitigations"].append("leo_aquarius_profile_exception")
return frame
if mode == "variant_only":
frame["mitigations"].append("leo_aquarius_exception_disputed")
frame["status"] = "active"
dignity = _mars_dignity(chart)
if dignity in {"own", "exalted"}:
frame["mitigations"].append("mars_%s" % dignity)
if _same_sign_contact(chart, "Mars", "Jupiter"):
frame["mitigations"].append("jupiter_conjunction")
if _same_sign_contact(chart, "Mars", "Moon"):
frame["mitigations"].append("moon_conjunction")
return frame
def analyze_kuja(chart: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
"""Analyze Mars-only Kuja / Mangal Dosha across visible reference frames."""
profile = _profile(profile_id)
if _planet_sign(chart, "Mars") is None:
return {
"system": "Kuja / Mangal Dosha",
"scope": "Mars only",
"profile_id": profile_id,
"status": "not_available",
"reason": "Mars position is required.",
"frames": [],
}
frames = [_frame_kuja(chart, ref, profile) for ref in profile["reference_frames"]]
active = [frame for frame in frames if frame["status"] == "active"]
cancelled = [frame for frame in frames if frame["status"] == "cancelled_by_profile_exception"]
severity = {0: "none", 1: "mild", 2: "material", 3: "strong"}[len(active)]
return {
"system": "Kuja / Mangal Dosha",
"scope": "Mars only",
"profile_id": profile_id,
"status": "executed",
"active_reference_count": len(active),
"cancelled_reference_count": len(cancelled),
"qualitative_level": severity,
"frames": frames,
"boundary": "Reference count is a presentation label, not a probability or a compatibility score.",
}
def analyze_papasamya(chart: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
"""Report five-planet pressure separately from Mars-only Kuja."""
profile = _profile(profile_id)
rows = []
for planet in profile["papasamya_planets"]:
planet_sign = _planet_sign(chart, planet)
if planet_sign is None:
rows.append({"planet": planet, "status": "not_available", "frames": []})
continue
frames = []
for reference in profile["reference_frames"]:
house = _house_from(_reference_sign(chart, reference), planet_sign)
frames.append({
"reference": reference,
"house": house,
"in_pressure_house": house in set(profile["kuja_houses"]) if house is not None else None,
})
rows.append({"planet": planet, "status": "executed", "frames": frames})
active = [
{"planet": row["planet"], "reference": frame["reference"], "house": frame["house"]}
for row in rows for frame in row.get("frames", []) if frame.get("in_pressure_house")
]
return {
"system": "Papasamya pressure",
"scope": "Mars, Saturn, Sun, Rahu and Ketu; separate from Kuja.",
"profile_id": profile_id,
"status": "executed" if any(row["status"] == "executed" for row in rows) else "not_available",
"planet_rows": rows,
"active_placements": active,
"boundary": "This is a descriptive pressure layer. It is not renamed as Mangal Dosha and is not reduced to a sex-weighted score.",
}
def compare_kuja(person_a: Mapping[str, Any], person_b: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
"""Compare two Kuja results without sex-specific tolerance or scoring."""
a, b = analyze_kuja(person_a, profile_id), analyze_kuja(person_b, profile_id)
if a["status"] != "executed" or b["status"] != "executed":
outcome = "incomplete"
else:
difference = abs(a["active_reference_count"] - b["active_reference_count"])
outcome = "not_triggered" if not a["active_reference_count"] and not b["active_reference_count"] else (
"broadly_balanced" if difference <= 1 else "asymmetric"
)
return {
"comparison_method": "neutral_reference_count",
"gender_asymmetry_used": False,
"outcome": outcome,
"person_a": a,
"person_b": b,
"boundary": "Both-active is a traditional balancing consideration, not an automatic approval or cancellation.",
}
def _moon_lon(chart: Mapping[str, Any]) -> Optional[float]:
longitude = _planet_lon(chart, "Moon")
return longitude if longitude is not None else _lon(chart.get("moon_lon"))
def _are_natural_friends(lord_a: str, lord_b: str) -> bool:
if lord_a == lord_b:
return True
return lord_b in FRIENDSHIP.get(lord_a, {}).get("friend", []) and lord_a in FRIENDSHIP.get(lord_b, {}).get("friend", [])
def _ashtakoota_with_exceptions(
person_a: Mapping[str, Any],
person_b: Mapping[str, Any],
) -> Dict[str, Any]:
moon_a, moon_b = _moon_lon(person_a), _moon_lon(person_b)
if moon_a is None or moon_b is None:
return {"status": "not_available", "reason": "Both Moon longitudes are required for Ashtakoota."}
forward = calculate_ashtakoot(moon_a, moon_b)
reverse = calculate_ashtakoot(moon_b, moon_a)
# The historical Varna point encodes caste hierarchy. It cannot truthfully
# be relabelled as a modern value judgement, so the international product
# excludes it rather than deriving a replacement from lunar signs.
score_names = ("Vashya", "Tara", "Yoni", "GrahaMaitri", "Gana", "Bhakoot", "Nadi")
symmetric_scores = {
name: round(
(float(forward["scores"][name]) + float(reverse["scores"][name])) / 2.0,
2,
)
for name in score_names
}
international_core = {
"method": "International Ashtakoota core v1 (both input orders averaged; classical Varna excluded)",
"scores": symmetric_scores,
"total_score": round(sum(symmetric_scores.values()), 2),
"max_score": 35.0,
"match_percentage": round(sum(symmetric_scores.values()) / 35.0 * 100, 1),
"person_a_details": {
"moon_sign": SIGNS[int(moon_a // 30)],
"nakshatra": get_nakshatra(moon_a)["name"],
"nakshatra_pada": get_nakshatra(moon_a)["pada"],
},
"person_b_details": {
"moon_sign": SIGNS[int(moon_b // 30)],
"nakshatra": get_nakshatra(moon_b)["name"],
"nakshatra_pada": get_nakshatra(moon_b)["pada"],
},
"directionality": {
"status": "symmetric",
"rule": "all included Kutas are averaged across person_a -> person_b and person_b -> person_a",
},
"boundary": "This is a mutual-reference score, not a caste hierarchy or an automatic relationship verdict.",
}
sign_a, sign_b = SIGNS[int(moon_a // 30)], SIGNS[int(moon_b // 30)]
nak_a, nak_b = get_nakshatra(moon_a), get_nakshatra(moon_b)
bhakoot_exceptions = []
if symmetric_scores["Bhakoot"] == 0:
lord_a, lord_b = SIGN_LORDS[sign_a], SIGN_LORDS[sign_b]
if lord_a == lord_b:
bhakoot_exceptions.append("same_sign_lord")
elif _are_natural_friends(lord_a, lord_b):
bhakoot_exceptions.append("mutual_natural_friend_sign_lords")
nadi_exceptions = []
if symmetric_scores["Nadi"] == 0:
if nak_a["index"] == nak_b["index"] and nak_a["pada"] != nak_b["pada"]:
nadi_exceptions.append("same_nakshatra_different_pada")
if sign_a == sign_b and nak_a["index"] != nak_b["index"]:
nadi_exceptions.append("same_rashi_different_nakshatra")
return {
"status": "executed",
"international_core": international_core,
"shared_life_dialogue": {
"status": "not_scored",
"storage": "no_answers_collected",
"prompts": [
"What does a committed relationship mean to each of you?",
"How do you want to make decisions about money, home, and care responsibilities?",
"What boundaries, privacy expectations, and conflict-repair habits matter most?",
"How do you each imagine work, family, location, and personal autonomy over the next few years?",
],
"boundary": "These are voluntary discussion prompts, not a compatibility test or a score.",
},
"classical_varna": {
"status": "excluded_from_default",
"reason": "Historical caste hierarchy is not used in the international product or its default score.",
},
"person_a": {"moon_sign": sign_a, "nakshatra": nak_a},
"person_b": {"moon_sign": sign_b, "nakshatra": nak_b},
"exception_review": {
"bhakoot": {
"raw_score": symmetric_scores["Bhakoot"],
"candidate_cancellations": bhakoot_exceptions,
"status": "candidate_mitigation" if bhakoot_exceptions else "none",
},
"nadi": {
"raw_score": symmetric_scores["Nadi"],
"candidate_cancellations": nadi_exceptions,
"status": "candidate_mitigation" if nadi_exceptions else "none",
},
"yoni": {
"raw_score": symmetric_scores["Yoni"],
"status": "legacy_three_band_table",
"boundary": "The legacy 4/2/0 table remains visible. Intermediate 3/1 grades require a separately versioned source table before becoming a default score.",
},
},
}
def _structure_status(chart: Mapping[str, Any]) -> Dict[str, Any]:
keys = {"d1_relationship", "d9", "upapada", "darakaraka", "relationship_dasha"}
supplied = [key for key in keys if chart.get(key) is not None]
return {
"status": "partial" if supplied else "not_available",
"supplied_layers": sorted(supplied),
"required_for_complete_reading": ["D1 relationship structure", "D9", "UL", "DK", "relationship timing / Dasha"],
"boundary": "36-point matching and Dosha layers do not replace D1/D9/UL/DK or timing review.",
}
def build_marriage_match_dossier(
person_a: Mapping[str, Any],
person_b: Mapping[str, Any],
*,
relationship_type: str = "romantic_or_marriage",
profile_id: str = DEFAULT_PROFILE,
) -> Dict[str, Any]:
"""Build the stable, user-facing love/marriage compatibility payload.
Inputs are chart snapshots only. The caller is responsible for calculating
D1/D9/timing snapshots; this function never stores birth details.
"""
if relationship_type not in VALID_RELATIONSHIP_TYPES:
raise ValueError("This product supports romantic/marriage matching only.")
profile = _profile(profile_id)
kuja = compare_kuja(person_a, person_b, profile_id)
ashtakoota = _ashtakoota_with_exceptions(person_a, person_b)
return {
"product": PRODUCT_VERSION,
"relationship_type": relationship_type,
"rule_profile": {
"id": profile_id,
"label": profile["label"],
"notes": profile["notes"],
"gender_asymmetry_used": False,
},
"ashtakoota_36": ashtakoota,
"kuja_mangal_dosha": kuja,
"papasamya": {
"person_a": analyze_papasamya(person_a, profile_id),
"person_b": analyze_papasamya(person_b, profile_id),
},
"relationship_structure": {
"person_a": _structure_status(person_a),
"person_b": _structure_status(person_b),
},
"presentation": {
"headline": "Relationship compatibility is presented as parallel traditional layers, not one approval score.",
"decision_boundary": "Do not let the 36-point score, Kuja, or Papasamya alone determine a relationship conclusion.",
"not_in_scope": ["business partnerships", "family matching", "age-based cancellation", "sex-asymmetric tolerance"],
},
"audit": {
"kuja": "executed: Mars-only, Lagna/Moon/Venus frames where data is supplied",
"papasamya": "executed separately: five-planet pressure placement review",
"ashtakoota": ashtakoota["status"],
"d1_d9_ul_dk_timing": "input-dependent; no missing layer is fabricated",
},
}