From ce2bc79e6e97a8df513d50c6d26b872d8cac9e1e Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Wed, 15 Jul 2026 00:19:30 +0800 Subject: [PATCH] add auditable western timing evidence --- docs/research/pre_work_error_ledger.md | 2 + .../oracle/western_oracle_adapter_contract.md | 21 ++- scripts/audit_capabilities.py | 1 + scripts/jyotish_api_server.py | 27 +++- scripts/skill_release_package.py | 2 + scripts/western_timing_engine.py | 128 ++++++++++++++++++ tests/test_audit_capabilities.py | 25 ++++ tests/test_western_chart_engine.py | 19 +++ tests/test_western_timing_engine.py | 39 ++++++ 9 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 scripts/western_timing_engine.py create mode 100644 tests/test_audit_capabilities.py create mode 100644 tests/test_western_timing_engine.py diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index f7946fdd..b21643f7 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -96,6 +96,8 @@ For large architecture or release work, also read: | 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. | +| ERR-066 | The pre-work fragment sweep invoked a retired `audit-capabilities --mode strict` contract, so governance tests and the mandatory preflight failed before real checks ran. | mitigated 2026-07-14 | Invoke the supported `--mode validate`; `tests/test_preflight_fragment_scan.py` and `pre_work_check.py` must remain green before substantive work. | +| ERR-067 | A generic “Western timing” label can imply secondary progressions, solar arcs, and returns that have not been computed. | mitigated 2026-07-14 | Native timing accepts explicit `transit_date` and `solar_return_year` only; packet boundaries and premium prompts name every unsupported technique. | ## Fragment Sweep Command Set diff --git a/references/oracle/western_oracle_adapter_contract.md b/references/oracle/western_oracle_adapter_contract.md index 70448daa..e533deff 100644 --- a/references/oracle/western_oracle_adapter_contract.md +++ b/references/oracle/western_oracle_adapter_contract.md @@ -18,12 +18,31 @@ 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 +secondary progressions, solar arcs, non-solar 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. +### Optional Timing Input + +To add only requested native time evidence, pass: + +```json +{ + "western_timing": { + "transit_date": "2026-07-09", + "solar_return_year": 2026 + } +} +``` + +`transit_date` produces a local-date transit-to-natal major-aspect snapshot. +`solar_return_year` locates the exact tropical solar return and calculates its +return chart at the supplied birthplace/location. Both are calculation data, +not event verdicts. Secondary progressions and solar arcs remain unavailable +until separately implemented and tested. + ## Accepted Input ```json diff --git a/scripts/audit_capabilities.py b/scripts/audit_capabilities.py index dca22b0a..cfcf5a40 100644 --- a/scripts/audit_capabilities.py +++ b/scripts/audit_capabilities.py @@ -23,6 +23,7 @@ ALLOWED_STATUS = { "covered", "complete", "partial", + "blocked", "knowledge-only", "workflow-only", "not-integrated", diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index e7063430..34dc7e14 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -52,9 +52,11 @@ except ModuleNotFoundError: # pragma: no cover - script execution path try: from scripts.western_oracle_adapter import build_packet_from_oracle_payload from scripts.western_chart_engine import build_tropical_western_evidence_packet + from scripts.western_timing_engine import build_timing_techniques 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 + from western_timing_engine import build_timing_techniques SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) REPO_ROOT = os.path.abspath(os.path.join(SCRIPTS_DIR, '..')) @@ -179,7 +181,7 @@ def _western_evidence_packet_from_body( if automatic in {False, 'off', 'external_only'} or body.get('entry_mode') == 'prashna' or not isinstance(birth_payload, dict): return None try: - return build_tropical_western_evidence_packet( + packet = 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)), @@ -187,6 +189,29 @@ def _western_evidence_packet_from_body( timezone=body.get('western_timezone') or birth_payload['tz'], house_system=str(body.get('western_house_system', 'P')), ) + timing_request = body.get('western_timing') + if isinstance(timing_request, dict): + birth = { + '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')), + } + timing = build_timing_techniques( + **birth, + transit_date=timing_request.get('transit_date'), + solar_return_year=timing_request.get('solar_return_year'), + ) + if timing: + packet['timing_techniques'] = timing + packet['sections']['timing_techniques'] = {'status': 'used', 'source_path': 'western.native_timing'} + packet['missing_sections'] = [item for item in packet['missing_sections'] if item != 'timing_techniques'] + packet['boundary'] = ( + 'Native calculations include only requested transit snapshots and/or exact solar-return charts; ' + 'they do not infer duration, outcomes, progressions, solar arcs, returns beyond solar, or interpretation.' + ) + return packet except Exception as exc: # pragma: no cover - defensive boundary return { 'system': 'western_astrology', diff --git a/scripts/skill_release_package.py b/scripts/skill_release_package.py index 66b57fe8..77821898 100644 --- a/scripts/skill_release_package.py +++ b/scripts/skill_release_package.py @@ -48,6 +48,7 @@ Do not add private birth data, API keys, or desktop oracle screenshots to this p 如果没有 VedAstro official_raw_response,请标记 official_blocked 或 local_fallback。 如果我提供西方占星导出,请作为 western_oracle_payload 进入统一主链,不要把单边西占信号说成双系统互证。 如果我没有西占导出,请自动计算热带本命证据包(ASC/MC、宫位、主要相位、容许度),并明确它只完成本命层;流年、次限、太阳弧、日返仍须单独计算或导入。 +如需西占时间技术,请传 western_timing:`{"transit_date":"YYYY-MM-DD","solar_return_year":YYYY}`;当前支持指定日 transit 与精确太阳回归,不把未计算的次限/太阳弧标成已用。 ## Highest Quality Mode @@ -112,6 +113,7 @@ REQUIRED_CONTRACTS = [ "scripts/user_invocation_acceptance_check.py", "scripts/diagnose_external_engine_adapters.py", "scripts/western_chart_engine.py", + "scripts/western_timing_engine.py", ] diff --git a/scripts/western_timing_engine.py b/scripts/western_timing_engine.py new file mode 100644 index 00000000..0b6257e4 --- /dev/null +++ b/scripts/western_timing_engine.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Auditable tropical transit and solar-return evidence calculations.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +import swisseph as swe + +try: + from western_chart_engine import _ASPECTS, _PLANETS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart +except ImportError: # pragma: no cover - package import path + from scripts.western_chart_engine import _ASPECTS, _PLANETS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart + + +def _target_jd(target_date: str, timezone: str | float | int) -> tuple[float, datetime]: + zone, _ = _birth_zone(timezone) + local = datetime.fromisoformat(target_date).replace(tzinfo=zone) + utc = local.astimezone(ZoneInfo("UTC")) + jd = swe.julday(utc.year, utc.month, utc.day, utc.hour + utc.minute / 60 + utc.second / 3600) + return jd, local + + +def _cross_aspects(transits: dict[str, dict[str, Any]], natal: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + matches: list[dict[str, Any]] = [] + for transit_name, transit in transits.items(): + for natal_name, point in natal.items(): + separation = abs(transit["longitude"] - point["longitude"]) + separation = min(separation, 360.0 - separation) + allowed_orb = _orb_for(transit_name, natal_name) + for aspect, exact in _ASPECTS.items(): + orb = abs(separation - exact) + if orb <= allowed_orb: + matches.append({ + "transit_planet": transit_name, + "natal_point": natal_name, + "aspect": aspect, + "exact_degrees": exact, + "separation": round(separation, 6), + "orb": round(orb, 6), + "allowed_orb": allowed_orb, + }) + return sorted(matches, key=lambda row: (row["orb"], row["transit_planet"], row["natal_point"])) + + +def calculate_transit_to_natal(*, target_date: str, **birth: Any) -> dict[str, Any]: + """Calculate major tropical transits to natal planets and ASC/MC on a local date.""" + natal_chart = build_tropical_natal_chart(**birth) + jd, local = _target_jd(target_date, birth["timezone"]) + flags = swe.FLG_SWIEPH | swe.FLG_SPEED + planets: dict[str, dict[str, Any]] = {} + for name, planet_id in _PLANETS.items(): + values, _ = swe.calc_ut(jd, planet_id, flags) + planets[name] = _point(values[0], speed=values[3]) + natal_points = { + **natal_chart["natal"]["planets"], + "ascendant": natal_chart["natal"]["angles"]["ascendant"], + "mc": natal_chart["natal"]["angles"]["mc"], + } + return { + "technique": "transits", + "status": "used", + "target_date": target_date, + "target_local_time": local.isoformat(), + "zodiac": "tropical", + "transit_planets": planets, + "aspects": _cross_aspects(planets, natal_points), + "orb_policy": "major aspects 0/60/90/120/180; min(per-point configured orb)", + "boundary": "A dated transit snapshot only; no duration, outcome, or interpretation is inferred.", + } + + +def _jd_to_local(jd_ut: float, timezone: str | float | int) -> datetime: + zone, _ = _birth_zone(timezone) + year, month, day, hour_float = swe.revjul(jd_ut, swe.GREG_CAL) + utc = datetime(year, month, day, tzinfo=ZoneInfo("UTC")) + timedelta(hours=hour_float) + return utc.astimezone(zone) + + +def calculate_solar_return(*, target_year: int, **birth: Any) -> dict[str, Any]: + """Find the exact tropical solar return and calculate its local return chart.""" + natal_chart = build_tropical_natal_chart(**birth) + natal_sun = natal_chart["natal"]["planets"]["sun"]["longitude"] + start_jd = swe.julday(int(target_year), 1, 1, 0.0) + return_jd = swe.solcross_ut(natal_sun, start_jd, swe.FLG_SWIEPH) + return_local = _jd_to_local(return_jd, birth["timezone"]) + return_birth = { + **birth, + "year": return_local.year, + "month": return_local.month, + "day": return_local.day, + "hour": return_local.hour, + "minute": return_local.minute, + "second": return_local.second, + } + return_chart = build_tropical_natal_chart(**return_birth) + returned_sun = return_chart["natal"]["planets"]["sun"]["longitude"] + delta = abs(_longitude(returned_sun - natal_sun)) + delta = min(delta, 360.0 - delta) + return { + "technique": "solar_return", + "status": "used", + "target_year": int(target_year), + "return_julian_day_ut": round(return_jd, 8), + "return_local_time": return_local.isoformat(), + "natal_sun_longitude": natal_sun, + "return_sun_longitude": returned_sun, + "sun_longitude_delta": round(delta, 8), + "return_chart": return_chart, + "boundary": "Exact solar return time and chart only; annual topics require separate audited interpretation.", + } + + +def build_timing_techniques( + *, + transit_date: str | None = None, + solar_return_year: int | None = None, + **birth: Any, +) -> dict[str, Any]: + """Materialize only the requested, independently auditable timing layers.""" + techniques: dict[str, Any] = {} + if transit_date: + techniques["transits"] = calculate_transit_to_natal(target_date=transit_date, **birth) + if solar_return_year is not None: + techniques["solar_return"] = calculate_solar_return(target_year=int(solar_return_year), **birth) + return techniques diff --git a/tests/test_audit_capabilities.py b/tests/test_audit_capabilities.py new file mode 100644 index 00000000..c9ee37c2 --- /dev/null +++ b/tests/test_audit_capabilities.py @@ -0,0 +1,25 @@ +"""Registry validator regression tests.""" + +from __future__ import annotations + +from scripts.audit_capabilities import validate_registry + + +def test_blocked_is_a_valid_honest_technique_status() -> None: + report = validate_registry({ + "techniques": { + "external_oracle": { + "name": "External oracle", + "domains": ["validation"], + "status": "blocked", + "knowledge_refs": [], + "commands": [], + "output_paths": [], + "audit_label": "External oracle", + "missing_impact": "Cannot claim external parity.", + } + }, + "routes": {}, + }) + + assert report["valid"] is True diff --git a/tests/test_western_chart_engine.py b/tests/test_western_chart_engine.py index 93cc2175..f09996ed 100644 --- a/tests/test_western_chart_engine.py +++ b/tests/test_western_chart_engine.py @@ -76,6 +76,25 @@ def test_workflow_does_not_auto_attach_natal_western_data_to_prashna() -> None: assert packet is None +def test_workflow_adds_only_explicit_western_timing_layers() -> None: + packet = _western_evidence_packet_from_body( + { + "entry_mode": "direct_chart", + "western_timing": {"transit_date": "2026-07-09", "solar_return_year": 2026}, + }, + {"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 set(packet["timing_techniques"]) == {"transits", "solar_return"} + assert packet["sections"]["timing_techniques"]["status"] == "used" + + 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") + assert "scripts/western_timing_engine.py" in _edition_files("basic_git") + assert "scripts/western_timing_engine.py" in _edition_files("premium_cloud_drive") diff --git a/tests/test_western_timing_engine.py b/tests/test_western_timing_engine.py new file mode 100644 index 00000000..e97517f4 --- /dev/null +++ b/tests/test_western_timing_engine.py @@ -0,0 +1,39 @@ +"""Regression tests for native Western timing calculations.""" + +from __future__ import annotations + +from scripts.western_timing_engine import ( + build_timing_techniques, + calculate_solar_return, + calculate_transit_to_natal, +) + + +_BIRTH = { + "year": 1993, "month": 4, "day": 17, "hour": 14, "minute": 49, + "latitude": 36.683333, "longitude": 114.35, "timezone": "Asia/Shanghai", +} + + +def test_transit_to_natal_emits_orb_auditable_aspects() -> None: + transit = calculate_transit_to_natal(**_BIRTH, target_date="2026-07-09") + + assert transit["technique"] == "transits" + assert transit["target_date"] == "2026-07-09" + assert transit["aspects"] + assert all(row["orb"] <= row["allowed_orb"] for row in transit["aspects"]) + + +def test_solar_return_calculates_return_moment_and_chart() -> None: + solar_return = calculate_solar_return(**_BIRTH, target_year=2026) + + assert solar_return["technique"] == "solar_return" + assert solar_return["target_year"] == 2026 + assert solar_return["return_chart"]["natal"]["planets"]["sun"]["sign"] == "Aries" + assert solar_return["sun_longitude_delta"] < 0.001 + + +def test_timing_builder_only_contains_requested_techniques() -> None: + timing = build_timing_techniques(**_BIRTH, transit_date="2026-07-09", solar_return_year=2026) + + assert set(timing) == {"transits", "solar_return"}