diff --git a/references/cross_project_contract/fixture_manifest.v1.json b/references/cross_project_contract/fixture_manifest.v1.json new file mode 100644 index 00000000..dd399d9d --- /dev/null +++ b/references/cross_project_contract/fixture_manifest.v1.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "privacy_scope": "public_synthetic_only", + "fixtures": [ + { + "id": "public_synthetic_delhi_1990_noon_mean_lahiri", + "birth": { + "synthetic": true, + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 28.6139, + "lon": 77.209, + "tz": 5.5 + }, + "effective": { + "ayanamsa": "lahiri", + "node_mode": "mean", + "timezone_offset": 5.5 + }, + "compatibility_hash": "257042461fe303aa3bff5a8333f65090832ce8a2be395f038523838d724b474b" + } + ] +} diff --git a/references/cross_project_contract/sync_ledger.json b/references/cross_project_contract/sync_ledger.json new file mode 100644 index 00000000..0cb7d103 --- /dev/null +++ b/references/cross_project_contract/sync_ledger.json @@ -0,0 +1,130 @@ +{ + "schema_version": 1, + "entries": [ + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "f4d8148fc031cfa581bce7f30410fcd63fc89202", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "fd06bdc2eefa5547ed09968a218a8590df484bf3", + "change_class": "calculation_contract", + "copied_files": [ + "references/cross_project_contract/fixture_manifest.v1.json", + "references/cross_project_contract/sync_ledger.json", + "scripts/cross_project_contract.py", + "tests/test_cross_project_contract.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: public synthetic fixture only; no production configuration or user data", + "focused_tests": [ + "python3 -m pytest -q tests/test_cross_project_contract.py", + "python3 scripts/cross_project_contract.py --require-match --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: 257042461fe303aa3bff5a8333f65090832ce8a2be395f038523838d724b474b", + "rollback": "git revert f4d8148 / git revert fd06bdc" + }, + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "25070634e26162a2ed7a5734aa783a4e42b26546", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "b6d292a7cd1a5c50d4b920caeec11b462248c21d", + "change_class": "sync_governance", + "copied_files": [ + "references/cross_project_contract/sync_policy.v1.json", + "scripts/cross_project_sync_status.py", + "tests/test_cross_project_sync_status.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: public sync policy and file hashes only; no production configuration or user data", + "focused_tests": [ + "python3 -m pytest -q tests/test_cross_project_contract.py tests/test_cross_project_sync_status.py", + "python3 scripts/cross_project_sync_status.py --peer /tmp/Jyotisha-jesse-ux --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: shared file sha256 comparison returned status=pass", + "rollback": "git revert 2507063 / git revert b6d292a" + }, + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "b206cc297022a82f90881bdbcc09b1fcb7b65a08", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "61655fe060577011d91b74ad1fc671bace1bd689", + "change_class": "calculation_contract", + "copied_files": [ + "scripts/domain_calculation_service.py", + "references/cross_project_contract/sync_policy.v1.json", + "tests/test_commercial_domain_calculation_contract.py", + "scripts/jyotish_api_server.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: no secrets or user data; VedAstro key not written to repository", + "focused_tests": [ + "python3 -m pytest -q tests/test_commercial_domain_calculation_contract.py tests/test_cross_project_contract.py tests/test_cross_project_sync_status.py", + "python3 scripts/cross_project_contract.py --require-match --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: commercial API result_hash matches domain_calculation_service.compute_chart", + "rollback": "git revert b206cc2 / git revert 61655fe" + }, + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "d1d09b9432f383963d10f6604a3bb0ffe2951eb9", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "73e0ff724d70a234a3fc02b207468377bf709997", + "change_class": "calculation_contract", + "copied_files": [ + "scripts/jyotish_api_server.py", + "tests/test_commercial_domain_calculation_contract.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: no secrets or user data; visible chart values now follow domain service", + "focused_tests": [ + "python3 -m pytest -q tests/test_commercial_domain_calculation_contract.py tests/test_cross_project_contract.py tests/test_cross_project_sync_status.py", + "python3 scripts/cross_project_contract.py --require-match --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: API Rahu/Ketu true-node visible longitudes match domain_calculation_service", + "rollback": "git revert 73e0ff7" + }, + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "9d5e909d7b5af2d034b87f617d124ec6853e53f7", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "09387d82ffe9aa59d81a2af6c66ea34d47850ed9", + "change_class": "calculation_contract", + "copied_files": [ + "scripts/jyotish_api_server.py", + "tests/test_commercial_domain_calculation_contract.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: no secrets or user data; Sade Sati now follows domain true Saturn transit", + "focused_tests": [ + "python3 -m pytest -q tests/test_commercial_domain_calculation_contract.py tests/test_cross_project_contract.py tests/test_cross_project_sync_status.py", + "python3 scripts/cross_project_contract.py --require-match --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: API Sade Sati transit Saturn provenance matches domain_calculation_service", + "rollback": "git revert 09387d8" + }, + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "5070952ecf57568092a82b97a2142e21ebeef4e7", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "e8a8ff62b0fa86f9e24be5a0ac2604afa575dd8c", + "change_class": "calculation_contract", + "copied_files": [ + "scripts/jyotish_api_server.py", + "tests/test_commercial_domain_calculation_contract.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: no secrets or user data; dasha boundary now follows domain service", + "focused_tests": [ + "python3 -m pytest -q tests/test_commercial_domain_calculation_contract.py tests/test_cross_project_contract.py tests/test_cross_project_sync_status.py", + "python3 scripts/cross_project_contract.py --require-match --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: API dasha start_date/current_md/remaining_years/result_hash match domain_calculation_service", + "rollback": "git revert e8a8ff6" + } + ] +} diff --git a/references/cross_project_contract/sync_policy.v1.json b/references/cross_project_contract/sync_policy.v1.json new file mode 100644 index 00000000..0f5f468b --- /dev/null +++ b/references/cross_project_contract/sync_policy.v1.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "sync_model": "research_validates_commercial_receives_mature", + "repositories": { + "research": "732642856/yinduzhanxing", + "commercial": "jesse-ux/Jyotisha" + }, + "directional_gates": { + "research_to_commercial": { + "source_required": "validated_in_research", + "target_required": "commercial_safe", + "required_checks": [ + "privacy_review", + "focused_tests", + "hash_contract_result" + ] + }, + "commercial_to_research": { + "source_required": "configuration_free_product_pattern", + "target_required": "local_test_double_or_no_secret", + "required_checks": [ + "privacy_review", + "focused_tests" + ] + } + }, + "shared_files": [ + "references/cross_project_contract/fixture_manifest.v1.json", + "references/cross_project_contract/sync_ledger.json", + "references/cross_project_contract/sync_policy.v1.json", + "scripts/cross_project_contract.py", + "scripts/domain_calculation_service.py", + "scripts/cross_project_sync_status.py", + "tests/test_cross_project_contract.py", + "tests/test_cross_project_sync_status.py" + ] +} diff --git a/scripts/cross_project_contract.py b/scripts/cross_project_contract.py new file mode 100644 index 00000000..96cf3585 --- /dev/null +++ b/scripts/cross_project_contract.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Validate public synthetic calculation fixtures shared across Jyotish projects.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT / "scripts") not in sys.path: + sys.path.insert(0, str(ROOT / "scripts")) + +from jyotish_engine import compute_chart_data + + +PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") +REQUIRED_LEDGER_FIELDS = { + "source_repository", + "source_commit", + "target_repository", + "target_commit", + "change_class", + "copied_files", + "dependency_delta", + "privacy_review", + "focused_tests", + "hash_contract_result", + "rollback", +} + + +def load_manifest(path: Path) -> dict[str, Any]: + manifest = json.loads(path.read_text(encoding="utf-8")) + if manifest.get("schema_version") != 1: + raise ValueError("fixture manifest schema_version must be 1") + if manifest.get("privacy_scope") != "public_synthetic_only": + raise ValueError("fixture manifest must be public_synthetic_only") + if not isinstance(manifest.get("fixtures"), list) or not manifest["fixtures"]: + raise ValueError("fixture manifest must contain fixtures") + return manifest + + +def load_ledger(path: Path) -> dict[str, Any]: + ledger = json.loads(path.read_text(encoding="utf-8")) + if ledger.get("schema_version") != 1 or not isinstance(ledger.get("entries"), list): + raise ValueError("sync ledger must contain schema_version=1 and entries array") + return ledger + + +def validate_ledger_entry(entry: dict[str, Any]) -> list[str]: + return sorted(REQUIRED_LEDGER_FIELDS - entry.keys()) + + +def _calculate_fixture_chart(fixture: dict[str, Any]) -> dict[str, Any]: + birth = fixture["birth"] + effective = fixture["effective"] + chart, _asc_idx, _jd, _ayanamsa = compute_chart_data( + birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"], + birth["lat"], birth["lon"], birth["tz"], node_mode=effective["node_mode"], + second=birth.get("second", 0), ayanamsa_name=effective["ayanamsa"], + ) + return chart + + +def _longitude(row: dict[str, Any]) -> float: + value = row.get("lon", row.get("degree")) + if not isinstance(value, (int, float)): + raise ValueError("chart row must provide numeric lon or degree") + return float(value) + + +def compatibility_payload(chart: dict[str, Any], fixture: dict[str, Any]) -> dict[str, Any]: + return { + "fixture_id": fixture["id"], + "birth": {key: value for key, value in fixture["birth"].items() if key != "synthetic"}, + "effective": { + "ayanamsa": fixture["effective"]["ayanamsa"], + "node_mode": fixture["effective"]["node_mode"], + "timezone_offset": fixture["effective"]["timezone_offset"], + }, + "ascendant": { + "sign": chart["ascendant"]["sign"], + "lon": _longitude(chart["ascendant"]), + }, + "planets": { + planet: {"sign": chart["planets"][planet]["sign"], "lon": _longitude(chart["planets"][planet])} + for planet in PLANETS + }, + } + + +def compatibility_hash(chart: dict[str, Any], fixture: dict[str, Any]) -> str: + encoded = json.dumps( + compatibility_payload(chart, fixture), ensure_ascii=True, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def evaluate_manifest(path: Path) -> dict[str, Any]: + manifest = load_manifest(path) + fixtures = [] + for fixture in manifest["fixtures"]: + chart = _calculate_fixture_chart(fixture) + actual = compatibility_hash(chart, fixture) + expected = fixture["compatibility_hash"] + fixtures.append( + { + "id": fixture["id"], + "expected_compatibility_hash": expected, + "actual_compatibility_hash": actual, + "matches": actual == expected, + } + ) + return {"schema_version": 1, "manifest": str(path), "fixtures": fixtures, "matches": all(row["matches"] for row in fixtures)} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", + type=Path, + default=ROOT / "references" / "cross_project_contract" / "fixture_manifest.v1.json", + ) + parser.add_argument("--format", choices=("text", "json"), default="text") + parser.add_argument("--require-match", action="store_true") + args = parser.parse_args(argv) + report = evaluate_manifest(args.manifest) + if args.format == "json": + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + for row in report["fixtures"]: + print(f"{row['id']}: {'match' if row['matches'] else 'mismatch'}") + return 0 if report["matches"] or not args.require_match else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/cross_project_sync_status.py b/scripts/cross_project_sync_status.py new file mode 100644 index 00000000..3210968c --- /dev/null +++ b/scripts/cross_project_sync_status.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Compare allow-listed shared contract files between the two Jyotish projects.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_POLICY = ROOT / "references" / "cross_project_contract" / "sync_policy.v1.json" + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def load_policy(path: Path = DEFAULT_POLICY) -> dict[str, Any]: + policy = json.loads(path.read_text(encoding="utf-8")) + if policy.get("schema_version") != 1: + raise ValueError("sync policy schema_version must be 1") + if policy.get("sync_model") != "research_validates_commercial_receives_mature": + raise ValueError("sync policy must encode research-first commercial-mature flow") + if not isinstance(policy.get("shared_files"), list) or not policy["shared_files"]: + raise ValueError("sync policy must contain shared_files") + return policy + + +def compare_peer(peer_root: Path, *, policy_path: Path = DEFAULT_POLICY, root: Path = ROOT) -> dict[str, Any]: + policy = load_policy(policy_path) + missing: list[str] = [] + mismatched: list[str] = [] + checked: list[dict[str, str]] = [] + + for rel_path in policy["shared_files"]: + local = root / rel_path + peer = peer_root / rel_path + if not local.exists() or not peer.exists(): + missing.append(rel_path) + continue + local_hash = _sha256(local) + peer_hash = _sha256(peer) + checked.append({"path": rel_path, "local_sha256": local_hash, "peer_sha256": peer_hash}) + if local_hash != peer_hash: + mismatched.append(rel_path) + + return { + "status": "pass" if not missing and not mismatched else "fail", + "sync_model": policy["sync_model"], + "checked_count": len(checked), + "missing": missing, + "mismatched": mismatched, + "checked": checked, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--peer", type=Path, required=True, help="Path to the other Jyotish repository") + parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY) + parser.add_argument("--format", choices=("json",), default="json") + args = parser.parse_args() + + report = compare_peer(args.peer, policy_path=args.policy) + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/domain_calculation_service.py b/scripts/domain_calculation_service.py new file mode 100644 index 00000000..f778cf91 --- /dev/null +++ b/scripts/domain_calculation_service.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Canonical calculation service shared by CLI, REST, and MCP adapters.""" + +from __future__ import annotations + +import hashlib +import json +import math +import threading +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +import swisseph as swe +from ayanamsa_utils import apply_ayanamsa, normalize_ayanamsa_name +from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra +from jyotish_engine import SIGNS, compute_chart_data +from sade_sati import calc_sade_sati_complete + +CONTRACT_VERSION = "1.0.0" +_SWISSEPH_LOCK = threading.RLock() +_PLANET_IDS = {"Saturn": swe.SATURN} + + +class CalculationError(ValueError): + pass + + +class TimezoneInferenceError(CalculationError): + pass + + +def _canonical_hash(payload: dict[str, Any]) -> str: + encoded = json.dumps( + payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _lookup_timezone_name(lat: float, lon: float) -> str | None: + try: + from timezonefinder import TimezoneFinder + except ImportError as exc: + raise TimezoneInferenceError("timezone inference dependency unavailable") from exc + return TimezoneFinder().timezone_at(lng=lon, lat=lat) + + +def infer_timezone_offset(*, lat: float, lon: float, local_datetime: datetime) -> float: + if not (-90 <= lat <= 90 and -180 <= lon <= 180): + raise TimezoneInferenceError("timezone inference received invalid coordinates") + tz_name = _lookup_timezone_name(lat, lon) + if not tz_name: + raise TimezoneInferenceError("timezone inference returned no IANA zone") + try: + offset = local_datetime.replace(tzinfo=ZoneInfo(tz_name)).utcoffset() + except Exception as exc: + raise TimezoneInferenceError("timezone inference failed for IANA zone") from exc + if offset is None: + raise TimezoneInferenceError("timezone inference returned no UTC offset") + return offset.total_seconds() / 3600.0 + + +def _normalized_request(payload: dict[str, Any]) -> dict[str, Any]: + requested_node = str(payload.get("node_mode", payload.get("nodeMode", "mean"))).lower() + if requested_node not in {"mean", "true"}: + raise CalculationError("node_mode must be mean or true") + ayanamsa = normalize_ayanamsa_name(payload.get("ayanamsa", "lahiri")) + local_dt = datetime( + int(payload["year"]), + int(payload["month"]), + int(payload["day"]), + int(float(payload.get("hour", 0))), + int(float(payload.get("minute", 0))), + int(float(payload.get("second", 0))), + ) + lat = float(payload["lat"]) + lon = float(payload["lon"]) + tz_requested = payload.get("tz") + timezone_source = "explicit_offset" + if tz_requested in {None, ""}: + tz = infer_timezone_offset(lat=lat, lon=lon, local_datetime=local_dt) + timezone_source = "iana_inferred" + else: + tz = float(tz_requested) + if not math.isfinite(tz) or not -14 <= tz <= 14: + raise CalculationError("tz must be a finite offset between -14 and 14") + return { + "year": local_dt.year, + "month": local_dt.month, + "day": local_dt.day, + "hour": int(float(payload.get("hour", 0))), + "minute": int(float(payload.get("minute", 0))), + "second": int(float(payload.get("second", 0))), + "lat": lat, + "lon": lon, + "tz": tz, + "timezone_source": timezone_source, + "ayanamsa": ayanamsa, + "node_mode": requested_node, + } + + +def _contract(requested: dict[str, Any], effective: dict[str, Any], *, algorithm: str) -> dict[str, Any]: + return { + "contract_version": CONTRACT_VERSION, + "algorithm": algorithm, + "requested": requested, + "effective": effective, + } + + +def compute_chart(payload: dict[str, Any]) -> dict[str, Any]: + request = _normalized_request(payload) + with _SWISSEPH_LOCK: + chart, _asc_idx, _jd, _ayanamsa = compute_chart_data( + request["year"], + request["month"], + request["day"], + request["hour"], + request["minute"], + request["lat"], + request["lon"], + request["tz"], + node_mode=request["node_mode"], + second=request["second"], + ayanamsa_name=request["ayanamsa"], + ) + if not isinstance(chart, dict): + raise CalculationError("canonical chart calculation failed") + + for planet in chart.get("planets", {}).values(): + if not isinstance(planet, dict) or "error" in planet: + continue + planet.setdefault("lon", planet.get("degree_raw", planet.get("degree"))) + if planet.get("sign") in SIGNS: + planet.setdefault("sign_idx", SIGNS.index(planet["sign"])) + + birth = chart.get("birth_info", {}) + effective = { + "ayanamsa": birth.get("ayanamsa_name", request["ayanamsa"]), + "node_mode": birth.get("node_mode", request["node_mode"]), + "timezone_offset": request["tz"], + "timezone_source": request["timezone_source"], + "ephemeris_source": "swisseph_calc_ut", + "ephemeris_flags_verified": False, + } + requested = { + "ayanamsa": payload.get("ayanamsa", "lahiri"), + "node_mode": payload.get("node_mode", payload.get("nodeMode", "mean")), + "timezone_offset": payload.get("tz"), + } + contract = _contract(requested, effective, algorithm="sidereal_natal_chart") + hash_payload = { + "contract": contract, + "birth": birth, + "ascendant": chart.get("ascendant"), + "planets": chart.get("planets"), + } + chart["calculation_contract"] = contract + chart["result_hash"] = _canonical_hash(hash_payload) + return chart + + +def compute_vimshottari_timeline( + *, birth_dt: datetime, moon_lon: float, current_date: datetime | None = None +) -> dict[str, Any]: + nak_info, progress, pada = lon_to_nakshatra(float(moon_lon) % 360) + timeline, elapsed, remaining, start_lord = build_dasha_timeline( + birth_dt.strftime("%Y-%m-%d"), nak_info, progress + ) + periods = [ + { + "lord": period["lord"], + "years": period["years"], + "start": period["start"].strftime("%Y-%m-%d"), + "end": period["end"].strftime("%Y-%m-%d"), + } + for period in timeline + ] + contract = _contract( + {"moon_longitude": float(moon_lon) % 360}, + {"year_basis_days": 365.25, "nakshatra": nak_info[0], "pada": pada}, + algorithm="vimshottari_birth_balance", + ) + result = { + "periods": periods, + "birth_balance": { + "lord": start_lord, + "elapsed_years": elapsed, + "remaining_years": remaining, + }, + "calculation_contract": contract, + } + result["result_hash"] = _canonical_hash(result) + return result +def compute_transit_longitude( + *, planet: str, reference_date: str, tz: float, ayanamsa: str = "lahiri" +) -> dict[str, Any]: + if planet not in _PLANET_IDS: + raise CalculationError(f"unsupported transit planet: {planet}") + try: + local_dt = datetime.strptime(reference_date[:10], "%Y-%m-%d").replace(hour=12) + except (TypeError, ValueError) as exc: + raise CalculationError("reference_date must be YYYY-MM-DD") from exc + ayanamsa_name = normalize_ayanamsa_name(ayanamsa) + with _SWISSEPH_LOCK: + apply_ayanamsa(ayanamsa_name, swe) + jd = swe.julday( + local_dt.year, + local_dt.month, + local_dt.day, + 12.0 - float(tz), + ) + ayanamsa_value = swe.get_ayanamsa(jd) + position, flags = swe.calc_ut(jd, _PLANET_IDS[planet]) + longitude = (position[0] - ayanamsa_value) % 360 + return { + "planet": planet, + "longitude": longitude, + "reference_date": reference_date[:10], + "ayanamsa": ayanamsa_name, + "timezone_offset": float(tz), + "swisseph_return_flags": int(flags), + "data_layer": "true_transit_positions", + } + + +def compute_sade_sati( + *, + moon_degree: float, + asc_degree: float, + reference_date: str, + tz: float, + ayanamsa: str = "lahiri", +) -> dict[str, Any]: + transit = compute_transit_longitude( + planet="Saturn", + reference_date=reference_date, + tz=tz, + ayanamsa=ayanamsa, + ) + result = calc_sade_sati_complete( + float(moon_degree) % 360, + float(asc_degree) % 360, + transit["longitude"], + datetime.strptime(reference_date[:10], "%Y-%m-%d"), + ) + result["transit_saturn_lon"] = transit["longitude"] + result["provenance"] = transit + result["calculation_contract"] = _contract( + {"reference_date": reference_date[:10], "ayanamsa": ayanamsa, "tz": tz}, + transit, + algorithm="sade_sati_true_saturn_transit", + ) + result["result_hash"] = _canonical_hash(result) + return result diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index e66719a9..232c4e96 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -4275,78 +4275,69 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): raise BadRequest('Invalid birth date') from e try: - import swisseph as swe - swe.set_ephe_path(os.path.join(SCRIPTS_DIR, '..', 'swiss_ephemeris')) + calculation_service = _load_local_module('domain_calculation_service') + canonical_chart = calculation_service.compute_chart({ + 'year': year, + 'month': month, + 'day': day, + 'hour': hour, + 'minute': minute, + 'second': second, + 'lat': lat, + 'lon': lon, + 'tz': tz, + 'ayanamsa': body.get('ayanamsa', 'lahiri'), + 'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')), + }) + canonical_birth = canonical_chart['birth_info'] + planets_data = canonical_chart['planets'] + ascendant_data = canonical_chart['ascendant'] + asc_lon = float(ascendant_data['lon']) + asc_sign = ascendant_data['sign'] + asc_sign_idx = SIGNS.index(asc_sign) birth_hour_decimal = self._birth_hour_decimal(hour, minute, second) - hour_ut = birth_hour_decimal - tz - jd = swe.julday(year, month, day, hour_ut) - ayanamsa_name = body.get('ayanamsa', 'lahiri') - try: - from jyotish_engine import _apply_ayanamsa, _ayanamsa_display_name - _apply_ayanamsa(ayanamsa_name) - ayanamsa_display = _ayanamsa_display_name(ayanamsa_name) - except ImportError: - swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0) - ayanamsa_name = 'lahiri' - ayanamsa_display = 'Lahiri' - ayanamsa = swe.get_ayanamsa(jd) + jd = float(canonical_birth['julian_day']) + ayanamsa = float(canonical_birth['ayanamsa']) + ayanamsa_name = canonical_birth['ayanamsa_name'] + ayanamsa_display = canonical_birth['ayanamsa_display'] - planets_data = {} - planet_ids = {'Sun': 0, 'Moon': 1, 'Mars': 4, 'Mercury': 2, 'Jupiter': 5, 'Venus': 3, 'Saturn': 6, 'Rahu': 10, 'Ketu': 20} - planet_names_rev = {v: k for k, v in planet_ids.items()} - - for pid, pname in planet_names_rev.items(): - if pid == 20: - rahu_result, _ = swe.calc_ut(jd, 10) - planet_lon = (rahu_result[0] - ayanamsa + 180) % 360 - else: - result, _ = swe.calc_ut(jd, pid) - planet_lon = (result[0] - ayanamsa) % 360 - sign_idx = int(planet_lon / 30) % 12 - planets_data[pname] = {'lon': planet_lon, 'sign_idx': sign_idx, 'sign': SIGNS[sign_idx], 'degree': planet_lon % 30} - - # Ascendant - asc_tropical = swe.houses_ex(jd, lat, lon, b'E')[0][0] % 360 - asc_lon = (asc_tropical - ayanamsa) % 360 - asc_sign_idx = int(asc_lon / 30) % 12 - asc_sign = SIGNS[asc_sign_idx] - - # Houses houses = {} for h in range(1, 13): - s = (asc_sign_idx + h - 1) % 12 - houses[h] = {'sign': SIGNS[s], 'sign_idx': s} - - # Planet houses - for pn, pd in planets_data.items(): - pd['house'] = ((pd['sign_idx'] - asc_sign_idx) % 12) + 1 - - # Dasha (simplified Vimshottari) - moon_lon = planets_data['Moon']['lon'] - nak_size = 360/27 - nak_idx = int(moon_lon / nak_size) - dasha_lords = ['Ketu','Venus','Sun','Moon','Mars','Rahu','Jupiter','Saturn','Mercury'] - dasha_years = [7,20,6,10,7,18,16,19,17] - nak_lord_idx = nak_idx % 9 - md_lord = dasha_lords[nak_lord_idx] - total_years = dasha_years[nak_lord_idx] - elapsed = (moon_lon % nak_size) / nak_size * total_years - remaining = total_years - elapsed + house = canonical_chart.get('houses', {}).get(f'house_{h}', {}) + sign = house.get('cusp_sign', SIGNS[(asc_sign_idx + h - 1) % 12]) + houses[h] = { + 'sign': sign, + 'sign_idx': SIGNS.index(sign), + 'cusp_degree': house.get('cusp_degree'), + } + moon_lon = float(planets_data['Moon']['lon']) birth_dt = datetime(year, month, day, int(hour), int(minute), int(second)) - elapsed_days = elapsed * 365.25636 - dasha_start = birth_dt - timedelta(days=elapsed_days) if elapsed_days < 365*120 else birth_dt + canonical_dasha = calculation_service.compute_vimshottari_timeline( + birth_dt=birth_dt, + moon_lon=moon_lon, + current_date=birth_dt, + ) + dasha_balance = canonical_dasha['birth_balance'] + md_lord = dasha_balance['lord'] + remaining = dasha_balance['remaining_years'] + total_years = canonical_dasha['periods'][0]['years'] + dasha_start = datetime.strptime(canonical_dasha['periods'][0]['start'], '%Y-%m-%d') - # Yoga detection yogas = self._detect_yogas(planets_data, asc_sign_idx) - - # Sade Sati - from sade_sati import calc_sade_sati_complete - # Transit Saturn (approximate) - saturn_year_progress = (year - 2026) * 12 / 30 # ~12 signs in 30 years - transit_saturn_sign = (planets_data['Saturn']['sign_idx'] + int(saturn_year_progress)) % 12 - transit_saturn_lon = transit_saturn_sign * 30 + 15 - sade_sati = calc_sade_sati_complete(moon_lon, asc_lon, transit_saturn_lon) + reference_date = ( + body.get('transit_date') + or body.get('today') + or body.get('current_date') + or datetime.now().strftime('%Y-%m-%d') + ) + sade_sati = calculation_service.compute_sade_sati( + moon_degree=moon_lon, + asc_degree=asc_lon, + reference_date=reference_date, + tz=tz, + ayanamsa=ayanamsa_name, + ) # Dasha清单 extended_dashas = _load_local_module('extended_dashas') @@ -4420,7 +4411,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'ayanamsa': round(ayanamsa, 4), 'ayanamsa_name': ayanamsa_name, 'ayanamsa_display': ayanamsa_display, - 'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')), + 'node_mode': canonical_chart['calculation_contract']['effective']['node_mode'], }, 'ascendant': { 'sign': asc_sign, @@ -4435,6 +4426,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'remaining_years': round(remaining, 2), 'total_years': total_years, 'start_date': dasha_start.isoformat() if hasattr(dasha_start, 'isoformat') else str(dasha_start), + 'periods': canonical_dasha['periods'], + 'birth_balance': canonical_dasha['birth_balance'], + 'calculation_contract': canonical_dasha['calculation_contract'], + 'result_hash': canonical_dasha['result_hash'], }, 'yogas': yogas, 'sade_sati': sade_sati, @@ -4443,6 +4438,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'special_lagnas': special_lagnas, 'available_dashas': dasha_list, 'dasha_count': len(dasha_list), + 'calculation_contract': canonical_chart['calculation_contract'], + 'result_hash': canonical_chart['result_hash'], } result['modules'] = { 'chart': { @@ -4450,6 +4447,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'ascendant': result['ascendant'], 'houses': result['houses'], 'birth_info': result['birth'], + 'calculation_contract': result['calculation_contract'], + 'result_hash': result['result_hash'], }, 'dasha': result['dasha'], 'shadbala': {'planets': sb.get('planets', {})} if 'sb' in locals() and isinstance(sb, dict) else {}, diff --git a/tests/test_commercial_domain_calculation_contract.py b/tests/test_commercial_domain_calculation_contract.py new file mode 100644 index 00000000..c3f40faa --- /dev/null +++ b/tests/test_commercial_domain_calculation_contract.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + + +BIRTH = { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 28.6139, + "lon": 77.2090, + "tz": 5.5, + "ayanamsa": "lahiri", + "node_mode": "true", +} + + +def test_domain_chart_exposes_effective_parameters_and_result_hash() -> None: + from domain_calculation_service import compute_chart + + result = compute_chart(BIRTH) + + assert result["calculation_contract"]["effective"]["node_mode"] == "true" + assert result["calculation_contract"]["effective"]["ayanamsa"] == "lahiri" + assert result["result_hash"] + + +def test_api_chart_uses_same_domain_contract_and_preserves_shape( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import domain_calculation_service + import jyotish_api_server + from jyotish_api_server import JyotishAPIHandler + + monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0") + monkeypatch.setattr( + jyotish_api_server, + "_attach_vedastro_main_entry_overview", + lambda result, _birth: result, + ) + + expected = domain_calculation_service.compute_chart(BIRTH) + result = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync( + {**BIRTH, "transit_date": "2026-07-11"} + ) + + assert result["result_hash"] == expected["result_hash"] + assert result["calculation_contract"] == expected["calculation_contract"] + assert result["birth"]["node_mode"] == "true" + assert result["planets"] + assert result["ascendant"] + assert "houses" in result diff --git a/tests/test_cross_project_contract.py b/tests/test_cross_project_contract.py new file mode 100644 index 00000000..f840452c --- /dev/null +++ b/tests/test_cross_project_contract.py @@ -0,0 +1,77 @@ +"""Public synthetic-fixture contract shared with the commercial repository.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import cross_project_contract as contract # noqa: E402 + + +MANIFEST = ROOT / "references" / "cross_project_contract" / "fixture_manifest.v1.json" +LEDGER = ROOT / "references" / "cross_project_contract" / "sync_ledger.json" + + +def test_public_fixture_manifest_has_complete_effective_settings() -> None: + manifest = contract.load_manifest(MANIFEST) + + assert manifest["schema_version"] == 1 + assert manifest["privacy_scope"] == "public_synthetic_only" + fixture = manifest["fixtures"][0] + assert fixture["birth"]["synthetic"] is True + assert fixture["effective"] == {"ayanamsa": "lahiri", "node_mode": "mean", "timezone_offset": 5.5} + + +def test_local_calculation_matches_public_compatibility_hash() -> None: + report = contract.evaluate_manifest(MANIFEST) + + assert report["matches"] is True + assert report["fixtures"][0]["matches"] is True + + +def test_comparator_reports_tampered_expected_hash(tmp_path: Path) -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + manifest["fixtures"][0]["compatibility_hash"] = "0" * 64 + changed = tmp_path / "fixture_manifest.v1.json" + changed.write_text(json.dumps(manifest), encoding="utf-8") + + report = contract.evaluate_manifest(changed) + + assert report["matches"] is False + assert report["fixtures"][0]["matches"] is False + + +def test_compatibility_payload_normalizes_engine_degree_field() -> None: + fixture = contract.load_manifest(MANIFEST)["fixtures"][0] + chart = { + "ascendant": {"sign": "Aries", "degree": 1.25}, + "planets": { + planet: {"sign": "Aries", "degree": float(index)} + for index, planet in enumerate(contract.PLANETS) + }, + } + + payload = contract.compatibility_payload(chart, fixture) + + assert payload["ascendant"]["lon"] == 1.25 + assert payload["planets"]["Sun"]["lon"] == 0.0 + + +def test_sync_ledger_requires_provenance_privacy_tests_hash_and_rollback() -> None: + ledger = contract.load_ledger(LEDGER) + + assert ledger["schema_version"] == 1 + assert len(ledger["entries"]) >= 1 + for entry in ledger["entries"]: + assert contract.validate_ledger_entry(entry) == [] + assert entry["privacy_review"].startswith("pass:") + missing = contract.validate_ledger_entry({"source_repository": "x"}) + assert "target_commit" in missing + assert "privacy_review" in missing + assert "rollback" in missing diff --git a/tests/test_cross_project_sync_status.py b/tests/test_cross_project_sync_status.py new file mode 100644 index 00000000..aabfed3f --- /dev/null +++ b/tests/test_cross_project_sync_status.py @@ -0,0 +1,64 @@ +"""Cross-repository sync status checks for the research/commercial pair.""" + +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import cross_project_sync_status as sync_status # noqa: E402 + + +POLICY = ROOT / "references" / "cross_project_contract" / "sync_policy.v1.json" + + +def _make_peer_copy(tmp_path: Path) -> Path: + peer = tmp_path / "peer" + policy = sync_status.load_policy(POLICY) + for rel_path in policy["shared_files"]: + source = ROOT / rel_path + target = peer / rel_path + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + return peer + + +def test_sync_policy_encodes_research_first_commercial_mature_rule() -> None: + policy = sync_status.load_policy(POLICY) + + assert policy["schema_version"] == 1 + assert policy["sync_model"] == "research_validates_commercial_receives_mature" + assert policy["directional_gates"]["research_to_commercial"]["source_required"] == "validated_in_research" + assert policy["directional_gates"]["research_to_commercial"]["target_required"] == "commercial_safe" + assert "references/cross_project_contract/fixture_manifest.v1.json" in policy["shared_files"] + + +def test_sync_status_passes_when_shared_files_match(tmp_path: Path) -> None: + peer = _make_peer_copy(tmp_path) + + report = sync_status.compare_peer(peer, policy_path=POLICY, root=ROOT) + + assert report["status"] == "pass" + assert report["missing"] == [] + assert report["mismatched"] == [] + assert report["checked_count"] == len(sync_status.load_policy(POLICY)["shared_files"]) + + +def test_sync_status_reports_mismatched_shared_file(tmp_path: Path) -> None: + peer = _make_peer_copy(tmp_path) + changed = peer / "references" / "cross_project_contract" / "fixture_manifest.v1.json" + data = json.loads(changed.read_text(encoding="utf-8")) + data["fixtures"][0]["compatibility_hash"] = "0" * 64 + changed.write_text(json.dumps(data, sort_keys=True), encoding="utf-8") + + report = sync_status.compare_peer(peer, policy_path=POLICY, root=ROOT) + + assert report["status"] == "fail" + assert report["missing"] == [] + assert report["mismatched"] == ["references/cross_project_contract/fixture_manifest.v1.json"]