add auditable western timing evidence
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,7 @@ ALLOWED_STATUS = {
|
||||
"covered",
|
||||
"complete",
|
||||
"partial",
|
||||
"blocked",
|
||||
"knowledge-only",
|
||||
"workflow-only",
|
||||
"not-integrated",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user