From bafa2968295d0775f0ee13d9a807697f41d9b729 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 17 Oct 2026 22:09:33 +0800 Subject: [PATCH] docs(tasks): marriage match engine ports upstream marriage_match.py Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v --- .../marriage_match.py.txt | 411 ++++++++++++++++++ .../marriage_match_product_contract.md | 79 ++++ .../test_marriage_match_product.py.txt | 83 ++++ docs/tasks/README.md | 2 +- docs/tasks/TASK-marriage-match-p1-20261017.md | 53 +-- 5 files changed, 595 insertions(+), 33 deletions(-) create mode 100644 docs/research/upstream_marriage_match_3c01a214/marriage_match.py.txt create mode 100644 docs/research/upstream_marriage_match_3c01a214/marriage_match_product_contract.md create mode 100644 docs/research/upstream_marriage_match_3c01a214/test_marriage_match_product.py.txt diff --git a/docs/research/upstream_marriage_match_3c01a214/marriage_match.py.txt b/docs/research/upstream_marriage_match_3c01a214/marriage_match.py.txt new file mode 100644 index 00000000..838eda61 --- /dev/null +++ b/docs/research/upstream_marriage_match_3c01a214/marriage_match.py.txt @@ -0,0 +1,411 @@ +#!/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": + return _sign_index(chart.get("ascendant")) or _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]: + return _planet_lon(chart, "Moon") or _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."} + raw = calculate_ashtakoot(moon_a, moon_b) + 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: + 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 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, + "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"], + "candidate_cancellations": bhakoot_exceptions, + "status": "candidate_mitigation" if bhakoot_exceptions else "none", + }, + "nadi": { + "raw_score": raw["scores"]["Nadi"], + "candidate_cancellations": nadi_exceptions, + "status": "candidate_mitigation" if nadi_exceptions else "none", + }, + "yoni": { + "raw_score": raw["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", + }, + } diff --git a/docs/research/upstream_marriage_match_3c01a214/marriage_match_product_contract.md b/docs/research/upstream_marriage_match_3c01a214/marriage_match_product_contract.md new file mode 100644 index 00000000..96bed1d8 --- /dev/null +++ b/docs/research/upstream_marriage_match_3c01a214/marriage_match_product_contract.md @@ -0,0 +1,79 @@ +# Love / Marriage Compatibility Product Contract + +## Scope + +This product is for romantic compatibility and marriage matching only. It does +not score business partners, family members, caste, age, or sexual orientation. +The two input people are always `person_a` and `person_b`; no result depends on +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 + 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, + 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 + pressure layer. It is never labelled Kuja Dosha. +5. **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. + +## Rule Profiles + +`practitioner_consensus_v1` is the default. It uses the common 1/2/4/7/8/12 +Kuja house set and displays Lagna, Moon, and Venus separately. It does not use +gender-asymmetric tolerance, age cancellation, caste, or a numerical +compatibility delta. + +`raman_muhurtha_v1` is a visible alternative lineage: it omits the first house +and applies its documented Leo/Aquarius exception. It must be selected +explicitly and never replaces the default result silently. + +## Input and API Contract + +The API is `POST /api/marriage-match` and accepts only already calculated chart +snapshots; it does not accept or store raw birth records: + +```json +{ + "profile_id": "practitioner_consensus_v1", + "relationship_type": "romantic_or_marriage", + "person_a": { + "ascendant": {"longitude": 0.0}, + "planets": { + "Moon": {"longitude": 30.0}, + "Venus": {"longitude": 60.0}, + "Mars": {"longitude": 180.0} + } + }, + "person_b": {"ascendant": {"longitude": 90.0}, "planets": {"Moon": {"longitude": 120.0}, "Venus": {"longitude": 150.0}, "Mars": {"longitude": 270.0}}} +} +``` + +The equivalent calculator command is: + +```bash +python3 scripts/jyotish_engine.py marriage-match \ + --a-asc 0 --a-moon 30 --a-venus 60 --a-mars 180 \ + --b-asc 90 --b-moon 120 --b-venus 150 --b-mars 270 +``` + +Optional five-pressure-planet longitudes can be supplied to both calls. The +response includes an `audit` block that distinguishes executed, unavailable, +and input-dependent layers. + +## Customer Presentation Rules + +- Show the 36-point total as a compact summary, with the eight 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 + Papasamya alone. +- If a requested D1/D9/UL/DK/timing layer was not supplied, show it as missing + instead of suppressing the section. diff --git a/docs/research/upstream_marriage_match_3c01a214/test_marriage_match_product.py.txt b/docs/research/upstream_marriage_match_3c01a214/test_marriage_match_product.py.txt new file mode 100644 index 00000000..099161ba --- /dev/null +++ b/docs/research/upstream_marriage_match_3c01a214/test_marriage_match_product.py.txt @@ -0,0 +1,83 @@ +from __future__ import annotations + +import os +import sys + +import pytest + +SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts") +if SCRIPTS not in sys.path: + sys.path.insert(0, SCRIPTS) + +from marriage_match import build_marriage_match_dossier + + +def chart(asc, moon, venus, mars, *, saturn=210, sun=120, rahu=300, ketu=120): + return { + "ascendant": {"longitude": asc}, + "planets": { + "Moon": {"longitude": moon}, "Venus": {"longitude": venus}, + "Mars": {"longitude": mars}, "Saturn": {"longitude": saturn}, + "Sun": {"longitude": sun}, "Rahu": {"longitude": rahu}, "Ketu": {"longitude": ketu}, + }, + } + + +def test_default_profile_separates_mars_kuja_from_papasamya(): + a = chart(0, 30, 60, 180, saturn=180) + b = chart(90, 120, 150, 270, saturn=270) + report = build_marriage_match_dossier(a, b) + assert report["rule_profile"]["id"] == "practitioner_consensus_v1" + assert report["kuja_mangal_dosha"]["person_a"]["scope"] == "Mars only" + pressure = report["papasamya"]["person_a"] + assert {row["planet"] for row in pressure["planet_rows"]} == {"Mars", "Saturn", "Sun", "Rahu", "Ketu"} + assert "score" not in report["kuja_mangal_dosha"] + + +def test_kuja_comparison_is_gender_neutral_and_not_numeric_tolerance(): + a = chart(0, 30, 60, 180) + b = chart(90, 120, 150, 270) + forward = build_marriage_match_dossier(a, b)["kuja_mangal_dosha"] + reverse = build_marriage_match_dossier(b, a)["kuja_mangal_dosha"] + assert forward["gender_asymmetry_used"] is False + assert forward["outcome"] == reverse["outcome"] + assert forward["comparison_method"] == "neutral_reference_count" + + +def test_raman_variant_is_visible_and_first_house_difference_is_explicit(): + # Mars in the first house from each reference frame: default sees it; + # Raman's selected lineage does not. + same_sign = chart(0, 0, 0, 0) + default = build_marriage_match_dossier(same_sign, same_sign) + raman = build_marriage_match_dossier(same_sign, same_sign, profile_id="raman_muhurtha_v1") + assert default["kuja_mangal_dosha"]["person_a"]["active_reference_count"] == 3 + assert raman["kuja_mangal_dosha"]["person_a"]["active_reference_count"] == 0 + assert raman["rule_profile"]["id"] == "raman_muhurtha_v1" + + +def test_bhakoot_and_nadi_exceptions_are_exposed_without_rewriting_raw_score(): + # Moon longitudes both in Ashwini but different padas: same Nadi raw zero, + # candidate cancellation stays a separately auditable flag. + a = chart(0, 1, 20, 180) + b = chart(90, 4, 120, 270) + report = build_marriage_match_dossier(a, b) + nadi = report["ashtakoota_36"]["exception_review"]["nadi"] + assert nadi["raw_score"] == 0 + assert "same_nakshatra_different_pada" in nadi["candidate_cancellations"] + + +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") + + +def test_api_handler_uses_the_new_product_contract_without_legacy_kuja_score(): + from jyotish_api_server import JyotishAPIHandler + + payload = { + "person_a": chart(0, 1, 20, 180), + "person_b": chart(90, 4, 120, 270), + } + result = JyotishAPIHandler._compute_marriage_match(None, payload) + assert result["product"] == "marriage_match_product_v1" + assert result["kuja_mangal_dosha"]["gender_asymmetry_used"] is False diff --git a/docs/tasks/README.md b/docs/tasks/README.md index ddc9fe9e..cdd2aa71 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -501,4 +501,4 @@ | `TASK-data-layer-types-20261013.md` | `PROGRESS-data-layer-types-20261013.md` | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1351;任务书预留的 1343 已被更大编号越过) | 待验收 | `codex/data-layer-types-20261013` | | `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` | | `TASK-people-archive-limit-20-20261017.md` | — | 星盘档案上限 5→20 人(触发器新迁移 + 常量 + 文案;婚恋合盘前置单) | 待领取 | `codex/people-archive-limit-20-20261017`;动表,需 test:db | -| `TASK-marriage-match-p1-20261017.md` | — | 婚恋合盘第一期:独立页、任意两人、从业者共识口径(分档并列)、按报告扣点(BUG-1370 起) | 排队(等上限单合入) | `codex/marriage-match-p1-20261017`;动表 | +| `TASK-marriage-match-p1-20261017.md` | — | 婚恋合盘第一期:独立页、任意两人、直接搬上游占星师 marriage_match.py(3c01a214)、按报告扣点(BUG-1370 起) | 排队(等上限单合入) | `codex/marriage-match-p1-20261017`;动表 | diff --git a/docs/tasks/TASK-marriage-match-p1-20261017.md b/docs/tasks/TASK-marriage-match-p1-20261017.md index 17536fa5..5acad4ea 100644 --- a/docs/tasks/TASK-marriage-match-p1-20261017.md +++ b/docs/tasks/TASK-marriage-match-p1-20261017.md @@ -5,7 +5,7 @@ - `origin/staging` = `f95eb670b`(开工前 `git fetch origin --prune`,以最新 `origin/staging` 为准) - 分支:`codex/marriage-match-p1-20261017`;工作树:`.worktrees/marriage-match-p1-20261017` - **前置**:`TASK-people-archive-limit-20-20261017.md` 必须先合入 staging。两单都改 `components/people/people-page.tsx`,本单在它之后从最新 `origin/staging` 开分支。 -- 原型(v4,规则已按共识口径改):https://claude.ai/artifact/Tq6vhXFMnGitDZhJdamaHe 。原型里的人名、分数、宫位全是虚构示例。 +- 原型(v5,火星冲克按上游模块口径):https://claude.ai/artifact/Tq6vhXFMnGitDZhJdamaHe 。原型里的人名、分数、宫位全是虚构示例。 ## 现状实证(按符号定位) @@ -39,40 +39,26 @@ | D6 | 副标题定稿:「看这段感情往后的走向,和两人之间的助力与考验。」页面任何地方不写「合不合」「会不会在一起」 | | D7 | 新增星盘入口放在两个选人下拉的**最底一行**「+ 新增星盘」,本页弹出与星盘档案同一张新建表,保存后自动选进这一格;档案满时这一行变灰 | | D8 | 「开始合盘」按钮居中,点数说明在按钮正下方 | -| D9 | **规则口径定为「从业者共识 + 分歧并列」`practitioner_consensus_v1`**(2026-10-17 第二版)。占星师复核意见:这些规则不能只按一本书定,参与的信息很多,不能粗暴下断语,不要刻板。所以不再以 Raman《Muhurtha》一书为准,改为按印度占星从业者公开资料的共识分档:**广泛公认的作默认,常见的、有争议的都列出来并注明分档**。**推翻**同日第一版的 `raman_muhurtha_v1`,以及现有 `calc_kuja_dosha` 的五星合分、旺弱打分表、`match_kuja_dosha` 的性别不对称阈值。旧打分与性别不对称不得出现在新功能里,也不作为并列口径展示(产品 10-17 同意) | -| D10 | **规则有分歧时并列展示**:每条命中的规则都标出分档(广泛公认 / 常见 / 有争议),并写明按另一派结果会变成什么,不悄悄只选一个。轻重一律用文字,不出百分比,也不出伪精确分数 | +| D9 | **引擎直接搬上游占星师的实现**(产品 2026-10-17 第三版):`yinduzhanxing` main `3c01a2148e82741dca62547a0ec1bab0b252766e` 的 `scripts/marriage_match.py`(默认 profile `practitioner_consensus_v1`,可选 `raman_muhurtha_v1`)原样搬入,口径以它为准。**推翻**本任务书前两版自写的规则:同日第一版的 `raman_muhurtha_v1` 细则,以及第二版自写的化解条件分档表、「未见 / 轻 / 明显」归档规则、Yoni 五档表。仍然推翻现有 `calc_kuja_dosha` 的五星合分与 `match_kuja_dosha` 的性别不对称(上游模块本身也不用它们) | +| D10 | **规则有分歧时并列展示**:默认 profile 的结果在前;`raman_muhurtha_v1` 与默认结果不同的地方,用「按 Raman 一派:……」并列写出,不悄悄替换。化解条件按调研记录 `docs/research/marriage_match_practitioner_consensus_2026_10_17.md` 加上分档标签(只影响展示,不改上游结果) | | D11 | 第二期(本单不做、不留占位):两人大运同步「什么时候顺」、就这对关系开对话、导出报告、非婚恋关系 | -### 规则口径 `practitioner_consensus_v1` +### 引擎口径(上游 `marriage_match.py`,摘要) -调研记录:`docs/research/marriage_match_practitioner_consensus_2026_10_17.md`(与本任务书同批推送,逐条附来源链接)。公开资料多为商业站互相转录,经典出处多是二手转引。所以分档只说明「从业者有多常用」,不说明「对不对」。 - -**火星冲克(Kuja / Mangal Dosha)**:只看火星。 - -| 项 | 默认 | 同时列出 | +| 层 | 上游行为(照搬) | 我们在外面加的 | | --- | --- | --- | -| 参照点 | 从上升、月亮、金星三处分别数,逐处列出「从哪里数、落第几宫」。**不给权重** | — | -| 犯宫 | 1、2、4、7、8、12(多数采用) | 每处触发都注明「不计 2 宫的版本(1/4/7/8/12)下是否仍算」「不计 1 宫的版本(2/4/7/8/12,Raman)下是否仍算」 | -| 化解:广泛公认 | 火星本垣(白羊、天蝎)或入旺(摩羯);木星与火星同宫或木星相位火星 | — | -| 化解:常见 | 水星 / 月亮 / 金星与火星同宫或相位;宫 × 星座例外(2 宫双子/处女、4 宫白羊/天蝎、7 宫巨蟹/摩羯、8 宫射手/双鱼、12 宫金牛/天秤,各家星座略有出入);巨蟹或狮子上升(火星为 Yogakaraka);火星在动星座 | 写明「常见,各家写法不一」 | -| 化解:有争议 | 火星落巨蟹(落陷)、逆行、燃烧;火星在狮子或水瓶;28 / 30 岁后减弱(多处明说无经典依据) | 写明「有争议」,不参与轻重判断,只列出 | -| 单人轻重(文字) | **未见**:三处都没触发,或每处触发都被广泛公认的条件化解;**轻**:触发只被常见条件化解,或只在某一宫位版本下成立;**明显**:至少一处触发没有任何广泛公认或常见的化解 | 页面脚注写明:这是计算层的归纳,不是定论,要结合整盘看 | -| 两人比较 | 两人都「明显」或都「轻」→ 相互抵消(广泛公认,前提是程度相当);一方「明显」、另一方「未见」→ 需要留意,并提示看婚姻宫压力是否平衡。**不分男女** | — | +| 36 分 | `calculate_ashtakoot(moon_a, moon_b)`,`person_a` 当男方。原始分不改;Bhakoot(同主星、互为朋友)与 Nadi(同星宿不同 pada、同星座不同星宿)只给 `candidate_cancellations` | **按档案性别把男方放 `person_a`**(D5:同性或缺性别按选择顺序)。页面表尾两个合计:原始合计、计入候选例外后的合计 | +| 性情 Yoni | 保留旧 4 / 2 / 0 三档,标 `legacy_three_band_table`;中间档要等有正式出处的完整表 | 页面注明「沿用三档旧表,中间档待定」 | +| 火星冲克 | 只看火星;从上升、月亮、金星三处;默认犯宫 1/2/4/7/8/12(Raman:2/4/7/8/12);宫 × 星座例外表直接取消该处;本垣 / 入旺、木星同宫、月亮同宫列为 mitigation,**不改变该处是否触发**;狮子 / 水瓶默认只标 disputed(Raman 下取消)。轻重 = 触发处数 0/1/2/3 → none / mild / material / strong;两人比较 = 处数差 ≤ 1 为 broadly_balanced,否则 asymmetric,不分男女 | 中文:无 / 轻 / 明显 / 重;相当 / 一方偏重。mitigation 中文名 + 分档标签:本垣、入旺、木星同宫(广泛公认),月亮同宫、宫 × 星座例外(常见),狮子 / 水瓶(有争议)。每处写「减轻,不改变这一处」 | +| 婚姻宫压力 | 火星、土星、太阳、罗睺、计都,从三处数,用 profile 的宫位集;只列出 | 两人处数的一句比较(大致均衡 / 一方明显偏重,差 ≥ 3 处或两倍以上),不出加权分 | +| 各自婚姻征象 | `relationship_structure` 只报告调用方传进来了哪些层(`d1_relationship`、`d9`、`upapada`、`darakaraka`、`relationship_dasha`),不计算 | 我们用 `relationship_analysis.analyze_relationship` 算 D1 第 7 宫主、金星、D9、UL、DK,作为快照字段传入;`relationship_dasha` 第一期不传(第二期) | +| 输入 | 只收已算好的盘快照(上升、各行星经度),不收出生资料 | Next 侧先并行算两张盘(沿用 BUG-1333 的并行 + 跳过外部概览),再组快照调用 | -**婚姻宫压力(Papasamya)**:多为南印做法。火星、土星、太阳、罗睺落在从上升、月亮、金星数的 1、2、4、7、8、12 宫,逐处列出;计都单列一行,标「部分派别计入」。两人比较只写「大致均衡 / 一方明显偏重」。**不出加权总分**(各派权重不同,经典没有公布分表)。南印习惯里「女方不得多于男方」的比较带性别不对称,不采用,脚注说明。单独一节,不叫火星冲克。 +**结论措辞**(产品层,不进上游模块):落点三档「助力多于考验」「有几处需要留意,建议结合整盘细看」「考验较多,建议请占星师细看」。规则:火星冲克 `asymmetric`,或任一方「重」→ 至少第二档;同时原始 36 分低于 18 且火星冲克 `asymmetric` → 第三档;其余第一档。36 分只作一层参考。 -**八项配对(36 分)** - -| 项 | 默认 | 同时列出 | -| --- | --- | --- | -| 性情 Yoni | 同类 4、七对死敌 0(牛—虎、象—狮、马—水牛、犬—鹿、猫—鼠、蛇—獴、羊—猴),广泛公认。中间档 3 / 2 / 1 用一张版本化 14 × 14 表计分(以 PyJHora `YoniArray` 对照写入、注明出处),页面用文字写「友好 / 中性 / 略不合」 | 注明「中间档各家表不同」;南印只判是否死敌,并列一行「南印:是 / 否死敌」 | -| 月宫距离 Bhakoot | 原始分照算照显示,并标类型(2/12、5/9、6/8)。两人月宫同一主星、或两主星互为朋友 → 例外成立(广泛公认 / 多数采用),单独标出;**不改原始分** | 九分盘主星友好、其他项满分可抵:标「部分占星师接受」 | -| 脉 Nadi | 原始分照算。例外(广泛公认):同一月亮星座不同星宿;同一星宿不同月亮星座。多数采用:同一星宿不同 pada | 月宫主星友好:标「少数」;豁免星宿名单:标「有争议」,并写明出自哪份名单 | -| 合计 | 表尾并列两个数:原始合计、计入广泛公认例外后的合计。**不显示及格分档**:常见及格线是 18,但各家分别用 18 / 25 / 27 / 28,脚注写「满分 36,常见及格线 18,各家不一」 | — | - -`ashtakoot.py` 现有的附加项(Mahendra、Stree Deergha、Vedha、Rajju、BadConstellations)保留展示,例外按上面同样的分档标注。 - -**结论措辞**:三句话总述的落点只用三档:「助力多于考验」「有几处需要留意,建议结合整盘细看」「考验较多,建议请占星师细看」。火星冲克一方「明显」另一方「未见」时,至少落到第二档。总述以各自婚姻征象(D1 第 7 宫、D9、UL、DK)为主,36 分只作一层参考。 +**上游两个已知 bug(10-17 已报给占星师)**,搬入时在本地修掉,并在 PROGRESS 里记成「与上游差异」,等上游修好后再对齐: +1. 结果依赖 A / B 顺序(种姓等有方向的项);我们靠「男方放 `person_a`」规避,**不改上游算法**。 +2. `_moon_lon`、`_reference_sign` 里的 `or` 把 0 当缺失:月亮在 0° 时 36 分 `not_available`;上升只给星座名「Aries」时上升一处 `not_available`。本地改成显式 `is None` 判断,各补一条测试。 ## 硬红线 @@ -90,12 +76,12 @@ | # | 内容 | 验收标准 | | --- | --- | --- | -| T1 | **引擎**:新模块实现 `practitioner_consensus_v1`:火星冲克(三参照点 × 三种宫位版本 × 命中的化解条件及其分档)、婚姻宫压力、八项(Yoni 表、Bhakoot / Nadi 例外分档)、单人婚姻征象(复用 `relationship_analysis.analyze_relationship`:7 宫主、金星、D9、UL、DK)、D9 对照(两人 D9 上升、D9 金星、D9 上升相隔几宫)。输入两份出生资料 + 性别,一次请求内算完(占重计算并发位)。每条规则带 `consensus: broad / common / disputed` 和来源键,来源键对应调研记录里的条目 | 新 pytest:每条化解条件至少一例且分档正确;宫位三版本差异各一例;单人「未见 / 轻 / 明显」与两人比较各至少一例;有争议的条件不改变轻重;Yoni 14 × 14 与 PyJHora 对照全等(测试里可 import PyJHora,缺时 skip 并记录),七对死敌为 0;Bhakoot / Nadi 例外不改原始分;至少 20 对虚构出生资料的八项原始分与 PyJHora North 方法对照,差异逐条解释 | -| T2 | **Python 接口**:新端点(如 `POST /api/marriage_match`)薄注册;旧 `/api/synastry` 保留给 MCP / CLI 兼容但不再被网页调用 | `tests/test_api_server_growth_contract.py` 绿;端点 golden 一份进 `frontend/tests/fixtures/` | +| T1 | **引擎**:把上游 `scripts/marriage_match.py`(`3c01a214`,本仓副本在 `docs/research/upstream_marriage_match_3c01a214/`)原样搬入,只做「已知 bug 2」的本地修复;搬入上游 `tests/test_marriage_match_product.py`。新增本地胶水模块(建议 `scripts/marriage_match_snapshot.py`):两份已算好的盘 + 性别 → 按 D5 定 `person_a` / `person_b` → 用 `relationship_analysis.analyze_relationship` 补各自婚姻征象快照 → 默认与 Raman 两个 profile 各调一次 `build_marriage_match_dossier` | 上游 6 条测试全过;新增:0° 月亮、白羊上升(星座名)各一条;男女顺序按性别(女方选在第一格时仍放 `person_b`);两个 profile 结果都在;`diff` 上游文件只剩 bug 2 的改动(写进 PROGRESS) | +| T2 | **Python 接口**:`POST /api/marriage-match`(与上游同名)薄注册,在独立模块里实现,`jyotish_api_server.py` 只加注册行;旧 `/api/synastry` 保留给 MCP / CLI 兼容但不再被网页调用;CLI 加 `marriage-match` 命令(与上游参数一致) | `tests/test_api_server_growth_contract.py` 绿;端点 golden 一份进 `frontend/tests/fixtures/` | | T3 | **退役与文档纠错**:删 `scripts/synastry.py`,CLI `synastry` 改走新模块或删除(同步 `SKILL.md` 命令表、`tests/run_all.py`、`tests/test_open_source_integrations.py`);`ashtakoot.py` 的 `calc_kuja_dosha` / `match_kuja_dosha` 标为旧版、不被新功能调用;删掉三处 Papasamya / Mangal 匹配虚报 | 全量 pytest 与基线逐条比对无新增失败;`git grep "synastry.calc_ashtakoot"` 0 命中 | | T4 | **数据**:`synastry_reports` 加列——两人的人物引用(本人用约定值,他人用 `chart_profiles.id`)、两人出生资料指纹、规则版本 `rules_version`、结构化结果;旧行不迁移、不显示 | `npm run test:db`:同一对人同指纹第二次读到旧行;任一人改出生资料后指纹不同;删人后其合盘行消失 | | T5 | **Next 接口**:重写 `app/api/synastry/route.ts`(或新路由,旧的删):收两个人物 id → `loadSubjectBirth` 两次 → 查已保存(命中即返回,不扣点)→ 预留 `report.full` → 调 T2 → 存 → 结算;性别按 D5;保留产品开关 `compatibility` 与限流桶;删掉 `relationshipType` 与 business / family / general 分支 | 路由测试覆盖:命中缓存不扣点;计算失败释放不扣;两个 id 相同 400;不属于本人的 id 拒绝;同性 / 缺性别按顺序定男方 | -| T6 | **页面**:侧栏「婚恋合盘」;`/marriage-match`(路由名可议,写进 DESIGN)选人页(两个自定义下拉,底行「+ 新增星盘」复用星盘档案新建表;居中按钮;点数来自定价不写死;之前的合盘列表只列「人名 × 人名 · 日期」,点开不扣点);结果页按原型 v4:三句话总述 → 各自的婚姻征象 → 八项配对(含例外绿字 / 另一派灰字、两个合计)→ 火星冲克(每人逐处列出触发与化解,化解条件带分档标签;两人对比)→ 婚姻宫压力 → 脚注(口径来源、分档含义、「计算层归纳不是定论」) | 渲染测试:两个状态(两人火星冲克相当 / 一方明显)的 golden 渲染;20 人时下拉可滚动;手机 390 px 无横向滚动;`next build` 后 `/` 仍 Static,首屏 gzip ±2% | +| T6 | **页面**:侧栏「婚恋合盘」;`/marriage-match`(路由名可议,写进 DESIGN)选人页(两个自定义下拉,底行「+ 新增星盘」复用星盘档案新建表;居中按钮;点数来自定价不写死;之前的合盘列表只列「人名 × 人名 · 日期」,点开不扣点);结果页按原型 v5:三句话总述 → 各自的婚姻征象 → 八项配对(含例外绿字 / 另一派灰字、两个合计)→ 火星冲克(每人逐处列出「从哪里数、落第几宫、是否触发」,减轻条件带分档标签并注明「不改变这一处」;Raman 一派不同时并列一句;两人对比)→ 婚姻宫压力 → 脚注(口径来源、分档含义、「计算层归纳不是定论」) | 渲染测试:两个状态(两人火星冲克相当 / 一方明显)的 golden 渲染;20 人时下拉可滚动;手机 390 px 无横向滚动;`next build` 后 `/` 仍 Static,首屏 gzip ±2% | | T7 | **三句话总述**:由服务端按结构化结果确定性拼句(不调模型),讲各自征象、相处的助力与考验、八项合计只作一层参考;落点只用「结论措辞」的三档,第二、三档讲清原因 | 单测覆盖两种状态;文案进 VOICE | | T8 | **清旧入口**:档案页「和我合盘」改跳新页并预选两人;删 `SynastryDeepLink`、`use-synastry.ts` 中无人用的部分、`buildSynastryQuestion` 草稿路径、孤儿 CSS、`page.tsx` 开机拉合盘历史 | 按 AGENTS §7.8 grep 源码文本合同测试同轮改,三栏说明 | | T9 | **记录**:调研记录已由 Claude 写好,执行方只需在 PROGRESS 引用;DESIGN.md、VOICE.md(新文案、删人确认句、侧栏名)、CHANGELOG、BUG_HISTORY(下节编号)、`docs/testing/marriage-match-p1-20261017.md` 真人清单(选人、新增星盘、扣点与再次打开不扣、删人连带删除、手机宽) | 文档齐 | @@ -113,6 +99,9 @@ cd .worktrees/marriage-match-p1-20261017 git log --oneline -5 -- frontend/src/lib/subject-birth.ts # 确认上限 20 人那单已在 grep -n "BUG-1[0-9][0-9][0-9]" docs/BUG_HISTORY.md | tail -3 python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45 +# 上游文件(上游仓库私有,已原样放进本仓只读参照目录,固定到 3c01a214) +ls docs/research/upstream_marriage_match_3c01a214/ +# marriage_match.py.txt → scripts/marriage_match.py;test_marriage_match_product.py.txt → tests/test_marriage_match_product.py ``` 验证:全量 `pytest tests -n 4` 与开工基线逐条比对;`run_quality_gate.py --profile quick`;前端 `tsc --noEmit`、`npm run lint`、`npm test`(含 grep 源码文本合同测试)、`npm run test:db`、`npm run build`。