diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index c1b2c1d9..f7946fdd 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -94,6 +94,8 @@ For large architecture or release work, also read: | ERR-061 | Full-reading passed longitude-only data to the Tajika layer, permanently blocking its speed-dependent seven-planet interaction evidence. | mitigated 2026-07-14 | Pass actual Swiss longitude/speed pairs. The output may expose only partial Ithasala/Easarapha candidates; named chains and event verdicts stay blocked pending golden cases. | | ERR-062 | A release gate could validate a parity manifest's shape without requiring all external engines to actually match, allowing “contract valid” to be mistaken for “oracle verified.” | mitigated 2026-07-14 | `three_engine_parity_replay_validator.py --require-pass` fails unless parity status is pass; `run_quality_gate.py --require-external-parity` exposes this as an explicit high-standard release requirement. | | ERR-063 | High-rigor API output could omit the three-engine parity state, especially on plan-only responses, allowing downstream UI or MCP callers to overstate verification. | mitigated 2026-07-14 | Every high-rigor execution and plan response carries `high_rigor_external_parity`; `require_external_parity=true` sets `success=false` unless parity is pass. | +| ERR-064 | Western cross-system support accepted external JSON but did not calculate a tropical natal chart from standard birth input, inviting agents to treat missing Western data as a completed cross-check. | mitigated 2026-07-14 | `western_chart_engine.py` computes Swiss Ephemeris tropical natal evidence for direct-chart/rectification only. It remains `partial` until separately materialized timing evidence exists; Prashna and external-payload precedence are regression-tested. | +| ERR-065 | A newly added release-critical script can be absent from a zip before its first Git commit because the package enumerates tracked files only. | mitigated 2026-07-14 | List `scripts/western_chart_engine.py` in `REQUIRED_CONTRACTS`; the release-package test proves both editions include it before commit. | ## Fragment Sweep Command Set diff --git a/references/oracle/western_oracle_adapter_contract.md b/references/oracle/western_oracle_adapter_contract.md index 9469c386..70448daa 100644 --- a/references/oracle/western_oracle_adapter_contract.md +++ b/references/oracle/western_oracle_adapter_contract.md @@ -8,6 +8,22 @@ This contract defines how external Western astrology outputs enter the high-rigo It is an evidence adapter, not a bundled Western astrology engine. +## Native Tropical Natal Layer + +`scripts/western_chart_engine.py` uses the existing Swiss Ephemeris binding to +calculate a tropical natal chart from birth data. For `direct_chart` and +`rectification`, the unified workflow defaults to `western_mode: "auto"` when +no external payload is supplied. It records planetary longitude/speed, +Placidus houses, ASC/MC/DC/IC, major aspects with explicit orb limits, +element/mode distribution, and traditional house-ruler chains. + +The native result is deliberately `partial`: it does **not** calculate +transits, secondary progressions, solar arcs, returns, synastry, or +interpretive signals. `prashna` does not receive a natal Western packet by +default. Set `western_mode` to `external_only` or `off` to suppress automatic +calculation. Explicit `western_evidence_packet` and `western_oracle_payload` +always take precedence. + ## Accepted Input ```json @@ -53,6 +69,7 @@ API and MCP callers may pass either field: - `western_oracle_payload`: raw external Western astrology JSON export; the project normalizes it with `western_oracle_adapter`. - `western_evidence_packet`: pre-normalized packet; the project carries it directly into `runtime_evidence_log`. +- `western_mode`: `auto` (default for non-Prashna birth-chart entries), `external_only`, or `off`. When Jyotish evidence also carries matching `cross_system_signals`, `Cross-System Arbitration` can become `used`. If only Western evidence is present, arbitration stays `partial` or `blocked`. diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 3abab4dc..e7063430 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -51,8 +51,10 @@ except ModuleNotFoundError: # pragma: no cover - script execution path from candidate_time_sensitivity_scan import scan_candidate_times try: from scripts.western_oracle_adapter import build_packet_from_oracle_payload + from scripts.western_chart_engine import build_tropical_western_evidence_packet except ModuleNotFoundError: # pragma: no cover - script execution path from western_oracle_adapter import build_packet_from_oracle_payload + from western_chart_engine import build_tropical_western_evidence_packet SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) REPO_ROOT = os.path.abspath(os.path.join(SCRIPTS_DIR, '..')) @@ -150,24 +152,50 @@ def _submit_background_job(callback): return future -def _western_evidence_packet_from_body(body: dict, route_packet: dict) -> dict | None: +def _western_evidence_packet_from_body( + body: dict, + route_packet: dict, + *, + birth_payload: dict | None = None, +) -> dict | None: explicit_packet = body.get('western_evidence_packet') if isinstance(explicit_packet, dict): return explicit_packet oracle_payload = body.get('western_oracle_payload') or body.get('western_astrology_oracle') - if not isinstance(oracle_payload, dict): + if isinstance(oracle_payload, dict): + try: + return build_packet_from_oracle_payload(oracle_payload, route_packet=route_packet) + except Exception as exc: # pragma: no cover - defensive contract boundary + return { + 'system': 'western_astrology', + 'status': 'blocked', + 'route': dict(route_packet), + 'signals': [], + 'missing_sections': ['western_oracle_payload'], + 'adapter_error': exc.__class__.__name__, + 'boundary': 'Western oracle payload was supplied but could not be normalized.', + } + automatic = body.get('western_mode', body.get('western_auto_compute', 'auto')) + if automatic in {False, 'off', 'external_only'} or body.get('entry_mode') == 'prashna' or not isinstance(birth_payload, dict): return None try: - return build_packet_from_oracle_payload(oracle_payload, route_packet=route_packet) - except Exception as exc: # pragma: no cover - defensive contract boundary + return build_tropical_western_evidence_packet( + route_packet=route_packet, + year=int(birth_payload['year']), month=int(birth_payload['month']), day=int(birth_payload['day']), + hour=int(birth_payload['hour']), minute=int(birth_payload['minute']), second=int(birth_payload.get('second', 0)), + latitude=float(birth_payload['lat']), longitude=float(birth_payload['lon']), + timezone=body.get('western_timezone') or birth_payload['tz'], + house_system=str(body.get('western_house_system', 'P')), + ) + except Exception as exc: # pragma: no cover - defensive boundary return { 'system': 'western_astrology', 'status': 'blocked', 'route': dict(route_packet), 'signals': [], - 'missing_sections': ['western_oracle_payload'], + 'missing_sections': ['native_tropical_calculation'], 'adapter_error': exc.__class__.__name__, - 'boundary': 'Western oracle payload was supplied but could not be normalized.', + 'boundary': 'Native Western natal calculation could not be materialized.', } @@ -192,7 +220,7 @@ def execute_consultation_workflow( Path(__file__).resolve().parents[1] / 'references/oracle/three_engine_parity_replay_manifest.json' ) route_packet = _UNIFIED_CONSULTATION_ORCHESTRATOR.resolve_route(question, themes) - western_evidence_packet = _western_evidence_packet_from_body(body, route_packet) + western_evidence_packet = _western_evidence_packet_from_body(body, route_packet, birth_payload=birth_payload) unified_contract = _UNIFIED_CONSULTATION_ORCHESTRATOR.shared_contract( entry_mode=entry_mode, question=question, diff --git a/scripts/skill_release_package.py b/scripts/skill_release_package.py index e69065fd..66b57fe8 100644 --- a/scripts/skill_release_package.py +++ b/scripts/skill_release_package.py @@ -47,6 +47,7 @@ Do not add private birth data, API keys, or desktop oracle screenshots to this p 请使用 strict_workflow,并在输出中标明 VedAstro / PyJHora-JHora / jyotishganit / Real Case Calibration 的状态。 如果没有 VedAstro official_raw_response,请标记 official_blocked 或 local_fallback。 如果我提供西方占星导出,请作为 western_oracle_payload 进入统一主链,不要把单边西占信号说成双系统互证。 +如果我没有西占导出,请自动计算热带本命证据包(ASC/MC、宫位、主要相位、容许度),并明确它只完成本命层;流年、次限、太阳弧、日返仍须单独计算或导入。 ## Highest Quality Mode @@ -110,6 +111,7 @@ REQUIRED_CONTRACTS = [ "references/oracle/western_oracle_adapter_contract.md", "scripts/user_invocation_acceptance_check.py", "scripts/diagnose_external_engine_adapters.py", + "scripts/western_chart_engine.py", ] diff --git a/scripts/western_chart_engine.py b/scripts/western_chart_engine.py new file mode 100644 index 00000000..11bbce20 --- /dev/null +++ b/scripts/western_chart_engine.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Native, auditable tropical Western natal-chart calculation. + +This module deliberately uses the project's existing Swiss Ephemeris binding +instead of bundling an AGPL Western astrology library. It is a calculation +layer only: transits, progressions, solar arcs, returns, and interpretation +remain separate evidence layers. +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timedelta, timezone as fixed_timezone +from typing import Any +from zoneinfo import ZoneInfo + +import swisseph as swe + +try: + from western_evidence_packet import build_western_evidence_packet +except ImportError: # pragma: no cover - package import path + from scripts.western_evidence_packet import build_western_evidence_packet + + +_PLANETS = { + "sun": swe.SUN, + "moon": swe.MOON, + "mercury": swe.MERCURY, + "venus": swe.VENUS, + "mars": swe.MARS, + "jupiter": swe.JUPITER, + "saturn": swe.SATURN, + "uranus": swe.URANUS, + "neptune": swe.NEPTUNE, + "pluto": swe.PLUTO, + "true_node": swe.TRUE_NODE, +} +_SIGNS = ( + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +) +_ELEMENTS = { + "Aries": "fire", "Leo": "fire", "Sagittarius": "fire", + "Taurus": "earth", "Virgo": "earth", "Capricorn": "earth", + "Gemini": "air", "Libra": "air", "Aquarius": "air", + "Cancer": "water", "Scorpio": "water", "Pisces": "water", +} +_MODES = { + "Aries": "cardinal", "Cancer": "cardinal", "Libra": "cardinal", "Capricorn": "cardinal", + "Taurus": "fixed", "Leo": "fixed", "Scorpio": "fixed", "Aquarius": "fixed", + "Gemini": "mutable", "Virgo": "mutable", "Sagittarius": "mutable", "Pisces": "mutable", +} +_RULERS = { + "Aries": "mars", "Taurus": "venus", "Gemini": "mercury", "Cancer": "moon", + "Leo": "sun", "Virgo": "mercury", "Libra": "venus", "Scorpio": "mars", + "Sagittarius": "jupiter", "Capricorn": "saturn", "Aquarius": "saturn", "Pisces": "jupiter", +} +_ASPECTS = {"conjunction": 0.0, "sextile": 60.0, "square": 90.0, "trine": 120.0, "opposition": 180.0} +_ORB = {"sun": 8.0, "moon": 8.0, "ascendant": 5.0, "mc": 5.0} + + +def _longitude(value: float) -> float: + return float(value) % 360.0 + + +def _point(longitude: float, *, house: int | None = None, speed: float | None = None) -> dict[str, Any]: + longitude = _longitude(longitude) + point = { + "longitude": round(longitude, 6), + "sign": _SIGNS[int(longitude // 30)], + "degree_in_sign": round(longitude % 30, 6), + } + if house is not None: + point["house"] = house + if speed is not None: + point["speed_longitude"] = round(float(speed), 8) + point["retrograde"] = bool(speed < 0) + return point + + +def _house_for_longitude(longitude: float, cusps: list[float]) -> int: + """Return Placidus house by testing each cusp-to-next-cusp circular arc.""" + longitude = _longitude(longitude) + for index, cusp in enumerate(cusps): + start = _longitude(cusp) + end = _longitude(cusps[(index + 1) % 12]) + span = (end - start) % 360.0 + if (longitude - start) % 360.0 < span: + return index + 1 + raise RuntimeError("Unable to assign longitude to a house") # pragma: no cover + + +def _orb_for(left: str, right: str) -> float: + return min(_ORB.get(left, 6.0), _ORB.get(right, 6.0)) + + +def _aspects(points: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + names = list(points) + found: list[dict[str, Any]] = [] + for index, left in enumerate(names): + for right in names[index + 1:]: + separation = abs(points[left]["longitude"] - points[right]["longitude"]) + separation = min(separation, 360.0 - separation) + allowed_orb = _orb_for(left, right) + for aspect, exact in _ASPECTS.items(): + orb = abs(separation - exact) + if orb <= allowed_orb: + found.append({ + "left": left, + "right": right, + "aspect": aspect, + "exact_degrees": exact, + "separation": round(separation, 6), + "orb": round(orb, 6), + "allowed_orb": allowed_orb, + }) + return sorted(found, key=lambda row: (row["orb"], row["left"], row["right"])) + + +def _distribution(planets: dict[str, dict[str, Any]]) -> dict[str, dict[str, int]]: + elements = {name: 0 for name in ("fire", "earth", "air", "water")} + modes = {name: 0 for name in ("cardinal", "fixed", "mutable")} + for planet in planets.values(): + elements[_ELEMENTS[planet["sign"]]] += 1 + modes[_MODES[planet["sign"]]] += 1 + return {"elements": elements, "modes": modes} + + +def _ruler_chains(cusps: list[float], planets: dict[str, dict[str, Any]]) -> dict[str, list[str]]: + chains: dict[str, list[str]] = {} + for house, cusp in enumerate(cusps, start=1): + sign = _SIGNS[int(_longitude(cusp) // 30)] + chain: list[str] = [] + current = _RULERS[sign] + for _ in range(12): + if current in chain: + break + chain.append(current) + current = _RULERS[planets[current]["sign"]] + chains[str(house)] = chain + return chains + + +def _birth_zone(value: str | float | int): + if isinstance(value, str): + return ZoneInfo(value), value + offset = float(value) + return fixed_timezone(timedelta(hours=offset)), f"UTC{offset:+g}" + + +def build_tropical_natal_chart( + *, + year: int, + month: int, + day: int, + hour: int, + minute: int, + latitude: float, + longitude: float, + timezone: str | float | int, + second: int = 0, + house_system: str = "P", +) -> dict[str, Any]: + """Calculate a tropical natal chart from local birth data using Swiss Ephemeris.""" + if len(house_system) != 1: + raise ValueError("house_system must be a single Swiss Ephemeris house-system letter") + zone, timezone_label = _birth_zone(timezone) + local = datetime(year, month, day, hour, minute, second, tzinfo=zone) + utc = local.astimezone(ZoneInfo("UTC")) + jd_ut = swe.julday(utc.year, utc.month, utc.day, utc.hour + utc.minute / 60 + utc.second / 3600) + flags = swe.FLG_SWIEPH | swe.FLG_SPEED + cusps_raw, ascmc = swe.houses_ex(jd_ut, float(latitude), float(longitude), house_system.encode("ascii"), 0) + cusps = [_longitude(cusp) for cusp in cusps_raw] + planets: dict[str, dict[str, Any]] = {} + for name, planet_id in _PLANETS.items(): + values, _ = swe.calc_ut(jd_ut, planet_id, flags) + position = _point(values[0], house=_house_for_longitude(values[0], cusps), speed=values[3]) + planets[name] = position + angles = { + "ascendant": _point(ascmc[0]), + "mc": _point(ascmc[1]), + "descendant": _point(ascmc[0] + 180.0), + "ic": _point(ascmc[1] + 180.0), + } + aspect_points = {**planets, "ascendant": angles["ascendant"], "mc": angles["mc"]} + natal = { + "ascendant": angles["ascendant"], + "mc": angles["mc"], + "angles": angles, + "planets": planets, + "houses": [{"house": index + 1, "cusp": _point(cusp)} for index, cusp in enumerate(cusps)], + "aspects": _aspects(aspect_points), + "distribution": _distribution(planets), + "house_ruler_chains": _ruler_chains(cusps, planets), + } + return { + "source_engine": "pyswisseph_tropical", + "engine_version": getattr(swe, "version", "unknown"), + "zodiac": "tropical", + "house_system": house_system.upper(), + "calculation_contract": { + "birth_timezone": timezone_label, + "local_birth_time": local.isoformat(), + "utc_birth_time": utc.isoformat(), + "julian_day_ut": round(jd_ut, 8), + "latitude": float(latitude), + "longitude": float(longitude), + "ephemeris": "Swiss Ephemeris via pyswisseph", + }, + "natal": natal, + "boundary": "Natal tropical calculation only; it does not calculate transits, progressions, solar arcs, returns, or interpretation.", + } + + +def build_tropical_western_evidence_packet(*, route_packet: dict[str, Any], **birth: Any) -> dict[str, Any]: + """Wrap direct natal calculation in the existing cross-system packet contract.""" + chart = build_tropical_natal_chart(**birth) + packet = build_western_evidence_packet( + route_packet=route_packet, + natal=chart["natal"], + timing_techniques={}, + signals=[], + ) + packet.update({ + "source_engine": chart["source_engine"], + "calculation": { + "status": "used", + "source_engine": chart["source_engine"], + "zodiac": chart["zodiac"], + "house_system": chart["house_system"], + "contract": chart["calculation_contract"], + }, + "native_chart": chart, + "boundary": chart["boundary"], + }) + return packet + + +def main() -> int: + parser = argparse.ArgumentParser(description="Calculate an auditable tropical Western natal chart.") + for name, kind in (("year", int), ("month", int), ("day", int), ("hour", int), ("minute", int)): + parser.add_argument(f"--{name}", required=True, type=kind) + parser.add_argument("--lat", required=True, type=float) + parser.add_argument("--lon", required=True, type=float) + parser.add_argument("--timezone", required=True) + parser.add_argument("--second", type=int, default=0) + parser.add_argument("--house-system", default="P") + args = parser.parse_args() + print(json.dumps(build_tropical_natal_chart( + year=args.year, month=args.month, day=args.day, hour=args.hour, minute=args.minute, second=args.second, + latitude=args.lat, longitude=args.lon, timezone=args.timezone, house_system=args.house_system, + ), ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/tests/test_western_chart_engine.py b/tests/test_western_chart_engine.py new file mode 100644 index 00000000..93cc2175 --- /dev/null +++ b/tests/test_western_chart_engine.py @@ -0,0 +1,81 @@ +"""Regression tests for the native tropical Western chart calculator.""" + +from __future__ import annotations + +from scripts.western_chart_engine import ( + build_tropical_natal_chart, + build_tropical_western_evidence_packet, +) +from scripts.jyotish_api_server import _western_evidence_packet_from_body +from scripts.skill_release_package import _edition_files + + +_BIRTH = { + "year": 1993, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "latitude": 36.683333, + "longitude": 114.35, + "timezone": "Asia/Shanghai", +} + + +def test_native_engine_calculates_auditable_tropical_natal_chart() -> None: + chart = build_tropical_natal_chart(**_BIRTH) + + assert chart["source_engine"] == "pyswisseph_tropical" + assert chart["zodiac"] == "tropical" + assert chart["house_system"] == "P" + assert chart["natal"]["planets"]["sun"]["sign"] == "Aries" + assert 26 < chart["natal"]["planets"]["sun"]["longitude"] < 28 + assert set(chart["natal"]["angles"]) == {"ascendant", "mc", "descendant", "ic"} + assert len(chart["natal"]["houses"]) == 12 + assert all(1 <= planet["house"] <= 12 for planet in chart["natal"]["planets"].values()) + assert chart["natal"]["aspects"] + assert all(aspect["orb"] <= aspect["allowed_orb"] for aspect in chart["natal"]["aspects"]) + + +def test_native_engine_marks_timing_and_interpretation_boundaries() -> None: + packet = build_tropical_western_evidence_packet(**_BIRTH, route_packet={"primary_theme": "career"}) + + assert packet["status"] == "partial" + assert packet["calculation"]["status"] == "used" + assert packet["calculation"]["source_engine"] == "pyswisseph_tropical" + assert "timing_techniques" in packet["missing_sections"] + assert "signals" in packet["missing_sections"] + assert "does not calculate transits" in packet["boundary"] + + +def test_workflow_auto_materializes_native_western_natal_without_external_json() -> None: + packet = _western_evidence_packet_from_body( + {"entry_mode": "direct_chart", "western_mode": "auto"}, + {"primary_theme": "career"}, + birth_payload={ + "year": 1993, "month": 4, "day": 17, "hour": 14, "minute": 49, + "second": 0, "lat": 36.683333, "lon": 114.35, "tz": 8, + }, + ) + + assert packet is not None + assert packet["source_engine"] == "pyswisseph_tropical" + assert packet["status"] == "partial" + + +def test_workflow_does_not_auto_attach_natal_western_data_to_prashna() -> None: + packet = _western_evidence_packet_from_body( + {"entry_mode": "prashna", "western_mode": "auto"}, + {"primary_theme": "career"}, + birth_payload={ + "year": 1993, "month": 4, "day": 17, "hour": 14, "minute": 49, + "second": 0, "lat": 36.683333, "lon": 114.35, "tz": 8, + }, + ) + + assert packet is None + + +def test_release_editions_include_native_western_calculator() -> None: + assert "scripts/western_chart_engine.py" in _edition_files("basic_git") + assert "scripts/western_chart_engine.py" in _edition_files("premium_cloud_drive")