diff --git a/docs/research/upstream_full_synastry_7a773d13/README.md b/docs/research/upstream_full_synastry_7a773d13/README.md new file mode 100644 index 00000000..7e390872 --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/README.md @@ -0,0 +1,7 @@ +# 上游 `yinduzhanxing` main `7a773d13` 相对 `bde5d615` 的合盘相关差异(2026-10-18) + +只放与 `bde5d615` 相比改动过的文件;未列出的文件以 `docs/research/upstream_full_synastry_bde5d615/` 为准。`scripts/marriage_match.py` 两版逐字相同。 + +- `skill/`:上游 `skills/jyotish-full-synastry-reading/` 中改动的 6 个文件(单语言交付、D4 键名改为 `D4_Turyamsa`、Niryaana Shoola 对恋爱婚姻应期标为不适用)。 +- `tests/`:对应测试。 +- `niryaana_shoola_dasha.py.txt`:Niryaana Shoola 改为 PVR 口径(`pvr_niryaana_shoola_v1`:二宫 / 八宫取强、奇顺偶逆、动 / 固 / 双 7 / 8 / 9 年);旧 `goel_nirayana_shoola_v1`(上升 / 七宫取强、每宫 9 年)判定为其实是 Shoola Dasha 的规则,已禁止运行。 diff --git a/docs/research/upstream_full_synastry_7a773d13/niryaana_shoola_dasha.py.txt b/docs/research/upstream_full_synastry_7a773d13/niryaana_shoola_dasha.py.txt new file mode 100644 index 00000000..1db0d9bd --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/niryaana_shoola_dasha.py.txt @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Source-bounded Niryaana Shoola Dasha Mahadasha producer. + +Niryaana Shoola and Shoola are adjacent, but distinct, Jaimini systems. This +module implements the former only. The latter retains its Lagna/seventh, +fixed-nine-year implementation in ``scripts/shoola_dasha.py``. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from scripts.dasha_profile_arbitration import attach_profile_arbitration +from scripts.rashi_dasha_explicit_contract import add_calendar_years + + +PROFILE_ID = "pvr_niryaana_shoola_v1" +LEGACY_MISCLASSIFIED_PROFILE_ID = "goel_nirayana_shoola_v1" +PYJHORA_PROFILE_ID = "pyjhora_2nd_8th_observation_v1" +TOTAL_CYCLE = 96 +SIGNS = ( + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", + "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +) +SIGN_LORDS = { + 0: "Mars", 1: "Venus", 2: "Mercury", 3: "Moon", 4: "Sun", 5: "Mercury", + 6: "Venus", 7: "Mars", 8: "Jupiter", 9: "Saturn", 10: "Saturn", 11: "Jupiter", +} + + +def _sign_index(longitude: float) -> int: + return int(float(longitude) % 360.0 // 30.0) + + +def _forward_order(start: int) -> list[int]: + return [(int(start) + offset) % 12 for offset in range(12)] + + +def _reverse_order(start: int) -> list[int]: + return [(int(start) - offset) % 12 for offset in range(12)] + + +def resolve_lagna_seventh_tie_break( + *, + lagna_sign_index: int, + planets: dict[str, dict[str, Any]] | None = None, + planet_strengths: dict[str, float] | None = None, +) -> dict[str, Any]: + """Explicit Shoola-only tie-break retained for its separate producer.""" + planets = planets if isinstance(planets, dict) else {} + strengths = planet_strengths if isinstance(planet_strengths, dict) else {} + rows = [] + for label, sign in (("lagna", int(lagna_sign_index) % 12), ("seventh", (int(lagna_sign_index) + 6) % 12)): + lord = SIGN_LORDS[sign] + lord_data = planets.get(lord) if isinstance(planets.get(lord), dict) else {} + degree = lord_data.get("degree_in_sign", lord_data.get("degree")) + if not isinstance(degree, (int, float)) and isinstance(lord_data.get("lon"), (int, float)): + degree = float(lord_data["lon"]) % 30.0 + occupants = [ + name for name, data in planets.items() + if isinstance(data, dict) and int(data.get("sign_idx", -1)) % 12 == sign + ] + rows.append({ + "label": label, "sign_index": sign, "sign": SIGNS[sign], + "occupants": occupants, "occupant_count": len(occupants), "lord": lord, + "lord_strength": float(strengths[lord]) if isinstance(strengths.get(lord), (int, float)) else None, + "lord_degree_in_sign": float(degree) if isinstance(degree, (int, float)) else None, + }) + keys = [ + (row["occupant_count"], row["lord_strength"] if row["lord_strength"] is not None else float("-inf"), row["lord_degree_in_sign"] if row["lord_degree_in_sign"] is not None else float("-inf")) + for row in rows + ] + if keys[0] == keys[1]: + return {"status": "blocked", "reason": "Tie-break evidence remains equal after occupants, lord strength, and lord degree.", "rows": rows} + selected = rows[0] if keys[0] > keys[1] else rows[1] + return {"status": "selected", "selected": selected, "rows": rows, "rule": "occupants_then_sign_lord_strength_then_sign_lord_degree"} + + +def _sign_years(sign_index: int) -> int: + """PVR Niryaana Shoola: movable/fixed/dual = 7/8/9 years.""" + if int(sign_index) % 12 in {1, 4, 7, 10}: + return 8 + if int(sign_index) % 12 in {2, 5, 8, 11}: + return 9 + return 7 + + +def niryaana_shoola_applicability( + *, ascendant_longitude: float, second_strength: float, eighth_strength: float, +) -> dict[str, Any]: + """Select the stronger second/eighth sign; the source supplies no tie-break.""" + lagna = _sign_index(ascendant_longitude) + second, eighth = (lagna + 1) % 12, (lagna + 7) % 12 + second_strength, eighth_strength = float(second_strength), float(eighth_strength) + if second_strength == eighth_strength: + return { + "applicable": False, "execution_status": "blocked", "ascendant_sign_index": lagna, + "second_sign_index": second, "eighth_sign_index": eighth, + "second_strength": second_strength, "eighth_strength": eighth_strength, + "rule_family": "pvr_niryaana_shoola_2nd_8th_modality", + "reason": "The cited Niryaana Shoola rule does not provide a tie-break for equal second/eighth strength.", + } + start = second if second_strength > eighth_strength else eighth + return { + "applicable": True, "ascendant_sign_index": lagna, "second_sign_index": second, + "eighth_sign_index": eighth, "second_strength": second_strength, + "eighth_strength": eighth_strength, "starting_sign_index": start, + "starting_sign": SIGNS[start], "direction": "forward" if start % 2 == 0 else "reverse", + "rule_family": "pvr_niryaana_shoola_2nd_8th_modality", + "reason": "Niryaana Shoola starts from the stronger second/eighth sign; odd seeds run forward and even seeds reverse.", + } + + +def calculate_niryaana_shoola_dasha(birth_info: dict[str, Any]) -> dict[str, Any]: + """Calculate source-bounded Niryaana Shoola Mahadashas only. + + PVR's chapter proposes borrowing Narayana rules for children rather than + specifying its own AD/PD algorithm, so child periods remain deliberately + blocked. This system is for longevity analysis and is not relationship + timing evidence. + """ + profile_id = birth_info.get("profile_id") or PROFILE_ID + if profile_id == LEGACY_MISCLASSIFIED_PROFILE_ID: + return { + "applicable": False, "execution_status": "blocked", "confidence_status": "blocked", + "profile_id": profile_id, "major": [], "antardasha": [], "pratyantardasha": [], + "reason": "goel_nirayana_shoola_v1 conflates Niryaana Shoola with Shoola Dasha; choose pvr_niryaana_shoola_v1 or the separate Shoola producer.", + } + if profile_id not in {PROFILE_ID, PYJHORA_PROFILE_ID}: + return {"applicable": False, "execution_status": "blocked", "confidence_status": "blocked", "profile_id": profile_id, "major": [], "reason": "Unsupported Niryaana Shoola profile_id: " + str(profile_id)} + required = ("birth_datetime", "ascendant_longitude", "second_strength", "eighth_strength") + missing = [field for field in required if birth_info.get(field) is None] + if missing: + return {"applicable": False, "execution_status": "blocked", "confidence_status": "blocked", "profile_id": profile_id, "major": [], "reason": "Missing required input: " + ", ".join(missing)} + birth = birth_info["birth_datetime"] + if isinstance(birth, str): + birth = datetime.fromisoformat(birth) + applicability = niryaana_shoola_applicability( + ascendant_longitude=float(birth_info["ascendant_longitude"]), + second_strength=float(birth_info["second_strength"]), + eighth_strength=float(birth_info["eighth_strength"]), + ) + if not applicability["applicable"]: + return {**applicability, "profile_id": profile_id, "total_cycle": TOTAL_CYCLE, "major": [], "antardasha": [], "pratyantardasha": [], "confidence_status": "blocked"} + order = _forward_order(applicability["starting_sign_index"]) if applicability["direction"] == "forward" else _reverse_order(applicability["starting_sign_index"]) + try: + rows, cursor = [], birth + for sign_index in order: + years = _sign_years(sign_index) + end = add_calendar_years(cursor, years) + rows.append({"level": "mahadasha", "sign_index": sign_index, "sign": SIGNS[sign_index], "years": years, "start_date": cursor.isoformat(timespec="seconds"), "end_date": end.isoformat(timespec="seconds")}) + cursor = end + except ValueError: + return {**applicability, "profile_id": profile_id, "execution_status": "blocked", "confidence_status": "blocked", "major": [], "reason": "Calendar-year anniversary cannot be represented for this birth date."} + payload = { + **applicability, "profile_id": profile_id, "execution_status": "executed", + "confidence_status": "source_bounded", "verification_status": "pvr_rule_replay", + "period_depth_status": "mahadasha_executed_ad_pd_blocked_source_rule", + "total_cycle": TOTAL_CYCLE, "duration_basis": "calendar_years", "major": rows, + "antardasha": [], "pratyantardasha": [], + "relationship_applicability": "not_applicable_longevity_specific", + "reason": "PVR Niryaana Shoola MD replay. Its child-period rule is not independently specified, and longevity-specific periods are not relationship timing evidence.", + "source_locator": "PVR, An Integrated Approach to Vedic Astrology, ch. 22.2, source pages 285-292.", + } + attach_profile_arbitration(payload, "niryaana_shoola", profile_id) + return payload diff --git a/docs/research/upstream_full_synastry_7a773d13/skill/SKILL.md b/docs/research/upstream_full_synastry_7a773d13/skill/SKILL.md new file mode 100644 index 00000000..99a5e3e7 --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/skill/SKILL.md @@ -0,0 +1,275 @@ +--- +name: jyotish-full-synastry-reading +description: Produce a full Jyotish love and marriage synastry reading from two birth records or two mature personal Jyotish reports. Use when a user wants a professional relationship interpretation, interaction patterns, shared timing, and relationship-relevant life-period analysis rather than a score-only matching result. +--- + +# Full Jyotish Synastry Reading + +Use this skill to deliver a relationship reading, not a matchmaking scorecard. +Accept either two birth profiles or two mature personal Jyotish reports. A +birth profile contains local birth date, local birth time, and full birthplace +for each person. Do not require a relationship goal, sex/gender label, +questionnaire, caste, or a choice between relationship categories. Infer the +reading emphasis from the two charts. + +## Input Contract + +1. Resolve each birthplace to a sourced latitude, longitude, and timezone. + Display the normalized places and coordinate precision before calculation. + Ask a follow-up only for an ambiguous place, missing local time, or a time + range. Never invent a hospital/street coordinate. +2. Use one visible calculation profile for both people. If neither person + specifies one, use `lahiri` with `mean` nodes and state that default. Do not + silently substitute Raman or mix ayanamsas, node modes, timezones, or house + systems between the two charts. +3. Keep birth data in the active session or user-approved private runtime + storage. Never place it in a repository file, test, Skill, prompt example, + Git commit, or public report metadata. +4. If either time is not sufficiently precise for D9, UL, DK, houses, or timing, + calculate the stable Moon-based layer but label all time-sensitive layers + `blocked` or `parameter_sensitive`. Do not replace them with a score. + +## Full-Report Input + +When the user supplies two formal personal reports, treat them as rich evidence +packets, not as unverified prose to summarize. + +1. Identify each report's birth profile, calculation profile, report generation + date, current/transit reference date, and available raw modules. Prefer an + accompanying JSON packet, delivery manifest, or structured appendix over + extracting values from narrative sentences. +2. Verify that both reports use the same ayanamsa, node mode, zodiac and house + profile before comparing signs, houses, Vargas, strength values, or Dashas. + If profiles differ, retain two separate lanes and do not make a direct + placement comparison until the differing chart is recomputed. +3. Reuse stable natal material: D1, Vargas, house lords, D9, UL, DK, Yoga, + Shadbala, Ashtakavarga, Sahams, natal Dasha roots, and book citations. + Recalculate all date-sensitive material from the preserved birth inputs for + the current day: active Dasha, transits, annual charts, current-year Sahams, + and timing windows. If the report has no usable birth inputs, retain old + dynamic material as a dated historical snapshot rather than calling it now. +4. Build a source map before interpreting: `report field -> pair comparison -> + user-facing section -> assertion label`. Do not add a second prose-only + interpretation layer that loses the reports' structured evidence. + +## Execution Order + +Run this sequence for every complete reading. Reuse repository producers; do +not hand-calculate degrees, vargas, dashas, or house overlays in prose. + +1. Run the repository pre-work gate and capability route: + + ```bash + python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45 + python3 scripts/jyotish_engine.py audit-capabilities --mode table --route relationship_timing_strict + ``` + +2. With birth profiles, run `full-reading` separately for person A and person + B using the same `--ayanamsa lahiri --node-mode mean`, their normalized + coordinates, and the current date for `--today`/`--transit-date` unless the + user explicitly names a historical date. With report packets, perform the + Full-Report Input checks first, then recompute only date-sensitive layers + from the verified birth inputs. Preserve both raw result packets. + + For the normal birth-record workflow, use the controlled private runner + rather than manually assembling two engine commands. It runs both readings + concurrently, blocks mixed calculation profiles, and writes only outside + the repository: + + ```bash + python3 skills/jyotish-full-synastry-reading/scripts/prepare_full_synastry_packets.py \ + --person-a /private/runtime/person-a-birth.json \ + --person-b /private/runtime/person-b-birth.json \ + --output-dir /private/runtime/synastry-run + ``` + + Each profile contains `date`, `time`, full `place`, `latitude`, `longitude`, + `timezone`, `ayanamsa`, and `node_mode`. Omit `--today` for the actual + current date; use it only for an explicitly requested historical reading. + The runner performs the repository preflight before consuming either profile + and emits `preflight.json`, `person_a.json`, `person_b.json`, + `execution_receipt.json`, and `dossier.json`, all private. + +3. After both packets are complete, run the execution receipt. It is a gate, + not a cosmetic appendix: + + ```bash + python3 skills/jyotish-full-synastry-reading/scripts/validate_full_synastry_packets.py \ + --person-a /private/runtime/person-a-full-reading.json \ + --person-b /private/runtime/person-b-full-reading.json + ``` + + Do not write birth records, packets, or the resulting receipt into Git. + The receipt must show the calculation-profile gate, every individual + technique row, and the status plus source inputs for every pair task before + writing conclusions. `ready_for_comparison` means only that both source + packets contain usable inputs; it is not an interpretation or a positive + relationship finding. Read + `references/full-technique-execution-matrix.md` in full for the required + pair comparisons and event-class-specific timing rules. + +4. Assemble the private two-person evidence dossier before narrative writing: + + ```bash + python3 skills/jyotish-full-synastry-reading/scripts/build_full_synastry_dossier.py \ + --person-a /private/runtime/person-a-full-reading.json \ + --person-b /private/runtime/person-b-full-reading.json \ + --output /private/runtime/full-synastry-dossier.json + ``` + + It inventories every source module from both packets and produces a private + `module_narrative_plan` showing the reader question or non-assertive + disposition for each; derives directional pair overlays, angular + contacts and Jaimini rashi contacts; builds a separate reciprocal-care + brief for what helps each recipient feel cherished, safe or pressured; and + provides a `reader_section_brief` that binds every customer section to its + relevant source modules, pair lens and separate D1/D9 recipient evidence; + then attaches the neutral + `marriage-match` dossier. The contacts are raw facts, not a compatibility + verdict. A report may only claim a full pair reading when its + `delivery_readiness` is `ready_for_narrative`; otherwise retain the raw data + and state exactly which primary comparison is blocked. + +5. Build the pair layer from those two computed charts. Call the new + `marriage-match` product for its narrow structural evidence only. It must + receive the two calculated snapshots, never raw birth data. Treat its + 35-point Ashtakoota core, Kuja, and Papasamya as separate evidence rows. + +6. Execute and compare all relationship-relevant layers available from the two + full readings: + + | Layer | Required comparison | + |---|---| + | D1 and functional roles | 1st/7th axes, 5th/7th/8th/12th lords, Venus, Jupiter, Moon, mutual house overlays, dignity, aspects, yogas, Shadbala and Ashtakavarga where relevant | + | D9 | each D9 Lagna and 7th axis, D9 7th lord, Venus/Jupiter, DK, vargottama, Pushkara, D1-D9 agreement and conflict | + | Jaimini | seven- and eight-karaka variants, DK, UL, UL second house, A7/Darapada, Arudha and Argala; retain rule-profile differences | + | Periods and transits | each person's Vimshottari plus Narayana; relation-related MD/AD/PD, Dasha sandhi, double-transit and LL/7L triggers; keep active periods separate. Calendar overlap is not couple resonance. A relationship-event conclusion requires an explicit event class, both charts' event-relevant promise and activation, pair interaction, transit/annual corroboration and supplied relationship status/history. | + | Annual and specialized layers | Tajika/Varshaphala, Vivah Saham, relevant Sahams, KP, Nakshatra, Bhrigu Pada, Chara and other route-required producers; show `executed`, `blocked`, or `not_applicable` exactly as returned | + | Matching evidence | international 35-point Ashtakoota core, exception review, Mars-only Kuja from Lagna/Moon/Venus, and five-planet Papasamya kept distinct | + + Evidence priority is fixed: each person's relationship promise first, the + two-chart relationship structure second, mutually activated timing third, + and matching/pressure indicators last. Kuja is a narrow conflict-pressure + indicator. It cannot lead the report, overturn a sound relationship + structure by itself, or become a pass/fail conclusion. + +7. Search the local book corpus with `scripts/search_jyotish_books.py` for + each material interpretive rule. Record book, page/line, and exact snippet. + For claims that rely on a web-only rule, complete MEVG research, record the + source tier and conflict, and add required intake records before treating it + as more than `reference_only`. + +8. Before writing Chinese prose, read + `references/chinese-synastry-writing-contract.md`. Preserve its protected + terms, degrees, dates, assertion labels and source paths. Apply the local + Chinese editing pass only after the evidence map is frozen; it may improve + sentence rhythm and remove boilerplate, but may not change an astrological + fact, a term mapping, a source boundary or a time window. + +9. Run global-web evidence and real-case calibration according to + `references/mandatory-verification-gate-protocol.md`. Absence of external + closure lowers assertion strength; it does not erase a runnable native + calculation or its raw data. + +## User-Facing Report + +Write a professional Chinese relationship report, not Western-style +relationship coaching. Read `references/customer-report-shape.md` before +writing. Follow its judgment order exactly: individual promise -> +spouse/marriage carrying capacity -> lived pair interaction -> relationship +progression -> shared gains and costs -> long-term durability -> event-specific +timing -> bounded auxiliary evidence. The internal writer must use the actual +Jyotish structures. Explain primary Jyotish terms when they make the judgment +clear, but do not turn the report into a formula table. Never expose the name, +formula, score or lineage of a supplementary cultural-star, Western or +question-chart technique; it may only add a convergent experiential detail. + +Open the Chinese and English reader reports with an evidence-backed experience +of affection, being valued or drawing closer, not an analyst-led contrast. +Where the reciprocal-care brief is executed, the **Attraction and Closeness** +section must write both directions: what helps A feel warmly connected, what +helps B feel warmly connected, what pressure makes either withdraw, and one +mutual practice tied to the actual pair evidence. This is reciprocal-care +guidance, never a promise of love or a tactic for extracting consent. + +The private dossier, not the customer report, must retain every row from the +execution receipt, the two-person comparison recorded for that row, whether it +informed a conclusion, and why it did not if it was not used. It retains raw +placements, degrees, period boundaries, source-book citations and +MEVG/case-calibration state. Never commit that dossier or append it to the +customer copy. + +Never use the total Ashtakoota value, a percentage, or a pass/fail label in the +headline. If a traditional matching value is helpful, describe it only in the +appendix as one limited Moon/nakshatra reference layer. + +When mature personal reports are available, add a short section before +relationship weather called **What each person is carrying into the +relationship**. Explain each person's relationship-relevant life season: +available capacity, career or relocation pressure, family/home obligations, +inner change, and current relationship activation. Only include non-relationship +domains when their chart evidence shows a material effect on relationship +availability or decision-making. Do not duplicate two full personal reports. + +Use one evidence label for every substantive conclusion: +`multi_system_consensus`, `single_system_inference`, +`parameter_sensitive`, `unclosed_divisional_chart`, +`user_history_verification_required`, or `blocked`. + +When bilingual delivery is requested, produce **two independently written +Markdown reports** from the same frozen dossier. Write Chinese under +`references/chinese-synastry-writing-contract.md`, then start a separate +English composition pass under +`references/english-synastry-writing-contract.md`. English must use the +structured dossier, original placements and original citations; never translate +the Chinese draft. Run the private delivery gate after both files exist: + +```bash +python3 skills/jyotish-full-synastry-reading/scripts/validate_bilingual_synastry_delivery.py \ + --dossier /private/runtime/full-synastry-dossier.json \ + --chinese /private/runtime/Chinese-Full-Synastry-Report.md \ + --english /private/runtime/English-Full-Synastry-Report.md \ + --output /private/runtime/bilingual-delivery-receipt.json +``` + +The receipt checks shared evidence coverage, direct-English composition +metadata, terminology, section-by-section narrative depth, reciprocal-care +coverage and report shape. It does not justify copying a Chinese paragraph into +English. + +## Boundaries + +- Do not output an approve/reject verdict, relationship ranking, percentage, + “soulmate” claim, or automatic marriage/divorce prediction. +- Do not call 35 points a 36-point score. Varna/caste is excluded from the + default product, and the 35-point core is only a small traditional evidence + layer. +- Do not merge Papasamya into Kuja, apply sex-asymmetric tolerance, or claim + that both people having Kuja automatically cancels it. +- Do not use a present relationship problem to retrofit chart calculations. +- Do not call a full-reading module "used" merely because its key exists. The + execution receipt must show an actual non-blocked result for both people, and + pair comparisons must be recorded separately from individual calculations. +- Do not hide a conflict between D1, D9, Jaimini, Dasha, or transit evidence. + Explain the disagreement and reduce the conclusion accordingly. +- Never make health, fertility, legal-marriage, financial, or safety claims + solely from a compatibility layer. + +## Runtime References + +- `references/strict-workflow-router.md`: relationship timing route and audit table. +- `references/mandatory-verification-gate-protocol.md`: web evidence and case calibration. +- `references/navamsa-marriage-deep-analysis.md`: D9 relationship microscope. +- `references/marriage-timing-comprehensive-techniques.md`: timing source map. +- `docs/marriage_match_product_contract.md`: narrow pair-evidence contract. +- `references/customer-report-shape.md`: user-readable report and full-report-input shape. +- `references/full-technique-execution-matrix.md`: mandatory all-technique and + pair-comparison execution matrix. +- `references/interaction-progression-evidence-map.md`: mandatory comparison + map for lived interaction, progression, shared gains and shared costs. +- `references/customer-topic-priorities.md`: reader-first ordering for the + customer report; presentation source only, never a Jyotish rule source. +- `references/chinese-synastry-writing-contract.md`: Chinese terminology, + evidence-preserving editing and final-language gate. +- `references/english-synastry-writing-contract.md`: direct English report + composition and terminology gate. diff --git a/docs/research/upstream_full_synastry_7a773d13/skill/agents/openai.yaml.txt b/docs/research/upstream_full_synastry_7a773d13/skill/agents/openai.yaml.txt new file mode 100644 index 00000000..aa51b248 --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/skill/agents/openai.yaml.txt @@ -0,0 +1,4 @@ +interface: + display_name: "Full Jyotish Synastry Reading" + short_description: "Narrative love and marriage chart interpretation" + default_prompt: "Use $jyotish-full-synastry-reading for a full love/marriage synastry reading. Ask only for each person's local birth date, local birth time, and full birthplace when missing; resolve coordinates, calculate both charts under one visible profile, run the private full-synastry dossier, and write independently composed Chinese and English reports. Lead with lived interaction, each person's distinct experience, progression, shared gains/pressures, and event-class-specific timing. Do not turn Ashtakoota, Kuja, or coincident Dasha dates into a score, verdict, or shared relationship event." diff --git a/docs/research/upstream_full_synastry_7a773d13/skill/scripts/compose_full_synastry_delivery.py.txt b/docs/research/upstream_full_synastry_7a773d13/skill/scripts/compose_full_synastry_delivery.py.txt new file mode 100644 index 00000000..7f9375c4 --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/skill/scripts/compose_full_synastry_delivery.py.txt @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Compile two independently written private synastry drafts for delivery. + +This is a report compiler, not an astrology interpreter or a translation +service. It accepts separately authored Chinese and English section payloads, +binds them to the full-synastry dossier, and writes only after the existing +bilingual delivery gate passes. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import shutil +import tempfile +from pathlib import Path +from typing import Any, Mapping + + +ROOT = Path(__file__).resolve().parents[3] +VALIDATOR_PATH = Path(__file__).with_name("validate_bilingual_synastry_delivery.py") +EXPECTED_COMPOSITION = "independent_from_structured_dossier" +SECTION_KEYS = ( + "relationship_thesis", + "what_each_person_brings", + "emotional_safety", + "attraction_and_closeness", + "communication_conflict_and_repair", + "relationship_progression", + "shared_life_gains_and_pressures", + "can_it_stay_steady", + "relationship_weather", +) + + +def _load_module(name: str, path: Path) -> Any: + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +_VALIDATOR = _load_module("full_synastry_bilingual_delivery", VALIDATOR_PATH) +ZH_HEADINGS = dict(zip(SECTION_KEYS, _VALIDATOR.ZH_HEADINGS, strict=True)) +EN_HEADINGS = dict(zip(SECTION_KEYS, _VALIDATOR.EN_HEADINGS, strict=True)) + + +def _private_output_directory(value: str) -> Path: + path = Path(value).expanduser().resolve() + try: + path.relative_to(ROOT) + except ValueError: + return path + raise ValueError("--output-dir must be outside the repository because it can contain private relationship information") + + +def _payload(value: Mapping[str, Any], *, language: str) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise ValueError(f"{language} section payload must be a JSON object") + if value.get("language") != language: + raise ValueError(f"{language} section payload must declare language={language!r}") + if value.get("composition") != EXPECTED_COMPOSITION: + raise ValueError(f"{language} section payload must declare independent composition from the structured dossier") + title = value.get("title", "") + if not isinstance(title, str): + raise ValueError(f"{language} title must be text") + sections = value.get("sections") + if not isinstance(sections, Mapping): + raise ValueError(f"{language} payload requires a sections object") + missing = [key for key in SECTION_KEYS if not isinstance(sections.get(key), str) or not sections[key].strip()] + unknown = sorted(set(sections) - set(SECTION_KEYS)) + if missing or unknown: + detail = [] + if missing: + detail.append("missing=" + ", ".join(missing)) + if unknown: + detail.append("unknown=" + ", ".join(unknown)) + raise ValueError(f"{language} section payload keys are invalid: {'; '.join(detail)}") + return {"title": title.strip(), "sections": {key: sections[key].strip() for key in SECTION_KEYS}} + + +def _dossier_ready(dossier: Mapping[str, Any]) -> None: + if not isinstance(dossier, Mapping): + raise ValueError("dossier must be a JSON object") + if dossier.get("schema") != "jyotish.full_synastry_dossier.v1": + raise ValueError("dossier schema is not jyotish.full_synastry_dossier.v1") + if dossier.get("delivery_readiness") != "ready_for_narrative": + raise ValueError("dossier is not ready_for_narrative") + brief = dossier.get("reader_section_brief") + if not isinstance(brief, list): + raise ValueError("dossier has no reader_section_brief") + actual = {row.get("section") for row in brief if isinstance(row, Mapping)} + if actual != set(SECTION_KEYS): + raise ValueError("dossier reader_section_brief does not cover every customer section") + + +def _render_markdown(payload: Mapping[str, Any], headings: Mapping[str, str]) -> str: + blocks = [] + if payload["title"]: + blocks.append(f"# {payload['title']}") + for key in SECTION_KEYS: + blocks.append(f"## {headings[key]}\n\n{payload['sections'][key]}") + return "\n\n".join(blocks).strip() + "\n" + + +def compile_delivery( + dossier: Mapping[str, Any], chinese_payload: Mapping[str, Any], english_payload: Mapping[str, Any], +) -> dict[str, Any]: + """Compile independently authored reports and reject a failed delivery in memory.""" + _dossier_ready(dossier) + chinese = _render_markdown(_payload(chinese_payload, language="zh"), ZH_HEADINGS) + english = _render_markdown(_payload(english_payload, language="en"), EN_HEADINGS) + receipt = _VALIDATOR.build_delivery_receipt(dossier, chinese, english) + if receipt.get("status") != "passed": + raise ValueError("bilingual synastry delivery gate failed; inspect the private receipt before writing any report") + return {"chinese_markdown": chinese, "english_markdown": english, "delivery_receipt": receipt} + + +def compile_single_language_delivery( + dossier: Mapping[str, Any], payload: Mapping[str, Any], language: str, +) -> dict[str, Any]: + """Compile one independently authored language from the frozen dossier.""" + if language not in {"zh", "en"}: + raise ValueError("language must be 'zh' or 'en'") + _dossier_ready(dossier) + headings = ZH_HEADINGS if language == "zh" else EN_HEADINGS + markdown = _render_markdown(_payload(payload, language=language), headings) + receipt = _VALIDATOR.build_single_language_delivery_receipt(dossier, markdown, language) + if receipt.get("status") != "passed": + raise ValueError("single-language synastry delivery gate failed; inspect the private receipt before writing any report") + return {"language": language, "markdown": markdown, "delivery_receipt": receipt} + + +def _publish_delivery_bundle(output_dir: Path, compiled: Mapping[str, Any]) -> None: + """Publish all three files together; never overwrite an existing delivery.""" + if output_dir.exists(): + raise ValueError("--output-dir already exists; choose a new private release directory") + output_dir.parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=".synastry-delivery-", dir=output_dir.parent)) + try: + (staging / "Chinese-Full-Synastry-Report.md").write_text(compiled["chinese_markdown"], encoding="utf-8") + (staging / "English-Full-Synastry-Report.md").write_text(compiled["english_markdown"], encoding="utf-8") + (staging / "bilingual-delivery-receipt.json").write_text( + json.dumps(compiled["delivery_receipt"], ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + staging.replace(output_dir) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def _publish_single_language_delivery(output_dir: Path, compiled: Mapping[str, Any]) -> None: + """Atomically publish one language without creating a fictional peer report.""" + if output_dir.exists(): + raise ValueError("--output-dir already exists; choose a new private release directory") + output_dir.parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=".synastry-delivery-", dir=output_dir.parent)) + language = str(compiled["language"]) + report_name = "Chinese-Full-Synastry-Report.md" if language == "zh" else "English-Full-Synastry-Report.md" + receipt_name = f"{language}-delivery-receipt.json" + try: + (staging / report_name).write_text(compiled["markdown"], encoding="utf-8") + (staging / receipt_name).write_text( + json.dumps(compiled["delivery_receipt"], ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + staging.replace(output_dir) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def main() -> int: + parser = argparse.ArgumentParser(description="Compile private, independently written full-synastry Markdown delivery.") + parser.add_argument("--dossier", required=True, help="Private full-synastry dossier JSON") + parser.add_argument("--chinese-sections", help="Private independent Chinese section payload JSON") + parser.add_argument("--english-sections", help="Private independent English section payload JSON") + parser.add_argument("--language", choices=("zh", "en"), help="Compile one independently authored language") + parser.add_argument("--sections", help="Private section payload JSON used with --language") + parser.add_argument("--output-dir", required=True, help="Private directory outside the repository") + parser.add_argument("--dry-run", action="store_true", help="Validate and print the intended delivery without writing files") + args = parser.parse_args() + + output_dir = _private_output_directory(args.output_dir) + dossier = json.loads(Path(args.dossier).read_text(encoding="utf-8")) + if args.language: + if not args.sections or args.chinese_sections or args.english_sections: + parser.error("--language requires --sections and cannot be combined with bilingual section arguments") + compiled = compile_single_language_delivery( + dossier, json.loads(Path(args.sections).read_text(encoding="utf-8")), args.language, + ) + files = [ + "Chinese-Full-Synastry-Report.md" if args.language == "zh" else "English-Full-Synastry-Report.md", + f"{args.language}-delivery-receipt.json", + ] + else: + if not args.chinese_sections or not args.english_sections: + parser.error("bilingual delivery requires --chinese-sections and --english-sections") + chinese_payload = json.loads(Path(args.chinese_sections).read_text(encoding="utf-8")) + english_payload = json.loads(Path(args.english_sections).read_text(encoding="utf-8")) + compiled = compile_delivery(dossier, chinese_payload, english_payload) + files = ["Chinese-Full-Synastry-Report.md", "English-Full-Synastry-Report.md", "bilingual-delivery-receipt.json"] + if args.dry_run: + print(json.dumps({ + "status": "would-deliver", + "output_dir": str(output_dir), + "files": files, + "receipt_status": compiled["delivery_receipt"]["status"], + }, ensure_ascii=False)) + return 0 + if args.language: + _publish_single_language_delivery(output_dir, compiled) + else: + _publish_delivery_bundle(output_dir, compiled) + print(json.dumps({ + "status": "delivered", + "output_dir": str(output_dir), + "files": files, + }, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/research/upstream_full_synastry_7a773d13/skill/scripts/prepare_full_synastry_packets.py.txt b/docs/research/upstream_full_synastry_7a773d13/skill/scripts/prepare_full_synastry_packets.py.txt new file mode 100644 index 00000000..0441cc97 --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/skill/scripts/prepare_full_synastry_packets.py.txt @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Prepare two private full-reading packets and a synastry dossier. + +This is the operational entry point for the full synastry Skill. It accepts +two private birth profiles, runs the native full-reading engine separately, +then builds the receipt and dossier consumed by the bilingual writing pass. +It deliberately does not write customer prose: that requires a chart reader +to distinguish the two people's evidence and live conditions. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from datetime import date, datetime +from pathlib import Path +from typing import Any, Mapping + + +ROOT = Path(__file__).resolve().parents[3] +ENGINE = ROOT / "scripts" / "jyotish_engine.py" +PRE_WORK_CHECK = ROOT / "scripts" / "pre_work_check.py" +DOSSIER_PATH = Path(__file__).with_name("build_full_synastry_dossier.py") +RECEIPT_PATH = Path(__file__).with_name("validate_full_synastry_packets.py") + + +def _load_module(name: str, path: Path) -> Any: + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +_DOSSIER = _load_module("full_synastry_dossier", DOSSIER_PATH) +_RECEIPT = _load_module("full_synastry_receipt", RECEIPT_PATH) + + +def _private_output_directory(value: str) -> Path: + path = Path(value).expanduser().resolve() + try: + path.relative_to(ROOT) + except ValueError: + return path + raise ValueError("--output-dir must be outside the repository because it contains private birth data") + + +def _number(profile: Mapping[str, Any], key: str, *, minimum: float, maximum: float) -> float: + value = profile.get(key) + if not isinstance(value, (int, float)) or isinstance(value, bool) or not minimum <= float(value) <= maximum: + raise ValueError(f"{key} must be a number between {minimum} and {maximum}") + return float(value) + + +def normalize_profile(profile: Mapping[str, Any]) -> dict[str, Any]: + """Validate a private birth profile without retaining identity fields.""" + if not isinstance(profile, Mapping): + raise ValueError("birth profile must be a JSON object") + birth_date = profile.get("date") + birth_time = profile.get("time") + if not isinstance(birth_date, str): + raise ValueError("date is required as YYYY-MM-DD") + if not isinstance(birth_time, str): + raise ValueError("time is required as HH:MM or HH:MM:SS") + try: + day = date.fromisoformat(birth_date) + try: + parsed_time = datetime.strptime(birth_time, "%H:%M:%S").time() + except ValueError: + parsed_time = datetime.strptime(birth_time, "%H:%M").time() + except ValueError as exc: + raise ValueError("date/time must use YYYY-MM-DD and HH:MM or HH:MM:SS") from exc + place = profile.get("place") + if not isinstance(place, str) or not place.strip(): + raise ValueError("place is required; keep the full birth-place text in the private profile") + ayanamsa = str(profile.get("ayanamsa", "lahiri")).lower() + node_mode = str(profile.get("node_mode", "mean")).lower() + if node_mode not in {"mean", "true"}: + raise ValueError("node_mode must be mean or true") + return { + "year": day.year, "month": day.month, "day": day.day, + "hour": parsed_time.hour, "minute": parsed_time.minute, "second": parsed_time.second, + "lat": _number(profile, "latitude", minimum=-90, maximum=90), + "lon": _number(profile, "longitude", minimum=-180, maximum=180), + "tz": _number(profile, "timezone", minimum=-14, maximum=14), + "ayanamsa": ayanamsa, "node_mode": node_mode, + "gender": profile.get("gender") if profile.get("gender") in {"male", "female"} else None, + } + + +def full_reading_command(profile: Mapping[str, Any], *, today: str | None = None) -> list[str]: + """Build the native command from validated input; never hand-calculate charts.""" + command = [ + sys.executable, str(ENGINE), "full-reading", + "--year", str(profile["year"]), "--month", str(profile["month"]), "--day", str(profile["day"]), + "--hour", str(profile["hour"]), "--minute", str(profile["minute"]), "--second", str(profile["second"]), + "--lat", str(profile["lat"]), "--lon", str(profile["lon"]), "--tz", str(profile["tz"]), + "--ayanamsa", str(profile["ayanamsa"]), "--node-mode", str(profile["node_mode"]), + ] + if profile.get("gender"): + command.extend(("--gender", str(profile["gender"]))) + if today: + command.extend(("--today", today, "--transit-date", today)) + return command + + +def preflight_command() -> list[str]: + """Use the repository gate before native chart calculation.""" + return [ + sys.executable, str(PRE_WORK_CHECK), + "--remote-timeout", "8", "--command-timeout", "45", + ] + + +def _run_preflight() -> dict[str, Any]: + completed = subprocess.run(preflight_command(), check=False, capture_output=True, text=True) + if completed.returncode: + detail = completed.stderr.strip().splitlines()[-1:] or completed.stdout.strip().splitlines()[-1:] + raise RuntimeError("synastry preflight failed: " + (detail[0] if detail else "unknown failure")) + return { + "status": "passed", + "command": ["scripts/pre_work_check.py", "--remote-timeout", "8", "--command-timeout", "45"], + } + + +def _run_full_reading(profile: Mapping[str, Any], today: str | None) -> dict[str, Any]: + completed = subprocess.run(full_reading_command(profile, today=today), check=False, capture_output=True, text=True) + if completed.returncode: + message = completed.stderr.strip().splitlines()[-1:] or ["native full-reading failed"] + raise RuntimeError("full-reading failed: " + message[0]) + try: + result = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError("full-reading did not return JSON") from exc + if not isinstance(result, dict): + raise RuntimeError("full-reading returned a non-object result") + return result + + +def prepare_synastry_packets(person_a: Mapping[str, Any], person_b: Mapping[str, Any], *, today: str | None = None) -> dict[str, Any]: + """Run both native charts concurrently and assemble their private evidence.""" + a, b = normalize_profile(person_a), normalize_profile(person_b) + if (a["ayanamsa"], a["node_mode"]) != (b["ayanamsa"], b["node_mode"]): + raise ValueError("both profiles must use the same ayanamsa and node_mode before direct comparison") + preflight = _run_preflight() + with ThreadPoolExecutor(max_workers=2) as pool: + future_a = pool.submit(_run_full_reading, a, today) + future_b = pool.submit(_run_full_reading, b, today) + packet_a, packet_b = future_a.result(), future_b.result() + return { + "preflight": preflight, + "person_a": packet_a, + "person_b": packet_b, + "execution_receipt": _RECEIPT.build_execution_receipt(packet_a, packet_b), + "dossier": _DOSSIER.build_full_synastry_dossier(packet_a, packet_b), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Prepare private full-synastry evidence from two birth profiles.") + parser.add_argument("--person-a", required=True, help="Private JSON profile: date, time, place, latitude, longitude, timezone, ayanamsa, node_mode") + parser.add_argument("--person-b", required=True, help="Private JSON profile: date, time, place, latitude, longitude, timezone, ayanamsa, node_mode") + parser.add_argument("--output-dir", required=True, help="Private directory outside the repository") + parser.add_argument("--today", help="Optional historical reference date YYYY-MM-DD; omit for the actual current date") + args = parser.parse_args() + if args.today: + date.fromisoformat(args.today) + output_dir = _private_output_directory(args.output_dir) + person_a = json.loads(Path(args.person_a).read_text(encoding="utf-8")) + person_b = json.loads(Path(args.person_b).read_text(encoding="utf-8")) + prepared = prepare_synastry_packets(person_a, person_b, today=args.today) + output_dir.mkdir(parents=True, exist_ok=True) + for name, value in prepared.items(): + (output_dir / f"{name}.json").write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps({ + "status": "prepared", "output_dir": str(output_dir), + "dossier_status": prepared["dossier"].get("delivery_readiness"), + "next_step": "Write Chinese and English reports independently from dossier.json, then run validate_bilingual_synastry_delivery.py.", + }, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/research/upstream_full_synastry_7a773d13/skill/scripts/validate_bilingual_synastry_delivery.py.txt b/docs/research/upstream_full_synastry_7a773d13/skill/scripts/validate_bilingual_synastry_delivery.py.txt new file mode 100644 index 00000000..6c34df8c --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/skill/scripts/validate_bilingual_synastry_delivery.py.txt @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Validate independently written Chinese and English synastry reports. + +The validator checks shared evidence coverage against a private dossier. It +does not translate prose and never treats one language as the other's source. +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any, Mapping + + +SCHEMA = "jyotish.full_synastry_bilingual_delivery_receipt.v1" +SINGLE_LANGUAGE_SCHEMA = "jyotish.full_synastry_single_language_delivery_receipt.v1" +ZH_HEADINGS = ("结论总纲", "各自带入关系的需求", "情绪安全与相处感受", "吸引与亲密", "沟通、冲突与修复", "关系如何推进", "共同生活的收益与压力", "能不能稳定走下去", "关系应期") +EN_HEADINGS = ("Relationship Thesis", "What Each Person Brings", "Emotional Safety and Lived Experience", "Attraction and Closeness", "Communication, Conflict and Repair", "How the Relationship Progresses", "Shared Life: Gains and Pressures", "Can It Stay Steady?", "Relationship Weather") +EVIDENCE_LABELS = ( + "multi_system_consensus", "single_system_inference", "parameter_sensitive", + "unclosed_divisional_chart", "user_history_verification_required", "blocked", +) +SUPPLEMENTARY_METHOD_TERMS = ("Shukuyo", "Western synastry", "Composite chart", "Davison", "Prashna", "宿曜", "星宿关系", "西占合盘", "组合盘", "问卦") +PRIVATE_EVIDENCE_TERMS = ( + "execution_receipt", "source_map", "delivery_readiness", "module_inventory", "modules.", + "pair_interaction_evidence", "mutual_affection_activation", "reader_section_brief", + "recipient_experience_evidence", "d1_recipient_evidence", "d9_recipient_evidence", + "parallel_personal_timing", "event_resonance_gate", + "原始模块索引", "执行回执", "证据包路径", "章节证据简报", "接收方证据", +) +ZH_READER_MIN_CHARACTERS = 2000 +EN_READER_MIN_WORDS = 1200 +READER_BANNED_TERMS = ("灵魂伴侣", "宇宙安排", "百分百", "一定会", "soulmate", "the universe", "guaranteed", "100%") +ZH_D9_TERMS = ("D9", "九分盘", "婚姻分盘", "Navamsa", "Navamsha") +EN_D9_TERMS = ("D9", "Navamsa", "Navamsha") +ZH_ANALYST_LED_OPENERS = ("这段关系的核心", "这段关系不是", "不是有没有吸引", "不是“", "不是\"") +EN_ANALYST_LED_OPENERS = ("the core of this relationship", "this relationship is not") +ZH_AFFECTION_OPENING_TERMS = ("珍惜", "靠近", "在意", "被理解", "被重视", "安心", "温暖", "好感", "心动", "亲近", "回应", "投入") +EN_AFFECTION_OPENING_TERMS = ("cherish", "close", "care", "understood", "valued", "safe", "warm", "drawn", "affection", "respond", "invest") +ZH_SECTION_MIN_CHARACTERS = 100 +EN_SECTION_MIN_WORDS = 70 +ZH_AFFECTION_SECTION_WARMTH_TERMS = ("珍惜", "靠近", "在意", "被理解", "被重视", "安心", "温暖", "好感", "心动", "亲近") +ZH_AFFECTION_SECTION_BOUNDARY_TERMS = ("空间", "压力", "边界", "催促", "忽略", "退缩") +EN_AFFECTION_SECTION_WARMTH_TERMS = ("cherish", "close", "care", "understood", "valued", "safe", "warm", "drawn", "affection") +EN_AFFECTION_SECTION_BOUNDARY_TERMS = ("space", "pressure", "boundary", "rush", "ignored", "withdraw") + + +def _heading_status(text: str, headings: tuple[str, ...]) -> dict[str, bool]: + return {heading: heading in text for heading in headings} + + +def _opening_after_heading(text: str, heading: str, headings: tuple[str, ...]) -> str: + """Return the reader-visible opening of one section, not the whole report.""" + return _section_bodies(text, headings).get(heading, "")[:500] + + +def _section_bodies(text: str, headings: tuple[str, ...]) -> dict[str, str]: + """Extract heading bodies without mistaking prose mentions for headings.""" + found: list[tuple[int, int, str]] = [] + for heading in headings: + pattern = re.compile( + rf"^[ \t]*(?:#{{1,6}}[ \t]*)?(?:(?:[一二三四五六七八九十]+|[0-9]+|[IVXLCDM]+)[、..)][ \t]*)?{re.escape(heading)}[ \t#]*$", + re.MULTILINE, + ) + match = pattern.search(text) + if match: + found.append((match.start(), match.end(), heading)) + found.sort() + bodies = {heading: "" for heading in headings} + for index, (_start, end, heading) in enumerate(found): + next_start = found[index + 1][0] if index + 1 < len(found) else len(text) + bodies[heading] = text[end:next_start].strip() + return bodies + + +def _opening_quality(chinese: str, english: str) -> dict[str, Any]: + zh_opening = _opening_after_heading(chinese, "结论总纲", ZH_HEADINGS) + en_opening = _opening_after_heading(english, "Relationship Thesis", EN_HEADINGS) + zh_hits = [phrase for phrase in ZH_ANALYST_LED_OPENERS if phrase in zh_opening] + en_lower = en_opening.lower() + en_hits = [phrase for phrase in EN_ANALYST_LED_OPENERS if phrase in en_lower] + return { + "chinese_analyst_led_openers": zh_hits, + "english_analyst_led_openers": en_hits, + "status": "passed" if not zh_hits and not en_hits else "review_required", + "boundary": "The thesis must begin with the reader's lived relationship question and answer, not an analyst-led contrast such as 'not X but Y'.", + } + + +def _dossier_status(dossier: Mapping[str, Any]) -> dict[str, str]: + rows = dossier.get("source_map", []) if isinstance(dossier.get("source_map"), list) else [] + return { + str(row.get("label")): str(row.get("status")) + for row in rows if isinstance(row, Mapping) and row.get("label") + } + + +def _d9_pair_evidence_status(dossier: Mapping[str, Any]) -> str: + interaction = dossier.get("pair_interaction_evidence") + if not isinstance(interaction, Mapping): + return "not_available" + d9 = interaction.get("d9_interaction_evidence") + return str(d9.get("status")) if isinstance(d9, Mapping) else "not_available" + + +def _mutual_affection_status(dossier: Mapping[str, Any]) -> str: + brief = dossier.get("mutual_affection_activation") + return str(brief.get("status")) if isinstance(brief, Mapping) else "not_available" + + +def _opening_affection_coverage( + dossier: Mapping[str, Any], chinese: str, english: str, +) -> dict[str, Any]: + """Require a warm, evidence-backed opening only when the brief exists.""" + status = _mutual_affection_status(dossier) + required = status == "executed" + zh_opening = _opening_after_heading(chinese, "结论总纲", ZH_HEADINGS) + en_opening = _opening_after_heading(english, "Relationship Thesis", EN_HEADINGS).lower() + chinese_terms = [term for term in ZH_AFFECTION_OPENING_TERMS if term in zh_opening] + english_terms = [term for term in EN_AFFECTION_OPENING_TERMS if term in en_opening] + return { + "available_status": status, + "required_in_opening": required, + "chinese_terms": chinese_terms, + "english_terms": english_terms, + "status": "passed" if not required or (chinese_terms and english_terms) else "review_required", + "boundary": ( + "A warm opening describes evidence-backed conditions for reciprocal care. " + "It must not promise love, consent, fidelity, or an outcome." + ), + } + + +def _section_depth_coverage(chinese: str, english: str) -> dict[str, Any]: + chinese_bodies = _section_bodies(chinese, ZH_HEADINGS) + english_bodies = _section_bodies(english, EN_HEADINGS) + chinese_rows = { + heading: len(re.findall(r"[\u4e00-\u9fff]", body)) + for heading, body in chinese_bodies.items() + } + english_rows = { + heading: len(re.findall(r"\b[A-Za-z][A-Za-z'-]*\b", body)) + for heading, body in english_bodies.items() + } + return { + "chinese_characters": chinese_rows, + "chinese_minimum_per_section": ZH_SECTION_MIN_CHARACTERS, + "english_words": english_rows, + "english_minimum_per_section": EN_SECTION_MIN_WORDS, + "status": "passed" if ( + all(count >= ZH_SECTION_MIN_CHARACTERS for count in chinese_rows.values()) + and all(count >= EN_SECTION_MIN_WORDS for count in english_rows.values()) + ) else "review_required", + "boundary": "This guards against heading-only sections and does not certify astrological correctness or narrative quality.", + } + + +def _attraction_reciprocity_coverage(dossier: Mapping[str, Any], chinese: str, english: str) -> dict[str, Any]: + """Require warmth and boundary coverage when reciprocal evidence exists.""" + required = _mutual_affection_status(dossier) == "executed" + chinese_section = _section_bodies(chinese, ZH_HEADINGS)["吸引与亲密"] + english_section = _section_bodies(english, EN_HEADINGS)["Attraction and Closeness"].lower() + chinese_warmth = [term for term in ZH_AFFECTION_SECTION_WARMTH_TERMS if term in chinese_section] + chinese_boundary = [term for term in ZH_AFFECTION_SECTION_BOUNDARY_TERMS if term in chinese_section] + english_warmth = [term for term in EN_AFFECTION_SECTION_WARMTH_TERMS if term in english_section] + english_boundary = [term for term in EN_AFFECTION_SECTION_BOUNDARY_TERMS if term in english_section] + passed = not required or ( + chinese_warmth and chinese_boundary and english_warmth and english_boundary + ) + return { + "required": required, + "chinese_warmth_terms": chinese_warmth, + "chinese_boundary_terms": chinese_boundary, + "english_warmth_terms": english_warmth, + "english_boundary_terms": english_boundary, + "status": "passed" if passed else "review_required", + "boundary": ( + "This requires reciprocal-care and space/pressure language when evidence is executed. " + "It does not prove consent, attraction, fidelity or any relationship outcome." + ), + } + + +def build_delivery_receipt(dossier: Mapping[str, Any], chinese: str, english: str) -> dict[str, Any]: + source_status = _dossier_status(dossier) + cited_labels = {label: {"zh": label in chinese, "en": label in english} for label in EVIDENCE_LABELS} + primary_pair_tasks = [ + row for row in (dossier.get("execution_receipt", {}) or {}).get("pair_execution_tasks", []) + if isinstance(row, Mapping) and row.get("status") in {"ready_for_comparison", "parameter_sensitive", "blocked"} + ] + english_cjk_ratio = len(re.findall(r"[\u4e00-\u9fff]", english)) / max(len(english), 1) + chinese_characters = len(re.findall(r"[\u4e00-\u9fff]", chinese)) + english_words = len(re.findall(r"\b[A-Za-z][A-Za-z'-]*\b", english)) + zh_headings = _heading_status(chinese, ZH_HEADINGS) + en_headings = _heading_status(english, EN_HEADINGS) + d9_status = _d9_pair_evidence_status(dossier) + d9_required = d9_status == "executed" + d9_coverage = { + "available_status": d9_status, + "required_in_narrative": d9_required, + "chinese": any(term in chinese for term in ZH_D9_TERMS), + "english": any(term.lower() in english.lower() for term in EN_D9_TERMS), + } + opening_quality = _opening_quality(chinese, english) + opening_affection = _opening_affection_coverage(dossier, chinese, english) + section_depth = _section_depth_coverage(chinese, english) + attraction_reciprocity = _attraction_reciprocity_coverage(dossier, chinese, english) + return { + "schema": SCHEMA, + "dossier_schema": dossier.get("schema"), + "composition": {"chinese": "direct_from_structured_dossier", "english": "direct_from_structured_dossier_not_translated_from_chinese"}, + "headings": {"chinese": zh_headings, "english": en_headings}, + "evidence_labels": cited_labels, + "source_map_status": source_status, + "pair_tasks": [{"task": row.get("task"), "status": row.get("status")} for row in primary_pair_tasks], + "d9_narrative_coverage": d9_coverage, + "opening_quality": opening_quality, + "opening_affection_coverage": opening_affection, + "section_depth_coverage": section_depth, + "attraction_reciprocity_coverage": attraction_reciprocity, + "supplementary_method_terms": {"chinese": {term: term in chinese for term in SUPPLEMENTARY_METHOD_TERMS}, "english": {term: term in english for term in SUPPLEMENTARY_METHOD_TERMS}}, + "private_evidence_terms": {"chinese": {term: term in chinese for term in PRIVATE_EVIDENCE_TERMS}, "english": {term: term in english for term in PRIVATE_EVIDENCE_TERMS}}, + "english_cjk_ratio": round(english_cjk_ratio, 5), + "reader_depth": {"chinese_characters": chinese_characters, "chinese_minimum": ZH_READER_MIN_CHARACTERS, "english_words": english_words, "english_minimum": EN_READER_MIN_WORDS, "status": "passed" if chinese_characters >= ZH_READER_MIN_CHARACTERS and english_words >= EN_READER_MIN_WORDS else "review_required"}, + "reader_language_terms": {"chinese": {term: term in chinese for term in READER_BANNED_TERMS}, "english": {term: term in english.lower() for term in READER_BANNED_TERMS}}, + "status": "passed" if ( + dossier.get("delivery_readiness") == "ready_for_narrative" and all(zh_headings.values()) and all(en_headings.values()) + and opening_quality["status"] == "passed" and opening_affection["status"] == "passed" + and section_depth["status"] == "passed" and attraction_reciprocity["status"] == "passed" + and (not d9_required or (d9_coverage["chinese"] and d9_coverage["english"])) + and not any(term in chinese for term in SUPPLEMENTARY_METHOD_TERMS) and not any(term in english for term in SUPPLEMENTARY_METHOD_TERMS) + and not any(term in chinese for term in PRIVATE_EVIDENCE_TERMS) and not any(term in english for term in PRIVATE_EVIDENCE_TERMS) + and chinese_characters >= ZH_READER_MIN_CHARACTERS and english_words >= EN_READER_MIN_WORDS + and not any(term in chinese for term in READER_BANNED_TERMS) and not any(term in english.lower() for term in READER_BANNED_TERMS) + and english_cjk_ratio < 0.05 + ) else "review_required", + "boundary": "This validates delivery structure and shared evidence coverage. It does not prove a translation is absent or replace technical review.", + } + + +def build_single_language_delivery_receipt( + dossier: Mapping[str, Any], report: str, language: str, +) -> dict[str, Any]: + """Validate one independently composed customer language without a placeholder peer.""" + if language not in {"zh", "en"}: + raise ValueError("language must be 'zh' or 'en'") + headings = ZH_HEADINGS if language == "zh" else EN_HEADINGS + text = report if language == "zh" else report.lower() + d9_status = _d9_pair_evidence_status(dossier) + affection_status = _mutual_affection_status(dossier) + section_bodies = _section_bodies(report, headings) + section_sizes = { + heading: ( + len(re.findall(r"[\u4e00-\u9fff]", body)) if language == "zh" + else len(re.findall(r"\b[A-Za-z][A-Za-z'-]*\b", body)) + ) + for heading, body in section_bodies.items() + } + min_size = ZH_SECTION_MIN_CHARACTERS if language == "zh" else EN_SECTION_MIN_WORDS + opening_heading = "结论总纲" if language == "zh" else "Relationship Thesis" + attraction_heading = "吸引与亲密" if language == "zh" else "Attraction and Closeness" + opening = _opening_after_heading(report, opening_heading, headings) + analyst_terms = ZH_ANALYST_LED_OPENERS if language == "zh" else EN_ANALYST_LED_OPENERS + analyst_hits = [term for term in analyst_terms if term in (opening if language == "zh" else opening.lower())] + warmth_terms = ZH_AFFECTION_OPENING_TERMS if language == "zh" else EN_AFFECTION_OPENING_TERMS + warmth_hits = [term for term in warmth_terms if term in (opening if language == "zh" else opening.lower())] + attraction = section_bodies[attraction_heading] if language == "zh" else section_bodies[attraction_heading].lower() + attraction_warmth = ZH_AFFECTION_SECTION_WARMTH_TERMS if language == "zh" else EN_AFFECTION_SECTION_WARMTH_TERMS + attraction_boundary = ZH_AFFECTION_SECTION_BOUNDARY_TERMS if language == "zh" else EN_AFFECTION_SECTION_BOUNDARY_TERMS + attraction_warmth_hits = [term for term in attraction_warmth if term in attraction] + attraction_boundary_hits = [term for term in attraction_boundary if term in attraction] + narrative_d9_terms = ZH_D9_TERMS if language == "zh" else EN_D9_TERMS + d9_present = any(term.lower() in text for term in narrative_d9_terms) + reader_depth = len(re.findall(r"[\u4e00-\u9fff]", report)) if language == "zh" else len(re.findall(r"\b[A-Za-z][A-Za-z'-]*\b", report)) + reader_min = ZH_READER_MIN_CHARACTERS if language == "zh" else EN_READER_MIN_WORDS + forbidden = READER_BANNED_TERMS + forbidden_hits = [term for term in forbidden if term in (report if language == "zh" else report.lower())] + supplementary_hits = [term for term in SUPPLEMENTARY_METHOD_TERMS if term in report] + private_hits = [term for term in PRIVATE_EVIDENCE_TERMS if term in report] + checks = { + "dossier_ready": dossier.get("delivery_readiness") == "ready_for_narrative", + "all_headings": all(_heading_status(report, headings).values()), + "opening_is_reader_led": not analyst_hits, + "opening_affection": affection_status != "executed" or bool(warmth_hits), + "section_depth": all(size >= min_size for size in section_sizes.values()), + "attraction_reciprocity": affection_status != "executed" or bool(attraction_warmth_hits and attraction_boundary_hits), + "d9_coverage": d9_status != "executed" or d9_present, + "reader_depth": reader_depth >= reader_min, + "no_forbidden_language": not forbidden_hits, + "no_supplementary_method_names": not supplementary_hits, + "no_private_evidence_terms": not private_hits, + "english_is_not_mostly_cjk": language != "en" or len(re.findall(r"[\u4e00-\u9fff]", report)) / max(len(report), 1) < 0.05, + } + return { + "schema": SINGLE_LANGUAGE_SCHEMA, + "dossier_schema": dossier.get("schema"), + "language": language, + "composition": "direct_from_structured_dossier" if language == "zh" else "direct_from_structured_dossier_not_translated_from_chinese", + "headings": _heading_status(report, headings), + "checks": checks, + "source_map_status": _dossier_status(dossier), + "d9_narrative_coverage": {"available_status": d9_status, "required_in_narrative": d9_status == "executed", "present": d9_present}, + "opening_quality": {"analyst_led_openers": analyst_hits, "status": "passed" if not analyst_hits else "review_required"}, + "opening_affection_coverage": {"available_status": affection_status, "terms": warmth_hits, "status": "passed" if checks["opening_affection"] else "review_required"}, + "section_depth_coverage": {"sizes": section_sizes, "minimum": min_size, "status": "passed" if checks["section_depth"] else "review_required"}, + "attraction_reciprocity_coverage": {"warmth_terms": attraction_warmth_hits, "boundary_terms": attraction_boundary_hits, "status": "passed" if checks["attraction_reciprocity"] else "review_required"}, + "reader_depth": {"value": reader_depth, "minimum": reader_min, "status": "passed" if checks["reader_depth"] else "review_required"}, + "status": "passed" if all(checks.values()) else "review_required", + "boundary": "This validates one independently authored language. It does not certify a future second-language report or treat one language as a translation source.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate independent Chinese and English full synastry reports.") + parser.add_argument("--dossier", required=True, help="Private full-synastry dossier JSON") + parser.add_argument("--chinese", help="Chinese Markdown report") + parser.add_argument("--english", help="English Markdown report written directly from the dossier") + parser.add_argument("--language", choices=("zh", "en"), help="Validate one independently authored language") + parser.add_argument("--report", help="Single-language Markdown report used with --language") + parser.add_argument("--output", help="Private receipt output path") + args = parser.parse_args() + dossier = json.loads(Path(args.dossier).read_text(encoding="utf-8")) + if args.language: + if not args.report or args.chinese or args.english: + parser.error("--language requires --report and cannot be combined with --chinese or --english") + receipt = build_single_language_delivery_receipt( + dossier, Path(args.report).read_text(encoding="utf-8"), args.language, + ) + else: + if not args.chinese or not args.english: + parser.error("bilingual validation requires --chinese and --english") + receipt = build_delivery_receipt( + dossier, + Path(args.chinese).read_text(encoding="utf-8"), + Path(args.english).read_text(encoding="utf-8"), + ) + encoded = json.dumps(receipt, ensure_ascii=False, indent=2) + if args.output: + Path(args.output).write_text(encoded + "\n", encoding="utf-8") + else: + print(encoded) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/research/upstream_full_synastry_7a773d13/skill/scripts/validate_full_synastry_packets.py.txt b/docs/research/upstream_full_synastry_7a773d13/skill/scripts/validate_full_synastry_packets.py.txt new file mode 100644 index 00000000..a6aa7b79 --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/skill/scripts/validate_full_synastry_packets.py.txt @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Create a privacy-safe execution receipt for two full-reading packets. + +This is deliberately an audit adapter, not an interpreter. It establishes +whether both people were calculated under comparable settings and whether the +relationship route's individual evidence modules are actually present before a +synastry narrative is written. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any, Mapping + + +SCHEMA = "jyotish.full_synastry_execution_receipt.v1" + +# The paths are the native full-reading outputs. Several technique labels +# intentionally share a producer because one calculation supports more than +# one relationship question; the report writer must still account for each. +REQUIRED_TECHNIQUES: tuple[tuple[str, tuple[str, ...], str], ...] = ( + ("D1/Rashi", ("chart",), "natal promise and house framework"), + ("Functional Benefic/Malefic", ("functional_benefic_malefic",), "Lagna-specific roles"), + ("Bhava Chalit", ("bhava_chalit",), "house-boundary confirmation"), + ("Planetary Aspects", ("aspects",), "actual contact and friction channels"), + ("Planetary Friendship", ("planetary_friendship",), "planetary cooperation context"), + ("D9/Navamsa", ("varga_full.D9_Navamsa", "d9_navamsa_expanded"), "long-term relationship structure"), + ("Vimshottari", ("dasha",), "primary planet-period timing"), + ("Dasha Sandhi", ("dasha_sandhi",), "period-transition pressure"), + ("Narayana Dasha", ("narayana_dasha",), "rashi-period cross-check"), + ("Jaimini/Chara", ("jaimini",), "karakas, arudhas and rashi timing"), + ("Darakaraka (DK)", ("jaimini",), "partner significator"), + ("Upapada Lagna (UL)", ("special_lagnas", "yogas_doshas"), "marriage formation and sustenance"), + ("Argala", ("argala",), "support and obstruction"), + ("Shadbala", ("shadbala",), "relative planetary strength"), + ("Ashtakavarga", ("ashtakavarga",), "transit capacity"), + ("Vimsopaka", ("vimsopaka",), "cross-varga dignity weighting"), + ("Vargottama", ("vargottama",), "D1-D9 reinforcement"), + ("Pushkara", ("pushkara",), "supportive Navamsa condition"), + ("Dispositor Chain", ("dispositor_chains", "final_dispositors"), "energy-flow resolution"), + ("Inter-chart Linkage", ("inter_chart_linkage",), "D1-D9 linkage within each chart"), + ("Avasthas", ("avasthas",), "condition and capacity of relationship significators"), + ("Bhava Bala", ("bhava_bala",), "relative capacity of relationship and shared-life houses"), + ("Sensitive Points", ("sensitive_points",), "derived point context; never a standalone verdict"), + ("Lagna Vargottamamsa", ("lagna_vargottamamsa",), "D1-D9 ascendant reinforcement"), + ("Extended Vargas", ("varga_extended",), "D5/D6/D8/D11 context; use only by documented topic"), + ("Tajika Yogas", ("tajika_yogas",), "annual activation context"), + ("Vivah and other Sahams", ("sahams",), "event-sensitive points"), + ("Mangal Dosha", ("yogas_doshas",), "Mars-only marriage-pressure evidence"), + ("Punarphoo", ("punarphoo",), "observation-only Moon-Saturn layer"), + ("Arudha Lagna", ("special_lagnas", "yogas_doshas"), "public relationship frame"), + ("Tithi Lord", ("tithi_lord",), "lunar relationship context"), + ("Rashi Tulya Navamsa", ("rashi_tulya_navamsa",), "D1-D9 comparison"), + ("Marriage Counting", ("marriage_counting",), "supplemental relationship-pattern layer"), + ("D30 Trimshamsa", ("trimshamsa_d30",), "crisis-pressure context only"), + ("Muntha / Solar Return", ("muntha", "solar_return"), "annual relationship weather"), + ("Bhrigu Pada Dasha", ("bhrigu_pada_dasha",), "parameter-sensitive comparison layer"), + ("Nakshatra Advanced", ("nakshatra_dasha", "nakshatra_advanced"), "nakshatra timing/context"), + ("Double Transit / LL7L", ("double_transit_pac", "transit_ll7l"), "reference-date activation"), + ("Transit Multi-reference", ("transit_positions", "transit_multi_reference"), "cross-reference present activation"), + ("KP 7th-house layer", ("kp",), "formalisation question support"), + ("Sudarshana Chakra", ("sudarshana",), "triple-lagna confirmation"), + ("Yogini Dasha", ("yogini_dasha",), "secondary timing only; retain its own birth-balance boundary"), + ("Ashtottari Dasha", ("ashtottari_dasha",), "conditional secondary timing; verify applicability"), + ("Kalachakra Dasha", ("kalachakra_dasha",), "conditional secondary timing; verify method profile"), + ("Navamsha Dasha", ("navamsha_dasha",), "D9-oriented secondary timing; parameter-sensitive"), + ("Niryaana Shoola Dasha", ("niryaana_shoola_dasha",), "longevity-specific system; not applicable to relationship timing"), + ("Shastihayani Dasha", ("shastihayani_dasha",), "conditional secondary timing; applicability required"), + ("Dasha Convergence", ("dasa_convergence",), "cross-dasha agreement within each chart"), + ("Relationship Strict Evidence", ("relationship_strict_evidence",), "relationship-route rule and book-evidence payload"), + ("Western comparison layer", ("unified_western_reading",), "independent supporting lens"), +) + +PAIR_TASKS: tuple[tuple[str, tuple[str, ...], str], ...] = ( + ( + "D1/D9 mutual overlays and relationship axes", + ("chart", "varga_full.D9_Navamsa"), + "primary Jyotish pair structure", + ), + ( + "lived interaction: emotional safety, attraction, communication and conflict pattern", + ("chart", "aspects", "planetary_friendship", "varga_full.D9_Navamsa"), + "compare mutual Lagna/Moon/Venus/Mars/Mercury contacts and D9 confirmation; write observable interaction, not personality labels", + ), + ( + "shared gain and cost: resources, home, work and private-space axes", + ("chart", "varga_full.D2_Hora", "varga_full.D4_Turyamsa", "varga_full.D10_Dasamsa", "bhava_bala", "ashtakavarga"), + "compare 2/4/6/10/11/12-house capacity and relevant varga context; no financial prediction from compatibility alone", + ), + ( + "long-term intimacy and repair: 5/7/8/12 axes, UL/DK/A7, Argala and pressure layers", + ("chart", "jaimini", "special_lagnas", "argala", "yogas_doshas", "trimshamsa_d30"), + "separate attraction, commitment, deep binding, recovery and pressure; D30 is bounded context only", + ), + ( + "DK, UL, UL-second and A7/Darapada cross-comparison", + ("jaimini", "special_lagnas"), + "primary Jaimini marriage structure", + ), + ( + "two-person Dasha/Narayana/Chara context comparison", + ("dasha", "narayana_dasha", "jaimini"), + "primary timing convergence", + ), + ( + "secondary-dasha convergence and disagreement review", + ("nakshatra_dasha", "yogini_dasha", "ashtottari_dasha", "kalachakra_dasha", "navamsha_dasha", "dasa_convergence"), + "secondary systems qualify or challenge a primary timing window; never invent a date when profiles conflict", + ), + ( + "shared transit, Tajika, Saham and annual-window comparison", + ("tajika_yogas", "sahams", "muntha"), + "annual and transit corroboration", + ), + ( + "international Ashtakoota core, additional Kutas, Mars-only Kuja and separate Papasamya", + ("chart",), + "limited Moon/nakshatra and pressure context; build through marriage-match", + ), + ( + "Nakshatra relationship context", + ("nakshatra_dasha",), + "Jyotish nakshatra context; never a standalone marriage verdict", + ), + ( + "optional 27-nakshatra Shukuyo narrative layer", + (), + "separate cultural narrative only; never merge into Jyotish evidence", + ), + ( + "optional Western synastry/composite/Davison layer", + ("unified_western_reading",), + "separate supporting perspective; a natal Western packet is not a composite chart", + ), + ( + "Prashna context when a real question chart was supplied", + (), + "guarded context only; never replace the two natal charts", + ), +) + + +def _packet(value: Mapping[str, Any]) -> Mapping[str, Any]: + nested = value.get("raw_full_reading") if isinstance(value, Mapping) else None + return nested if isinstance(nested, Mapping) else value + + +def _get_path(value: Mapping[str, Any], dotted: str) -> Any: + cursor: Any = value + for key in dotted.split("."): + if not isinstance(cursor, Mapping) or key not in cursor: + return None + cursor = cursor[key] + return cursor + + +def _status(value: Any) -> str: + if value is None: + return "blocked" + if isinstance(value, Mapping): + state = str(value.get("execution_status") or value.get("status", "")).lower() + if state in {"blocked", "not_available", "unavailable", "skipped"}: + return "blocked" + if state in { + "partial", "partial_verified", "parameter_sensitive", "observation_only", + "guarded", "reference_only", "computed_pending_validation", + }: + return "parameter_sensitive" + if value.get("skipped") is True or value.get("available") is False: + return "blocked" + return "executed" + + +def _profile(packet: Mapping[str, Any]) -> dict[str, Any]: + birth = packet.get("birth_info", {}) if isinstance(packet.get("birth_info"), Mapping) else {} + profile = packet.get("calculation_profile", {}) if isinstance(packet.get("calculation_profile"), Mapping) else {} + settings = profile.get("effective_settings", {}) if isinstance(profile.get("effective_settings"), Mapping) else {} + return { + "ayanamsa": birth.get("ayanamsa_name") or birth.get("ayanamsa") or profile.get("ayanamsa") or settings.get("ayanamsa"), + "node_mode": birth.get("node_mode") or profile.get("node_mode") or settings.get("node_mode"), + "position_mode": profile.get("position_mode") or settings.get("position_mode"), + "house_system": profile.get("house_system") or settings.get("house_system"), + } + + +def _technique_row(packet: Mapping[str, Any], definition: tuple[str, tuple[str, ...], str]) -> dict[str, Any]: + label, candidates, purpose = definition + if label == "Niryaana Shoola Dasha": + return { + "technique": label, + "status": "not_applicable", + "source_path": None, + "purpose": purpose, + } + modules = packet.get("modules", {}) if isinstance(packet.get("modules"), Mapping) else {} + found_path = next((path for path in candidates if _get_path(modules, path) is not None), None) + value = _get_path(modules, found_path) if found_path else None + return { + "technique": label, + "status": _status(value), + "source_path": f"modules.{found_path}" if found_path else None, + "purpose": purpose, + } + + +def _pair_task_row( + person_a: Mapping[str, Any], + person_b: Mapping[str, Any], + definition: tuple[str, tuple[str, ...], str], +) -> dict[str, Any]: + """State whether both packets can support a comparison, not its conclusion.""" + task, required_paths, boundary = definition + if not required_paths: + return { + "task": task, + "status": "not_applicable", + "required_inputs": [], + "boundary": boundary, + } + modules_a = person_a.get("modules", {}) if isinstance(person_a.get("modules"), Mapping) else {} + modules_b = person_b.get("modules", {}) if isinstance(person_b.get("modules"), Mapping) else {} + inputs = [] + for path in required_paths: + a_value = _get_path(modules_a, path) + b_value = _get_path(modules_b, path) + a_status, b_status = _status(a_value), _status(b_value) + if a_status == b_status == "executed": + status = "ready_for_comparison" + elif "blocked" in {a_status, b_status}: + status = "blocked" + else: + status = "parameter_sensitive" + inputs.append({ + "source_path": f"modules.{path}", + "status": status, + "person_a_status": a_status, + "person_b_status": b_status, + }) + overall = "ready_for_comparison" + if any(row["status"] == "blocked" for row in inputs): + overall = "blocked" + elif any(row["status"] == "parameter_sensitive" for row in inputs): + overall = "parameter_sensitive" + return { + "task": task, + "status": overall, + "required_inputs": inputs, + "boundary": boundary, + } + + +def build_execution_receipt(person_a: Mapping[str, Any], person_b: Mapping[str, Any]) -> dict[str, Any]: + """Return a non-identifying audit receipt for a pair of full readings.""" + a, b = _packet(person_a), _packet(person_b) + profile_a, profile_b = _profile(a), _profile(b) + profile_complete = all(profile_a.values()) and all(profile_b.values()) + profile_match = profile_complete and all(profile_a[key] == profile_b[key] for key in profile_a) + rows = [] + for definition in REQUIRED_TECHNIQUES: + a_row, b_row = _technique_row(a, definition), _technique_row(b, definition) + individual_statuses = {a_row["status"], b_row["status"]} + combined = "executed" if individual_statuses == {"executed"} else "blocked" + if individual_statuses == {"not_applicable"}: + combined = "not_applicable" + elif combined == "blocked" and individual_statuses <= {"executed", "partial", "parameter_sensitive", "observation_only", "guarded", "not_applicable"}: + combined = "parameter_sensitive" + rows.append({ + "technique": definition[0], + "status": combined, + "person_a": a_row, + "person_b": b_row, + }) + return { + "schema": SCHEMA, + "calculation_profile": {"person_a": profile_a, "person_b": profile_b, "compatible": profile_match}, + "profile_gate": "passed" if profile_match else "blocked", + "profile_gate_reason": None if profile_match else ( + "calculation_profile_incomplete" if not profile_complete else "calculation_profile_mismatch" + ), + "individual_technique_audit": rows, + "pair_execution_tasks": [_pair_task_row(a, b, task) for task in PAIR_TASKS], + "boundary": ( + "This receipt proves packet presence and calculation-profile comparability only. " + "It does not turn a module into a conclusion; pair tasks, book evidence, MEVG and case calibration remain required." + ), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate two full-reading packets before a full synastry interpretation.") + parser.add_argument("--person-a", required=True, help="JSON full-reading or export packet for person A") + parser.add_argument("--person-b", required=True, help="JSON full-reading or export packet for person B") + parser.add_argument("--output", help="Optional receipt path; do not place customer data in a repository path.") + args = parser.parse_args() + with Path(args.person_a).open(encoding="utf-8") as handle: + person_a = json.load(handle) + with Path(args.person_b).open(encoding="utf-8") as handle: + person_b = json.load(handle) + receipt = build_execution_receipt(person_a, person_b) + encoded = json.dumps(receipt, ensure_ascii=False, indent=2) + if args.output: + Path(args.output).write_text(encoded + "\n", encoding="utf-8") + else: + print(encoded) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/research/upstream_full_synastry_7a773d13/tests/test_bilingual_synastry_delivery.py.txt b/docs/research/upstream_full_synastry_7a773d13/tests/test_bilingual_synastry_delivery.py.txt new file mode 100644 index 00000000..1695c6cc --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/tests/test_bilingual_synastry_delivery.py.txt @@ -0,0 +1,210 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "skills" / "jyotish-full-synastry-reading" / "scripts" / "validate_bilingual_synastry_delivery.py" +spec = importlib.util.spec_from_file_location("bilingual_synastry_delivery", MODULE_PATH) +module = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(module) + + +def _full_chinese_report() -> str: + headings = ["结论总纲", "各自带入关系的需求", "情绪安全与相处感受", "吸引与亲密", "沟通、冲突与修复", "关系如何推进", "共同生活的收益与压力", "能不能稳定走下去", "关系应期"] + prose = "双方需要清楚说明节奏、边界、现实安排与实际互动;任何判断都需由两人的现实经验验证,不能由单一配置替代。" + sections = [] + for heading in headings: + suffix = "D9 婚姻分盘用于检验长期承诺的结构,不能代替本命互动。" if heading == "能不能稳定走下去" else "" + sections.append(f"{heading}\n{(prose * 5) + suffix}") + return "\n".join(sections) + + +def _full_english_report() -> str: + headings = [ + "Relationship Thesis", "What Each Person Brings", "Emotional Safety and Lived Experience", + "Attraction and Closeness", "Communication, Conflict and Repair", + "How the Relationship Progresses", "Shared Life: Gains and Pressures", + "Can It Stay Steady?", "Relationship Weather", + ] + prose = "Each conclusion remains conditional on both charts, actual interaction, practical circumstances, and repeated real-world observation by both people. " + sections = [] + for heading in headings: + suffix = "Navamsa (D9) qualifies long-term commitment and does not replace lived natal interaction. " if heading == "Can It Stay Steady?" else "" + sections.append(f"{heading}\n{(prose * 8) + suffix}") + return "\n".join(sections) + + +def test_receipt_requires_independent_full_report_shapes_without_forced_term_dumping(): + dossier = { + "schema": "jyotish.full_synastry_dossier.v1", + "delivery_readiness": "ready_for_narrative", + "source_map": [], + "execution_receipt": {"pair_execution_tasks": []}, + } + chinese = _full_chinese_report() + english = _full_english_report() + receipt = module.build_delivery_receipt(dossier, chinese, english) + assert receipt["status"] == "passed" + assert receipt["composition"]["english"] == "direct_from_structured_dossier_not_translated_from_chinese" + assert not any(receipt["supplementary_method_terms"]["english"].values()) + + +def test_receipt_rejects_incomplete_or_chinese_english_delivery(): + receipt = module.build_delivery_receipt({"delivery_readiness": "blocked"}, "结论总纲", "结论总纲") + assert receipt["status"] == "review_required" + + +def test_receipt_allows_primary_jyotish_terms_but_hides_supplementary_method_names(): + dossier = { + "schema": "jyotish.full_synastry_dossier.v1", + "delivery_readiness": "ready_for_narrative", + "source_map": [], + "execution_receipt": {"pair_execution_tasks": []}, + } + chinese = _full_chinese_report() + "\nD9 与 UL 的结论在此一致" + english = _full_english_report() + "\nNavamsa Upapada Darakaraka Vimshottari Narayana" + assert module.build_delivery_receipt(dossier, chinese, english)["status"] == "passed" + assert module.build_delivery_receipt(dossier, chinese, english + "\nWestern synastry")["status"] == "review_required" + assert module.build_delivery_receipt(dossier, chinese, english + "\nexecution_receipt")["status"] == "review_required" + assert module.build_delivery_receipt(dossier, chinese, english + "\nreader_section_brief")["status"] == "review_required" + + +def test_receipt_rejects_heading_only_or_fated_language(): + dossier = {"schema": "jyotish.full_synastry_dossier.v1", "delivery_readiness": "ready_for_narrative", "source_map": [], "execution_receipt": {"pair_execution_tasks": []}} + assert module.build_delivery_receipt(dossier, "结论总纲", "Relationship Thesis")["reader_depth"]["status"] == "review_required" + assert module.build_delivery_receipt(dossier, _full_chinese_report() + "灵魂伴侣", _full_english_report())["status"] == "review_required" + + +def test_receipt_rejects_analyst_led_not_x_but_y_opening(): + dossier = {"schema": "jyotish.full_synastry_dossier.v1", "delivery_readiness": "ready_for_narrative", "source_map": [], "execution_receipt": {"pair_execution_tasks": []}} + chinese = _full_chinese_report().replace("结论总纲", "结论总纲\n这段关系的核心,不是有没有吸引,而是能否落实承诺。", 1) + receipt = module.build_delivery_receipt(dossier, chinese, _full_english_report()) + assert receipt["opening_quality"]["status"] == "review_required" + assert receipt["status"] == "review_required" + + +def test_receipt_requires_warm_reciprocal_opening_when_affection_evidence_is_executed(): + dossier = { + "schema": "jyotish.full_synastry_dossier.v1", + "delivery_readiness": "ready_for_narrative", + "source_map": [], + "execution_receipt": {"pair_execution_tasks": []}, + "mutual_affection_activation": {"status": "executed"}, + } + cold_chinese = _full_chinese_report() + cold_english = _full_english_report() + cold = module.build_delivery_receipt(dossier, cold_chinese, cold_english) + assert cold["opening_affection_coverage"]["required_in_opening"] is True + assert cold["status"] == "review_required" + + chinese = cold_chinese.replace( + "结论总纲\n", "结论总纲\nA 会在 B 的稳定回应里感到被重视与安心,B 也会在 A 的投入中感到被珍惜。\n", 1, + ) + english = cold_english.replace( + "Relationship Thesis\n", "Relationship Thesis\nA feels valued and safe in B's steady response, while B feels cherished by A's investment.\n", 1, + ) + receipt = module.build_delivery_receipt(dossier, chinese, english) + assert receipt["opening_affection_coverage"]["chinese_terms"] + assert receipt["opening_affection_coverage"]["english_terms"] + assert receipt["attraction_reciprocity_coverage"]["status"] == "review_required" + + +def test_receipt_rejects_long_report_with_empty_reader_sections(): + dossier = { + "schema": "jyotish.full_synastry_dossier.v1", + "delivery_readiness": "ready_for_narrative", + "source_map": [], + "execution_receipt": {"pair_execution_tasks": []}, + } + headings_zh = "\n".join(["结论总纲", "各自带入关系的需求", "情绪安全与相处感受", "吸引与亲密", "沟通、冲突与修复", "关系如何推进", "共同生活的收益与压力", "能不能稳定走下去", "关系应期"]) + headings_en = "\n".join(["Relationship Thesis", "What Each Person Brings", "Emotional Safety and Lived Experience", "Attraction and Closeness", "Communication, Conflict and Repair", "How the Relationship Progresses", "Shared Life: Gains and Pressures", "Can It Stay Steady?", "Relationship Weather"]) + receipt = module.build_delivery_receipt( + dossier, + headings_zh + "\n" + "双方需要核对实际互动与现实安排。" * 140, + headings_en + "\n" + "Both people need to verify practical interaction and real circumstances before drawing conclusions. " * 180, + ) + assert receipt["reader_depth"]["status"] == "passed" + assert receipt["section_depth_coverage"]["status"] == "review_required" + assert receipt["status"] == "review_required" + + +def test_section_depth_recognizes_markdown_and_numbered_headings(): + chinese = "## 一、结论总纲\n" + "双方需要把现实互动说清。" * 20 + english = "## I. Relationship Thesis\n" + "Both people need clear real-world interaction before conclusions. " * 20 + assert module._section_bodies(chinese, ("结论总纲",))["结论总纲"] + assert module._section_bodies(english, ("Relationship Thesis",))["Relationship Thesis"] + + +def test_receipt_requires_warmth_and_space_in_attraction_section_when_evidence_exists(): + dossier = { + "schema": "jyotish.full_synastry_dossier.v1", + "delivery_readiness": "ready_for_narrative", + "source_map": [], + "execution_receipt": {"pair_execution_tasks": []}, + "mutual_affection_activation": {"status": "executed"}, + } + chinese = _full_chinese_report().replace( + "结论总纲\n", "结论总纲\nA 会在 B 的稳定回应里感到被重视与安心,B 也会在 A 的投入中感到被珍惜。\n", 1, + ) + english = _full_english_report().replace( + "Relationship Thesis\n", "Relationship Thesis\nA feels valued and safe in B's steady response, while B feels cherished by A's investment.\n", 1, + ) + cold_attraction = module.build_delivery_receipt(dossier, chinese, english) + assert cold_attraction["attraction_reciprocity_coverage"]["status"] == "review_required" + + chinese = chinese.replace("吸引与亲密\n", "吸引与亲密\n双方在被珍惜时更愿意靠近,也要为彼此保留空间,避免压力累积。\n", 1) + english = english.replace("Attraction and Closeness\n", "Attraction and Closeness\nBoth feel warmer and closer when valued, while each needs space so pressure does not accumulate.\n", 1) + receipt = module.build_delivery_receipt(dossier, chinese, english) + assert receipt["attraction_reciprocity_coverage"]["status"] == "passed" + assert receipt["status"] == "passed" + + +def test_receipt_requires_d9_narrative_when_native_d9_pair_evidence_executed(): + dossier = { + "schema": "jyotish.full_synastry_dossier.v1", + "delivery_readiness": "ready_for_narrative", + "source_map": [], + "execution_receipt": {"pair_execution_tasks": []}, + "pair_interaction_evidence": {"d9_interaction_evidence": {"status": "executed"}}, + } + receipt = module.build_delivery_receipt(dossier, _full_chinese_report(), _full_english_report()) + assert receipt["status"] == "passed" + assert receipt["d9_narrative_coverage"]["required_in_narrative"] is True + without_d9 = _full_chinese_report().replace("D9 婚姻分盘用于检验长期承诺的结构,不能代替本命互动。", "") + assert module.build_delivery_receipt(dossier, without_d9, _full_english_report())["status"] == "review_required" + + +def test_single_language_receipt_accepts_an_independent_chinese_delivery(): + dossier = { + "schema": "jyotish.full_synastry_dossier.v1", + "delivery_readiness": "ready_for_narrative", + "source_map": [], + "execution_receipt": {"pair_execution_tasks": []}, + } + receipt = module.build_single_language_delivery_receipt( + dossier, _full_chinese_report(), "zh", + ) + assert receipt["status"] == "passed" + assert receipt["schema"] == module.SINGLE_LANGUAGE_SCHEMA + assert receipt["composition"] == "direct_from_structured_dossier" + + +def test_single_language_receipt_keeps_affection_and_section_gates(): + dossier = { + "schema": "jyotish.full_synastry_dossier.v1", + "delivery_readiness": "ready_for_narrative", + "source_map": [], + "execution_receipt": {"pair_execution_tasks": []}, + "mutual_affection_activation": {"status": "executed"}, + } + receipt = module.build_single_language_delivery_receipt(dossier, _full_chinese_report(), "zh") + assert receipt["status"] == "review_required" + warm = _full_chinese_report().replace( + "结论总纲\n", "结论总纲\n双方在稳定回应中感到被重视与珍惜,也能安心靠近。\n", 1, + ).replace( + "吸引与亲密\n", "吸引与亲密\n彼此被珍惜时更愿意靠近,也需保留空间和边界,避免压力累积。\n", 1, + ) + assert module.build_single_language_delivery_receipt(dossier, warm, "zh")["status"] == "passed" diff --git a/docs/research/upstream_full_synastry_7a773d13/tests/test_compose_full_synastry_delivery.py.txt b/docs/research/upstream_full_synastry_7a773d13/tests/test_compose_full_synastry_delivery.py.txt new file mode 100644 index 00000000..67e3fd54 --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/tests/test_compose_full_synastry_delivery.py.txt @@ -0,0 +1,166 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "skills" / "jyotish-full-synastry-reading" / "scripts" / "compose_full_synastry_delivery.py" +spec = importlib.util.spec_from_file_location("compose_full_synastry_delivery", MODULE_PATH) +module = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(module) + + +def _dossier(*, affection="not_available"): + return { + "schema": "jyotish.full_synastry_dossier.v1", + "delivery_readiness": "ready_for_narrative", + "reader_section_brief": [{"section": key} for key in module.SECTION_KEYS], + "source_map": [], + "execution_receipt": {"pair_execution_tasks": []}, + "mutual_affection_activation": {"status": affection}, + } + + +def _payload(language: str): + if language == "zh": + prose = "双方需要清楚说明节奏、边界、现实安排与实际互动;任何判断都需由两人的现实经验验证,不能由单一配置替代。" + sections = {key: prose * 5 for key in module.SECTION_KEYS} + return { + "language": "zh", + "composition": module.EXPECTED_COMPOSITION, + "title": "双方的恋爱与婚姻合盘", + "sections": sections, + } + prose = "Each conclusion remains conditional on both charts, actual interaction, practical circumstances, and repeated real-world observation by both people. " + sections = {key: prose * 8 for key in module.SECTION_KEYS} + return { + "language": "en", + "composition": module.EXPECTED_COMPOSITION, + "title": "Full Love and Marriage Synastry Reading", + "sections": sections, + } + + +def test_compiler_requires_ready_dossier_and_independent_complete_section_payloads(): + compiled = module.compile_delivery(_dossier(), _payload("zh"), _payload("en")) + assert compiled["delivery_receipt"]["status"] == "passed" + assert "## 结论总纲" in compiled["chinese_markdown"] + assert "## Relationship Thesis" in compiled["english_markdown"] + assert "reader_section_brief" not in compiled["english_markdown"] + + incomplete = _payload("zh") + del incomplete["sections"]["relationship_weather"] + with pytest.raises(ValueError, match="missing=relationship_weather"): + module.compile_delivery(_dossier(), incomplete, _payload("en")) + with pytest.raises(ValueError, match="not ready_for_narrative"): + module.compile_delivery({**_dossier(), "delivery_readiness": "blocked"}, _payload("zh"), _payload("en")) + + +def test_compiler_requires_private_output_directory(tmp_path): + assert module._private_output_directory(str(tmp_path)) == tmp_path.resolve() + with pytest.raises(ValueError, match="outside the repository"): + module._private_output_directory(str(ROOT / "outputs")) + + +def test_cli_writes_only_a_passed_private_delivery(tmp_path, monkeypatch): + dossier_path = tmp_path / "dossier.json" + chinese_path = tmp_path / "chinese.json" + english_path = tmp_path / "english.json" + output_dir = tmp_path / "delivery" + dossier_path.write_text(json.dumps(_dossier()), encoding="utf-8") + chinese_path.write_text(json.dumps(_payload("zh"), ensure_ascii=False), encoding="utf-8") + english_path.write_text(json.dumps(_payload("en")), encoding="utf-8") + monkeypatch.setattr(sys, "argv", [ + "compose_full_synastry_delivery.py", + "--dossier", str(dossier_path), + "--chinese-sections", str(chinese_path), + "--english-sections", str(english_path), + "--output-dir", str(output_dir), + ]) + + assert module.main() == 0 + assert (output_dir / "Chinese-Full-Synastry-Report.md").is_file() + assert (output_dir / "English-Full-Synastry-Report.md").is_file() + receipt = json.loads((output_dir / "bilingual-delivery-receipt.json").read_text(encoding="utf-8")) + assert receipt["status"] == "passed" + + +def test_cli_does_not_create_output_directory_when_the_gate_fails(tmp_path, monkeypatch): + dossier_path = tmp_path / "dossier.json" + chinese_path = tmp_path / "chinese.json" + english_path = tmp_path / "english.json" + output_dir = tmp_path / "failed-delivery" + dossier_path.write_text(json.dumps({**_dossier(), "delivery_readiness": "blocked"}), encoding="utf-8") + chinese_path.write_text(json.dumps(_payload("zh"), ensure_ascii=False), encoding="utf-8") + english_path.write_text(json.dumps(_payload("en")), encoding="utf-8") + monkeypatch.setattr(sys, "argv", [ + "compose_full_synastry_delivery.py", + "--dossier", str(dossier_path), + "--chinese-sections", str(chinese_path), + "--english-sections", str(english_path), + "--output-dir", str(output_dir), + ]) + + with pytest.raises(ValueError, match="not ready_for_narrative"): + module.main() + assert not output_dir.exists() + + +def test_cli_dry_run_validates_without_creating_a_delivery(tmp_path, monkeypatch, capsys): + dossier_path = tmp_path / "dossier.json" + chinese_path = tmp_path / "chinese.json" + english_path = tmp_path / "english.json" + output_dir = tmp_path / "dry-run-delivery" + dossier_path.write_text(json.dumps(_dossier()), encoding="utf-8") + chinese_path.write_text(json.dumps(_payload("zh"), ensure_ascii=False), encoding="utf-8") + english_path.write_text(json.dumps(_payload("en")), encoding="utf-8") + monkeypatch.setattr(sys, "argv", [ + "compose_full_synastry_delivery.py", + "--dossier", str(dossier_path), + "--chinese-sections", str(chinese_path), + "--english-sections", str(english_path), + "--output-dir", str(output_dir), + "--dry-run", + ]) + + assert module.main() == 0 + assert json.loads(capsys.readouterr().out)["status"] == "would-deliver" + assert not output_dir.exists() + + +def test_publisher_refuses_to_overwrite_an_existing_private_delivery(tmp_path): + output_dir = tmp_path / "existing-delivery" + output_dir.mkdir() + marker = output_dir / "do-not-overwrite.txt" + marker.write_text("existing", encoding="utf-8") + compiled = module.compile_delivery(_dossier(), _payload("zh"), _payload("en")) + + with pytest.raises(ValueError, match="already exists"): + module._publish_delivery_bundle(output_dir, compiled) + assert marker.read_text(encoding="utf-8") == "existing" + + +def test_single_language_compiler_and_cli_publish_without_a_placeholder_peer(tmp_path, monkeypatch): + compiled = module.compile_single_language_delivery(_dossier(), _payload("zh"), "zh") + assert compiled["delivery_receipt"]["status"] == "passed" + assert "## 结论总纲" in compiled["markdown"] + + dossier_path = tmp_path / "dossier.json" + sections_path = tmp_path / "zh.json" + output_dir = tmp_path / "zh-delivery" + dossier_path.write_text(json.dumps(_dossier()), encoding="utf-8") + sections_path.write_text(json.dumps(_payload("zh"), ensure_ascii=False), encoding="utf-8") + monkeypatch.setattr(sys, "argv", [ + "compose_full_synastry_delivery.py", "--dossier", str(dossier_path), + "--language", "zh", "--sections", str(sections_path), "--output-dir", str(output_dir), + ]) + assert module.main() == 0 + assert (output_dir / "Chinese-Full-Synastry-Report.md").is_file() + assert (output_dir / "zh-delivery-receipt.json").is_file() + assert not (output_dir / "English-Full-Synastry-Report.md").exists() diff --git a/docs/research/upstream_full_synastry_7a773d13/tests/test_full_synastry_dossier.py.txt b/docs/research/upstream_full_synastry_7a773d13/tests/test_full_synastry_dossier.py.txt new file mode 100644 index 00000000..ba7c5903 --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/tests/test_full_synastry_dossier.py.txt @@ -0,0 +1,253 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "skills" / "jyotish-full-synastry-reading" / "scripts" / "build_full_synastry_dossier.py" +spec = importlib.util.spec_from_file_location("full_synastry_dossier", MODULE_PATH) +module = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(module) + + +def _packet(*, ayanamsa="lahiri"): + modules = {"chart": { + "ascendant": {"lon": 0.0}, + "planets": { + "Moon": {"degree_raw": 0.0}, "Venus": {"degree_raw": 20.0}, + "Mars": {"degree_raw": 180.0}, "Saturn": {"degree_raw": 210.0}, + "Sun": {"degree_raw": 120.0}, "Rahu": {"degree_raw": 300.0}, "Ketu": {"degree_raw": 120.0}, + }, + }, "functional_benefic_malefic": {}} + for _, candidates, _ in module._RECEIPT.REQUIRED_TECHNIQUES: + root, *rest = candidates[0].split(".") + modules.setdefault(root, {}) + cursor = modules[root] + for key in rest[:-1]: + cursor = cursor.setdefault(key, {}) + if rest: + cursor[rest[-1]] = {} + modules.setdefault("varga_full", {}).update({ + "D2_Hora": {}, "D4_Turyamsa": {}, "D10_Dasamsa": {}, "D9_Navamsa": {}, + }) + return { + "birth_info": {"ayanamsa_name": ayanamsa, "node_mode": "mean"}, + "calculation_profile": {"zodiac": "sidereal", "position_mode": "legacy", "house_system": "whole_sign"}, + "modules": modules, + } + + +def _d9_packet(*, ayanamsa="lahiri"): + packet = _packet(ayanamsa=ayanamsa) + d9 = { + "Ascendant": {"sign_idx": 0, "degree_in_sign": 5.0}, + "Moon": {"sign_idx": 3, "degree_in_sign": 10.0}, + "Venus": {"sign_idx": 6, "degree_in_sign": 15.0}, + "Mars": {"sign_idx": 9, "degree_in_sign": 20.0}, + "Saturn": {"sign_idx": 10, "degree_in_sign": 2.0}, + } + packet["modules"]["varga_full"]["D9_Navamsa"] = d9 + return packet + + +def test_dossier_preserves_full_module_inventory_and_uses_neutral_match_product(): + dossier = module.build_full_synastry_dossier(_packet(), _packet()) + assert dossier["delivery_readiness"] == "ready_for_narrative" + assert dossier["marriage_match"]["product"] == "marriage_match_product_v1" + assert dossier["marriage_match"]["kuja_mangal_dosha"]["gender_asymmetry_used"] is False + assert {row["person"] for row in dossier["module_inventory"]} == {"person_a", "person_b"} + assert all("source_path" in row for row in dossier["source_map"] if row["kind"] == "individual_technique") + narrative_plan = dossier["module_narrative_plan"] + assert {row["person"] for row in narrative_plan} == {"person_a", "person_b"} + d9_rows = [row for row in narrative_plan if row["module_id"] == "varga_full"] + assert all("can_it_stay_steady" in row["reader_sections"] for row in d9_rows) + assert {row["lane"] for row in dossier["relationship_lenses"]} == { + "relationship_state", "emotional_safety", "attraction_and_closeness", + "communication_conflict_and_repair", "relationship_progression", + "shared_life_gains_and_pressures", "stability_and_long_term_repair", + "near_term_relationship_weather", + } + assert all(row["status"] == "ready_for_comparison" for row in dossier["relationship_lenses"]) + interactions = dossier["pair_interaction_evidence"] + assert interactions["status"] == "executed" + assert len(interactions["person_a_to_person_b_overlays"]) == 7 + assert interactions["angular_contacts"] + assert interactions["jaimini_rashi_contacts"] + assert interactions["recipient_experience_evidence"]["person_a"] + assert interactions["recipient_experience_evidence"]["person_b"] + affection = dossier["mutual_affection_activation"] + assert affection["status"] == "executed" + assert affection["recipients"]["person_a"]["d1_evidence"] + assert len(affection["recipients"]["person_b"]["writer_obligations"]) == 4 + section_brief = {row["section"]: row for row in dossier["reader_section_brief"]} + assert set(section_brief) == { + "relationship_thesis", "what_each_person_brings", "emotional_safety", + "attraction_and_closeness", "communication_conflict_and_repair", + "relationship_progression", "shared_life_gains_and_pressures", + "can_it_stay_steady", "relationship_weather", + } + attraction = section_brief["attraction_and_closeness"] + assert attraction["d1_recipient_evidence"]["person_a"] + assert attraction["d1_recipient_evidence"]["person_b"] + assert "d9_recipient_evidence" in attraction + assert attraction["reciprocal_care"]["status"] == "executed" + assert section_brief["relationship_weather"]["event_resonance_gate"]["boundary"] + + +def test_dossier_blocks_mixed_calculation_profiles_without_dropping_inventory(): + dossier = module.build_full_synastry_dossier(_packet(), _packet(ayanamsa="raman")) + assert dossier["delivery_readiness"] == "blocked" + assert dossier["profile_gate"] == "blocked" + assert dossier["module_inventory"] + + +def test_pair_interaction_evidence_keeps_direction_and_blocks_without_both_ascendants(): + a = _packet() + b = _packet() + b["modules"]["chart"]["ascendant"] = {} + interactions = module.build_pair_interaction_evidence(a, b) + assert interactions["status"] == "blocked" + assert not interactions["person_a_to_person_b_overlays"] + assert interactions["angular_contacts"] + assert {row["direction"] for row in interactions["angular_contacts"]} <= { + "person_a_to_person_b", "person_b_to_person_a", "mutual_angle", "undetermined_direction", + } + + +def test_same_named_special_aspect_uses_directed_angle_not_planet_label(): + a = _packet() + b = _packet() + a["modules"]["chart"]["planets"]["Mars"]["degree_raw"] = 0.0 + b["modules"]["chart"]["planets"]["Mars"]["degree_raw"] = 270.0 + contacts = module.build_pair_interaction_evidence(a, b)["angular_contacts"] + mars_contact = next( + row for row in contacts + if row["person_a_planet"] == row["person_b_planet"] == "Mars" + and row["aspect_type"] == "Mars_aspect_4" + ) + assert mars_contact["direction"] == "person_b_to_person_a" + + +def test_recipient_experience_evidence_keeps_each_persons_received_contacts_separate(): + a, b = _packet(), _packet() + a["modules"]["chart"]["planets"]["Mars"]["degree_raw"] = 180.0 + b["modules"]["chart"]["ascendant"]["lon"] = 0.0 + interactions = module.build_pair_interaction_evidence(a, b) + received_by_b = interactions["recipient_experience_evidence"]["person_b"] + mars_overlay = next(item for item in received_by_b if item["evidence_type"] == "house_overlay" and item["source_planet"] == "Mars") + assert mars_overlay["source_direction"] == "person_a_to_person_b" + assert mars_overlay["recipient_house"] == 7 + assert "attraction_and_closeness" in mars_overlay["reader_topics"] + assert "relationship_progression_and_commitment" in mars_overlay["reader_topics"] + + +def test_pair_interaction_evidence_keeps_native_d9_contacts_separate_from_d1(): + a, b = _d9_packet(), _d9_packet() + b["modules"]["varga_full"]["D9_Navamsa"]["Ascendant"] = {"sign_idx": 6, "degree_in_sign": 0.0} + interactions = module.build_pair_interaction_evidence(a, b) + d9 = interactions["d9_interaction_evidence"] + assert interactions["division"] == "D1" + assert d9["status"] == "executed" + assert d9["division"] == "D9" + assert d9["source_paths"] == ["modules.varga_full.D9_Navamsa"] + moon_overlay = next(row for row in d9["person_a_to_person_b_overlays"] if row["planet"] == "Moon") + assert moon_overlay["recipient_house"] == 10 + assert "Do not average" in interactions["division_boundary"] + + +def test_parallel_timing_context_keeps_native_periods_separate_without_rebuilding_dasha(): + a, b = _packet(), _packet() + a["modules"]["dasha"] = { + "reference_date": "2026-10-17", + "current_dasha": { + "lord": "Jupiter", "start_datetime": "2024-01-01T00:00:00", "end_datetime": "2040-01-01T00:00:00", + "antardasha": { + "lord": "Venus", "start_datetime": "2026-01-01T00:00:00", "end_datetime": "2028-01-01T00:00:00", + "pratyantar_dasha_timeline": [ + {"lord": "Moon", "start_datetime": "2026-10-01T00:00:00", "end_datetime": "2026-12-01T00:00:00", "is_current": True}, + ], + }, + }, + } + b["modules"]["dasha"] = { + "reference_date": "2026-10-17", + "current_dasha": { + "lord": "Saturn", "start_datetime": "2020-06-01T00:00:00", "end_datetime": "2039-06-01T00:00:00", + "antardasha": { + "lord": "Mercury", "start_datetime": "2026-06-01T00:00:00", "end_datetime": "2027-06-01T00:00:00", + "pratyantar_dasha_timeline": [ + {"lord": "Mars", "start_datetime": "2026-10-15T00:00:00", "end_datetime": "2026-11-15T00:00:00", "is_current": True}, + ], + }, + }, + } + timing = module.build_full_synastry_dossier(a, b)["parallel_timing_context"] + assert timing["status"] == "executed" + assert timing["reference_alignment"] == "matched" + assert {(item["level"], item["label"]) for item in timing["person_a_active_periods"]} == { + ("mahadasha", "Jupiter"), ("antardasha", "Venus"), ("pratyantar", "Moon"), + } + assert {(item["level"], item["label"]) for item in timing["person_b_active_periods"]} == { + ("mahadasha", "Saturn"), ("antardasha", "Mercury"), ("pratyantar", "Mars"), + } + assert timing["event_resonance_requirement"]["status"] == "not_established_by_parallel_periods" + assert "Never calculate" in timing["boundary"] + + +def test_event_resonance_gate_rejects_generic_window_scores_and_requires_named_event(): + a, b = _packet(), _packet() + strict = { + "event_judgement": {"event_class": "relationship_formation", "verdict": "high_probability_window", "score": 89}, + "prediction_boundary_contract": { + "promise": {"status": "present"}, "activation": {"status": "present"}, + "manifestation": {"status": "present"}, "label": {"status": "present"}, + }, + } + a["modules"]["relationship_strict_evidence"] = strict + b["modules"]["relationship_strict_evidence"] = strict + gate = module.build_full_synastry_dossier(a, b)["event_resonance_gate"] + formation = next(row for row in gate["event_classes"] if row["event_class"] == "relationship_formation") + assert formation["status"] == "candidate_alignment_requires_event_history" + assert gate["person_a"]["ignored_non_event_fields"]["score"] == 89 + assert "never asserts" in gate["boundary"] + + +def test_event_resonance_gate_never_promotes_a_generic_verdict_without_event_label(): + a, b = _packet(), _packet() + strict = { + "event_judgement": {"verdict": "high_probability_window", "score": 83}, + "prediction_boundary_contract": { + "promise": {"status": "present"}, "activation": {"status": "weak"}, + "manifestation": {"status": "weak"}, "label": {"status": "missing"}, + }, + } + a["modules"]["relationship_strict_evidence"] = strict + b["modules"]["relationship_strict_evidence"] = strict + gate = module.build_full_synastry_dossier(a, b)["event_resonance_gate"] + assert gate["person_a"]["event_class"] is None + assert gate["person_a"]["status"] == "event_class_not_established" + assert all(row["status"] == "not_established" for row in gate["event_classes"]) + + +def test_event_resonance_gate_keeps_native_event_candidate_observation_only(): + a, b = _packet(), _packet() + for packet in (a, b): + packet["modules"]["chart"]["ascendant"]["sign"] = "Leo" + packet["modules"]["dasha"] = { + "current_dasha": {"mahadasha": "Saturn", "antardasha": {"lord": "Mercury"}}, + } + packet["modules"]["relationship_strict_evidence"] = { + "event_judgement": {"verdict": "high_probability_window", "score": 83}, + "prediction_boundary_contract": { + "promise": {"status": "present"}, "activation": {"status": "weak"}, + "manifestation": {"status": "weak"}, "label": {"status": "missing"}, + }, + } + gate = module.build_full_synastry_dossier(a, b)["event_resonance_gate"] + formation = next(row for row in gate["event_classes"] if row["event_class"] == "relationship_formation") + assert gate["person_a"]["event_class"] == "relationship_formation" + assert gate["person_a"]["event_class_source"] == "native_observation_only" + assert gate["person_a"]["status"] == "event_class_observation_only" + assert formation["status"] == "parallel_observation_only" diff --git a/docs/research/upstream_full_synastry_7a773d13/tests/test_full_synastry_execution_contract.py.txt b/docs/research/upstream_full_synastry_7a773d13/tests/test_full_synastry_execution_contract.py.txt new file mode 100644 index 00000000..8043b05f --- /dev/null +++ b/docs/research/upstream_full_synastry_7a773d13/tests/test_full_synastry_execution_contract.py.txt @@ -0,0 +1,142 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "skills" / "jyotish-full-synastry-reading" / "scripts" / "validate_full_synastry_packets.py" +spec = importlib.util.spec_from_file_location("full_synastry_execution", MODULE_PATH) +module = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(module) + + +def _packet(*, ayanamsa="lahiri", blocked=None, parameter_sensitive=None): + modules = {"chart": {"houses": {}}, "functional_benefic_malefic": {}} + for _, candidates, _ in module.REQUIRED_TECHNIQUES: + root, *rest = candidates[0].split(".") + root_status = ( + "blocked" if candidates[0] == blocked + else "observation_only" if candidates[0] == parameter_sensitive + else None + ) + modules.setdefault(root, {"status": root_status} if root_status else {}) + cursor = modules[root] + for key in rest[:-1]: + cursor = cursor.setdefault(key, {}) + if rest: + status = ( + "blocked" if candidates[0] == blocked + else "observation_only" if candidates[0] == parameter_sensitive + else None + ) + cursor[rest[-1]] = {"status": status} if status else {} + return { + "birth_info": {"ayanamsa_name": ayanamsa, "node_mode": "mean"}, + "calculation_profile": { + "zodiac": "sidereal", + "position_mode": "legacy", + "house_system": "whole_sign", + }, + "modules": modules, + } + + +def test_full_synastry_receipt_requires_every_registered_technique_for_both_people(): + receipt = module.build_execution_receipt(_packet(), _packet()) + assert receipt["profile_gate"] == "passed" + assert len(receipt["individual_technique_audit"]) == len(module.REQUIRED_TECHNIQUES) + by_technique = {row["technique"]: row["status"] for row in receipt["individual_technique_audit"]} + assert by_technique["Niryaana Shoola Dasha"] == "not_applicable" + assert {status for technique, status in by_technique.items() if technique != "Niryaana Shoola Dasha"} == {"executed"} + statuses = {task["task"]: task["status"] for task in receipt["pair_execution_tasks"]} + assert statuses["D1/D9 mutual overlays and relationship axes"] == "ready_for_comparison" + assert statuses["DK, UL, UL-second and A7/Darapada cross-comparison"] == "ready_for_comparison" + assert statuses["two-person Dasha/Narayana/Chara context comparison"] == "ready_for_comparison" + assert statuses["shared transit, Tajika, Saham and annual-window comparison"] == "ready_for_comparison" + assert statuses["Nakshatra relationship context"] == "ready_for_comparison" + assert statuses["optional 27-nakshatra Shukuyo narrative layer"] == "not_applicable" + assert statuses["Prashna context when a real question chart was supplied"] == "not_applicable" + + +def test_shared_life_pair_task_uses_the_native_d4_turyamsa_key(): + a, b = _packet(), _packet() + for packet in (a, b): + packet["modules"].setdefault("varga_full", {}).update({ + "D2_Hora": {}, "D4_Turyamsa": {}, "D10_Dasamsa": {}, + }) + packet["modules"]["bhava_bala"] = {} + packet["modules"]["ashtakavarga"] = {} + receipt = module.build_execution_receipt(a, b) + shared_life = next( + row for row in receipt["pair_execution_tasks"] + if row["task"].startswith("shared gain and cost") + ) + assert shared_life["status"] == "ready_for_comparison" + assert "modules.varga_full.D4_Turyamsa" in { + row["source_path"] for row in shared_life["required_inputs"] + } + + +def test_full_synastry_receipt_preserves_blocked_module_and_profile_mismatch(): + receipt = module.build_execution_receipt( + _packet(ayanamsa="lahiri"), + _packet(ayanamsa="raman", blocked="dasha"), + ) + dasha = next(row for row in receipt["individual_technique_audit"] if row["technique"] == "Vimshottari") + assert receipt["profile_gate"] == "blocked" + assert dasha["status"] == "blocked" + assert dasha["person_b"]["status"] == "blocked" + + +def test_full_synastry_receipt_keeps_observation_only_technique_out_of_completed_rows(): + receipt = module.build_execution_receipt( + _packet(parameter_sensitive="punarphoo"), + _packet(parameter_sensitive="punarphoo"), + ) + punarphoo = next(row for row in receipt["individual_technique_audit"] if row["technique"] == "Punarphoo") + assert punarphoo["status"] == "parameter_sensitive" + assert punarphoo["person_a"]["status"] == "parameter_sensitive" + + +def test_synastry_surfaces_lead_with_evidence_not_a_match_score(): + main_js = (ROOT / "jyotish-app" / "main.js").read_text(encoding="utf-8") + export_js = (ROOT / "jyotish-app" / "export.js").read_text(encoding="utf-8") + + assert "