docs(tasks): marriage match follows upstream bde5d615 (35-point core, order-independent)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
This commit is contained in:
Jesse_Chen
2026-10-18 13:04:46 +08:00
co-authored by Claude Opus 5.5
parent bafa296829
commit c4c983cf2a
5 changed files with 163 additions and 35 deletions
@@ -137,7 +137,10 @@ def _reference_sign(chart: Mapping[str, Any], reference: str) -> Optional[int]:
if value is not None:
return int(value // 30)
if reference == "lagna":
return _sign_index(chart.get("ascendant")) or _sign_index(chart.get("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"))
@@ -296,7 +299,8 @@ def compare_kuja(person_a: Mapping[str, Any], person_b: Mapping[str, Any], profi
def _moon_lon(chart: Mapping[str, Any]) -> Optional[float]:
return _planet_lon(chart, "Moon") or _lon(chart.get("moon_lon"))
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:
@@ -305,44 +309,96 @@ def _are_natural_friends(lord_a: str, lord_b: str) -> bool:
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]:
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."}
raw = calculate_ashtakoot(moon_a, moon_b)
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 raw["scores"]["Bhakoot"] == 0:
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 raw["scores"]["Nadi"] == 0:
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",
"raw_36_point": raw,
"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": raw["scores"]["Bhakoot"],
"raw_score": symmetric_scores["Bhakoot"],
"candidate_cancellations": bhakoot_exceptions,
"status": "candidate_mitigation" if bhakoot_exceptions else "none",
},
"nadi": {
"raw_score": raw["scores"]["Nadi"],
"raw_score": symmetric_scores["Nadi"],
"candidate_cancellations": nadi_exceptions,
"status": "candidate_mitigation" if nadi_exceptions else "none",
},
"yoni": {
"raw_score": raw["scores"]["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.",
},
@@ -9,18 +9,25 @@ which person is labelled male or female.
## Product Layers
1. **Ashtakoota (36 points)**: retain the raw Moon-based score and each Kuta.
It is one compatibility lens, not a relationship decision.
2. **Exception review**: show Bhakoot and Nadi cancellation candidates without
1. **International Ashtakoota core (35 points)**: calculate both input orders
and average every included Kuta before display, so swapping two people
cannot change the result. The historical `Varna` point is excluded, not
renamed: it encodes caste hierarchy and cannot truthfully be inferred as a
modern value judgement from lunar signs. It remains one compatibility lens,
not a relationship decision.
2. **Shared-life dialogue (not scored)**: the product offers voluntary prompts
about commitment, money, home, care, boundaries, and autonomy. It neither
collects answers nor turns them into a compatibility score.
3. **Exception review**: show Bhakoot and Nadi cancellation candidates without
silently changing the raw score. Yoni remains visibly marked as a legacy
three-band table until a versioned 14x14 intermediate-score source table is
admitted.
3. **Kuja / Mangal Dosha**: Mars only, inspected independently from Lagna,
4. **Kuja / Mangal Dosha**: Mars only, inspected independently from Lagna,
Moon, and Venus. The product exposes every reference-frame placement and
mitigation rather than creating a percentage score.
4. **Papasamya**: Mars, Saturn, Sun, Rahu, and Ketu are reported as a separate
5. **Papasamya**: Mars, Saturn, Sun, Rahu, and Ketu are reported as a separate
pressure layer. It is never labelled Kuja Dosha.
5. **Relationship structure and timing**: D1, D9, UL, DK, and relationship
6. **Relationship structure and timing**: D1, D9, UL, DK, and relationship
Dasha are input-dependent. Missing data is reported as unavailable rather
than replaced with an inferred result.
@@ -70,10 +77,12 @@ and input-dependent layers.
## Customer Presentation Rules
- Show the 36-point total as a compact summary, with the eight component rows.
- Keep the 35-point Moon/nakshatra reference layer out of the headline; show it
only as compact supporting evidence when the user requests the traditional
component rows.
- Render Kuja and Papasamya as separate cards with their selected profile.
- State that an active signal is neither a diagnosis nor a prediction.
- Do not make an automatic approval/rejection from 36 points, Kuja, or
- Do not make an automatic approval/rejection from the matching layer, Kuja, or
Papasamya alone.
- If a requested D1/D9/UL/DK/timing layer was not supplied, show it as missing
instead of suppressing the section.
@@ -66,6 +66,69 @@ def test_bhakoot_and_nadi_exceptions_are_exposed_without_rewriting_raw_score():
assert "same_nakshatra_different_pada" in nadi["candidate_cancellations"]
def test_international_core_is_order_independent_and_excludes_caste_point():
a = chart(0, 30, 60, 180)
b = chart(90, 120, 150, 270)
forward = build_marriage_match_dossier(a, b)["ashtakoota_36"]["international_core"]
reverse = build_marriage_match_dossier(b, a)["ashtakoota_36"]["international_core"]
assert forward["total_score"] == reverse["total_score"]
assert forward["scores"] == reverse["scores"]
assert forward["max_score"] == 35.0
assert "ValuesOrientation" not in forward["scores"]
assert "Varna" not in forward["scores"]
dialogue = build_marriage_match_dossier(a, b)["ashtakoota_36"]["shared_life_dialogue"]
assert dialogue["status"] == "not_scored"
def test_reported_directional_reproduction_is_order_independent_without_varna():
# The historical directional calculation yields 10 vs 9 when 45 and 200
# degrees are swapped because it includes Varna. The product core omits
# Varna and averages every included Kuta, so input order is immaterial.
a = chart(0, 45, 80, 120)
b = chart(90, 200, 170, 210)
forward = build_marriage_match_dossier(a, b)["ashtakoota_36"]["international_core"]
reverse = build_marriage_match_dossier(b, a)["ashtakoota_36"]["international_core"]
assert forward["total_score"] == reverse["total_score"] == 9.0
assert forward["scores"] == reverse["scores"]
assert forward["directionality"]["status"] == "symmetric"
def test_zero_degree_moon_is_a_valid_ashtakoota_input():
result = build_marriage_match_dossier(chart(0, 0, 20, 180), chart(90, 120, 150, 270))
assert result["ashtakoota_36"]["status"] == "executed"
assert result["ashtakoota_36"]["international_core"]["person_a_details"]["moon_sign"] == "Aries"
def test_aries_named_ascendant_is_a_valid_lagna_reference():
# Aries is sign index 0 and must not be discarded by a boolean fallback.
person_a = {"ascendant": "Aries", "planets": {"Mars": {"longitude": 120.0}}}
person_b = {"ascendant": "Taurus", "planets": {"Mars": {"longitude": 240.0}}}
kuja = build_marriage_match_dossier(person_a, person_b)["kuja_mangal_dosha"]
lagna_frame = kuja["person_a"]["frames"][0]
assert lagna_frame["reference"] == "lagna"
assert lagna_frame["reference_sign"] == "Aries"
assert lagna_frame["mars_house"] == 5
assert lagna_frame["status"] == "not_triggered"
def test_moon_only_quick_reference_stays_neutral_and_marks_missing_pressure_layers():
result = build_marriage_match_dossier(
{"planets": {"Moon": {"longitude": 0.0}}},
{"planets": {"Moon": {"longitude": 120.0}}},
)
assert result["ashtakoota_36"]["status"] == "executed"
assert result["ashtakoota_36"]["international_core"]["total_score"] == 18.5
assert result["kuja_mangal_dosha"]["person_a"]["status"] == "not_available"
assert result["kuja_mangal_dosha"]["person_b"]["status"] == "not_available"
def test_shared_life_dialogue_is_not_a_score_or_questionnaire_store():
dialogue = build_marriage_match_dossier(chart(0, 30, 60, 180), chart(90, 120, 150, 270))["ashtakoota_36"]["shared_life_dialogue"]
assert dialogue["status"] == "not_scored"
assert dialogue["storage"] == "no_answers_collected"
assert dialogue["prompts"]
def test_non_romantic_scope_is_rejected():
with pytest.raises(ValueError, match="romantic/marriage"):
build_marriage_match_dossier(chart(0, 1, 20, 180), chart(90, 4, 120, 270), relationship_type="business")