Evidence Packet
+仅显示已完成任务的可审计计算状态、证据包和技法审计。不会展示内部提示词或原始出生输入。
+ +运行状态
Technique Audit
-
Machine Evidence Packet
-
Warnings
-
From fd06bdc2eefa5547ed09968a218a8590df484bf3 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 12:18:37 +0800 Subject: [PATCH 01/30] feat: add public cross-project calculation contract --- .../fixture_manifest.v1.json | 12 ++ .../cross_project_contract/sync_ledger.json | 4 + scripts/cross_project_contract.py | 113 ++++++++++++++++++ tests/test_cross_project_contract.py | 63 ++++++++++ 4 files changed, 192 insertions(+) create mode 100644 references/cross_project_contract/fixture_manifest.v1.json create mode 100644 references/cross_project_contract/sync_ledger.json create mode 100644 scripts/cross_project_contract.py create mode 100644 tests/test_cross_project_contract.py 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..ba93b53f --- /dev/null +++ b/references/cross_project_contract/fixture_manifest.v1.json @@ -0,0 +1,12 @@ +{ + "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..df0e369a --- /dev/null +++ b/references/cross_project_contract/sync_ledger.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "entries": [] +} diff --git a/scripts/cross_project_contract.py b/scripts/cross_project_contract.py new file mode 100644 index 00000000..6e29302b --- /dev/null +++ b/scripts/cross_project_contract.py @@ -0,0 +1,113 @@ +#!/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": fixture["effective"], + "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/tests/test_cross_project_contract.py b/tests/test_cross_project_contract.py new file mode 100644 index 00000000..44044150 --- /dev/null +++ b/tests/test_cross_project_contract.py @@ -0,0 +1,63 @@ +"""Public synthetic-fixture contract shared with the backup 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) + fixture = manifest["fixtures"][0] + assert manifest["schema_version"] == 1 + assert manifest["privacy_scope"] == "public_synthetic_only" + 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, "entries": []} + missing = contract.validate_ledger_entry({"source_repository": "x"}) + assert "target_commit" in missing + assert "privacy_review" in missing + assert "rollback" in missing From 37fea987f96d1fcaeaad8ffff81a14438210f6f5 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 12:38:09 +0800 Subject: [PATCH 02/30] docs: record first research contract sync --- .../fixture_manifest.v1.json | 19 +++++++- .../cross_project_contract/sync_ledger.json | 25 ++++++++++- scripts/cross_project_contract.py | 44 +++++++++++++++---- tests/test_cross_project_contract.py | 21 +++++++-- 4 files changed, 94 insertions(+), 15 deletions(-) diff --git a/references/cross_project_contract/fixture_manifest.v1.json b/references/cross_project_contract/fixture_manifest.v1.json index ba93b53f..dd399d9d 100644 --- a/references/cross_project_contract/fixture_manifest.v1.json +++ b/references/cross_project_contract/fixture_manifest.v1.json @@ -4,8 +4,23 @@ "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}, + "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 index df0e369a..a9b67279 100644 --- a/references/cross_project_contract/sync_ledger.json +++ b/references/cross_project_contract/sync_ledger.json @@ -1,4 +1,27 @@ { "schema_version": 1, - "entries": [] + "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" + } + ] } diff --git a/scripts/cross_project_contract.py b/scripts/cross_project_contract.py index 6e29302b..96cf3585 100644 --- a/scripts/cross_project_contract.py +++ b/scripts/cross_project_contract.py @@ -19,9 +19,17 @@ 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", + "source_repository", + "source_commit", + "target_repository", + "target_commit", + "change_class", + "copied_files", + "dependency_delta", + "privacy_review", + "focused_tests", + "hash_contract_result", + "rollback", } @@ -69,8 +77,15 @@ def compatibility_payload(chart: dict[str, Any], fixture: dict[str, Any]) -> dic return { "fixture_id": fixture["id"], "birth": {key: value for key, value in fixture["birth"].items() if key != "synthetic"}, - "effective": fixture["effective"], - "ascendant": {"sign": chart["ascendant"]["sign"], "lon": _longitude(chart["ascendant"])}, + "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 @@ -79,7 +94,9 @@ def compatibility_payload(chart: dict[str, Any], fixture: dict[str, Any]) -> dic 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") + encoded = json.dumps( + compatibility_payload(chart, fixture), ensure_ascii=True, sort_keys=True, separators=(",", ":") + ).encode("utf-8") return hashlib.sha256(encoded).hexdigest() @@ -90,13 +107,24 @@ def evaluate_manifest(path: Path) -> dict[str, Any]: 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}) + 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( + "--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) diff --git a/tests/test_cross_project_contract.py b/tests/test_cross_project_contract.py index 44044150..024420ce 100644 --- a/tests/test_cross_project_contract.py +++ b/tests/test_cross_project_contract.py @@ -1,4 +1,4 @@ -"""Public synthetic-fixture contract shared with the backup repository.""" +"""Public synthetic-fixture contract shared with the commercial repository.""" from __future__ import annotations @@ -20,15 +20,17 @@ LEDGER = ROOT / "references" / "cross_project_contract" / "sync_ledger.json" def test_public_fixture_manifest_has_complete_effective_settings() -> None: manifest = contract.load_manifest(MANIFEST) - fixture = manifest["fixtures"][0] + 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 @@ -38,7 +40,9 @@ def test_comparator_reports_tampered_expected_hash(tmp_path: Path) -> None: 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 @@ -47,16 +51,25 @@ 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)}, + "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, "entries": []} + + assert ledger["schema_version"] == 1 + assert len(ledger["entries"]) == 1 + assert contract.validate_ledger_entry(ledger["entries"][0]) == [] + assert ledger["entries"][0]["privacy_review"].startswith("pass:") missing = contract.validate_ledger_entry({"source_repository": "x"}) assert "target_commit" in missing assert "privacy_review" in missing From b6d292a7cd1a5c50d4b920caeec11b462248c21d Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 13:40:28 +0800 Subject: [PATCH 03/30] feat: add cross-project sync status gate --- .../sync_policy.v1.json | 36 +++++++++ scripts/cross_project_sync_status.py | 73 +++++++++++++++++++ tests/test_cross_project_sync_status.py | 64 ++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 references/cross_project_contract/sync_policy.v1.json create mode 100644 scripts/cross_project_sync_status.py create mode 100644 tests/test_cross_project_sync_status.py 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..2a71896a --- /dev/null +++ b/references/cross_project_contract/sync_policy.v1.json @@ -0,0 +1,36 @@ +{ + "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/cross_project_sync_status.py", + "tests/test_cross_project_contract.py", + "tests/test_cross_project_sync_status.py" + ] +} 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/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"] From 274fdfe32aef266c90d10360f622178fe63deb50 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 13:49:51 +0800 Subject: [PATCH 04/30] docs: record sync governance contract --- .../cross_project_contract/sync_ledger.json | 21 +++++++++++++++++++ tests/test_cross_project_contract.py | 7 ++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/references/cross_project_contract/sync_ledger.json b/references/cross_project_contract/sync_ledger.json index a9b67279..732b4867 100644 --- a/references/cross_project_contract/sync_ledger.json +++ b/references/cross_project_contract/sync_ledger.json @@ -22,6 +22,27 @@ ], "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" } ] } diff --git a/tests/test_cross_project_contract.py b/tests/test_cross_project_contract.py index 024420ce..f840452c 100644 --- a/tests/test_cross_project_contract.py +++ b/tests/test_cross_project_contract.py @@ -67,9 +67,10 @@ def test_sync_ledger_requires_provenance_privacy_tests_hash_and_rollback() -> No ledger = contract.load_ledger(LEDGER) assert ledger["schema_version"] == 1 - assert len(ledger["entries"]) == 1 - assert contract.validate_ledger_entry(ledger["entries"][0]) == [] - assert ledger["entries"][0]["privacy_review"].startswith("pass:") + 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 From 61655fe060577011d91b74ad1fc671bace1bd689 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 14:14:25 +0800 Subject: [PATCH 05/30] feat: use domain calculation contract in commercial API --- .../sync_policy.v1.json | 1 + scripts/domain_calculation_service.py | 260 ++++++++++++++++++ scripts/jyotish_api_server.py | 27 +- ..._commercial_domain_calculation_contract.py | 58 ++++ 4 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 scripts/domain_calculation_service.py create mode 100644 tests/test_commercial_domain_calculation_contract.py diff --git a/references/cross_project_contract/sync_policy.v1.json b/references/cross_project_contract/sync_policy.v1.json index 2a71896a..0f5f468b 100644 --- a/references/cross_project_contract/sync_policy.v1.json +++ b/references/cross_project_contract/sync_policy.v1.json @@ -29,6 +29,7 @@ "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/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..4da9b368 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -4405,6 +4405,25 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): except Exception as e: import logging logging.warning(f"[api_server] yoga expansion detection failed: {e}") + 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_dasha = calculation_service.compute_vimshottari_timeline( + birth_dt=birth_dt, + moon_lon=moon_lon, + current_date=datetime.utcnow(), + ) result = { 'success': True, 'version': '6.9.15', 'birth': { @@ -4420,7 +4439,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 +4454,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 +4466,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': { diff --git a/tests/test_commercial_domain_calculation_contract.py b/tests/test_commercial_domain_calculation_contract.py new file mode 100644 index 00000000..e7d47938 --- /dev/null +++ b/tests/test_commercial_domain_calculation_contract.py @@ -0,0 +1,58 @@ +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)) + +import domain_calculation_service as calculation_service # noqa: E402 +import jyotish_api_server # noqa: E402 +from jyotish_api_server import JyotishAPIHandler # noqa: E402 + + +BIRTH = { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 28.6139, + "lon": 77.2090, + "tz": 5.5, + "ayanamsa": "lahiri", +} + + +def test_domain_chart_exposes_effective_params_and_result_hash() -> None: + mean = calculation_service.compute_chart({**BIRTH, "node_mode": "mean"}) + true = calculation_service.compute_chart({**BIRTH, "node_mode": "true"}) + + assert mean["calculation_contract"]["effective"]["ayanamsa"] == "lahiri" + assert true["calculation_contract"]["effective"]["node_mode"] == "true" + assert mean["planets"]["Rahu"]["lon"] != pytest.approx(true["planets"]["Rahu"]["lon"], abs=1e-8) + assert mean["result_hash"] != true["result_hash"] + + +def test_api_chart_response_uses_domain_contract_hash(monkeypatch: pytest.MonkeyPatch) -> None: + 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 = calculation_service.compute_chart({**BIRTH, "node_mode": "true"}) + + rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync( + {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"} + ) + + assert rest["result_hash"] == expected["result_hash"] + assert rest["birth"]["node_mode"] == "true" + assert rest["calculation_contract"]["effective"]["node_mode"] == "true" From 3f0d5791dcca075310c1a7487e1bfe09a6b79d46 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 14:17:19 +0800 Subject: [PATCH 06/30] docs: record commercial domain calculation sync --- .../cross_project_contract/sync_ledger.json | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/references/cross_project_contract/sync_ledger.json b/references/cross_project_contract/sync_ledger.json index 732b4867..a2dd76e8 100644 --- a/references/cross_project_contract/sync_ledger.json +++ b/references/cross_project_contract/sync_ledger.json @@ -43,6 +43,28 @@ ], "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" } ] } From 73e0ff724d70a234a3fc02b207468377bf709997 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 14:38:19 +0800 Subject: [PATCH 07/30] fix: source visible chart values from domain service --- scripts/jyotish_api_server.py | 25 +++++++++++++++++++ ..._commercial_domain_calculation_contract.py | 21 ++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 4da9b368..0126e0e8 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -4424,6 +4424,31 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): moon_lon=moon_lon, current_date=datetime.utcnow(), ) + canonical_planets = {} + for planet_name, planet in canonical_chart.get('planets', {}).items(): + if not isinstance(planet, dict): + continue + normalized_planet = dict(planet) + normalized_planet['degree'] = normalized_planet.get( + 'degree_in_sign', + normalized_planet.get('degree', normalized_planet.get('lon')), + ) + if normalized_planet.get('sign') in SIGNS: + normalized_planet['sign_idx'] = SIGNS.index(normalized_planet['sign']) + canonical_planets[planet_name] = normalized_planet + planets_data = canonical_planets or planets_data + ascendant_data = dict(canonical_chart.get('ascendant', {})) + if ascendant_data.get('sign') in SIGNS: + asc_sign = ascendant_data['sign'] + asc_sign_idx = SIGNS.index(asc_sign) + asc_lon = float(ascendant_data.get('lon', asc_lon)) + canonical_houses = canonical_chart.get('houses', {}) + if isinstance(canonical_houses, dict) and canonical_houses: + houses = {} + for h in range(1, 13): + house = canonical_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)} result = { 'success': True, 'version': '6.9.15', 'birth': { diff --git a/tests/test_commercial_domain_calculation_contract.py b/tests/test_commercial_domain_calculation_contract.py index e7d47938..73e078ef 100644 --- a/tests/test_commercial_domain_calculation_contract.py +++ b/tests/test_commercial_domain_calculation_contract.py @@ -56,3 +56,24 @@ def test_api_chart_response_uses_domain_contract_hash(monkeypatch: pytest.Monkey assert rest["result_hash"] == expected["result_hash"] assert rest["birth"]["node_mode"] == "true" assert rest["calculation_contract"]["effective"]["node_mode"] == "true" + + +def test_api_visible_chart_values_come_from_domain_service(monkeypatch: pytest.MonkeyPatch) -> None: + 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, + ) + request = {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"} + expected = calculation_service.compute_chart(request) + + rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(request) + + assert rest["ascendant"]["lon"] == pytest.approx(expected["ascendant"]["lon"], abs=1e-8) + for planet in ("Sun", "Moon", "Rahu", "Ketu"): + assert rest["planets"][planet]["sign"] == expected["planets"][planet]["sign"] + assert rest["planets"][planet]["lon"] == pytest.approx( + expected["planets"][planet]["lon"], abs=1e-8 + ) From 8d7291d5cf420f314eadc8dd5f22d6afd4d9c575 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 14:41:21 +0800 Subject: [PATCH 08/30] docs: record visible chart contract sync --- .../cross_project_contract/sync_ledger.json | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/references/cross_project_contract/sync_ledger.json b/references/cross_project_contract/sync_ledger.json index a2dd76e8..7787fe59 100644 --- a/references/cross_project_contract/sync_ledger.json +++ b/references/cross_project_contract/sync_ledger.json @@ -65,6 +65,26 @@ ], "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" } ] } From 09387d82ffe9aa59d81a2af6c66ea34d47850ed9 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 14:59:36 +0800 Subject: [PATCH 09/30] fix: source sade sati from domain service --- scripts/jyotish_api_server.py | 7 +++++ ..._commercial_domain_calculation_contract.py | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 0126e0e8..666a7bde 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -4424,6 +4424,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): moon_lon=moon_lon, current_date=datetime.utcnow(), ) + sade_sati = calculation_service.compute_sade_sati( + moon_degree=canonical_chart['planets']['Moon']['lon'], + asc_degree=canonical_chart['ascendant']['lon'], + reference_date=body.get('transit_date') or body.get('today') or body.get('current_date') or datetime.utcnow().strftime('%Y-%m-%d'), + tz=tz, + ayanamsa=body.get('ayanamsa', 'lahiri'), + ) canonical_planets = {} for planet_name, planet in canonical_chart.get('planets', {}).items(): if not isinstance(planet, dict): diff --git a/tests/test_commercial_domain_calculation_contract.py b/tests/test_commercial_domain_calculation_contract.py index 73e078ef..a4630481 100644 --- a/tests/test_commercial_domain_calculation_contract.py +++ b/tests/test_commercial_domain_calculation_contract.py @@ -77,3 +77,30 @@ def test_api_visible_chart_values_come_from_domain_service(monkeypatch: pytest.M assert rest["planets"][planet]["lon"] == pytest.approx( expected["planets"][planet]["lon"], abs=1e-8 ) + + +def test_api_sade_sati_uses_domain_true_saturn_transit(monkeypatch: pytest.MonkeyPatch) -> None: + 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, + ) + request = {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"} + chart = calculation_service.compute_chart(request) + expected = calculation_service.compute_sade_sati( + moon_degree=chart["planets"]["Moon"]["lon"], + asc_degree=chart["ascendant"]["lon"], + reference_date="2026-07-11", + tz=BIRTH["tz"], + ayanamsa=BIRTH["ayanamsa"], + ) + + rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(request) + + assert rest["sade_sati"]["transit_saturn_lon"] == pytest.approx( + expected["transit_saturn_lon"], abs=1e-8 + ) + assert rest["sade_sati"]["provenance"]["data_layer"] == "true_transit_positions" + assert rest["sade_sati"]["calculation_contract"]["algorithm"] == "sade_sati_true_saturn_transit" From 8a352ddf9c576ebc7a8032ad758b8b247d6dafcc Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 15:04:36 +0800 Subject: [PATCH 10/30] docs: record sade sati contract sync --- .../cross_project_contract/sync_ledger.json | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/references/cross_project_contract/sync_ledger.json b/references/cross_project_contract/sync_ledger.json index 7787fe59..7246c011 100644 --- a/references/cross_project_contract/sync_ledger.json +++ b/references/cross_project_contract/sync_ledger.json @@ -85,6 +85,26 @@ ], "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" } ] } From e8a8ff62b0fa86f9e24be5a0ac2604afa575dd8c Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 15:16:29 +0800 Subject: [PATCH 11/30] fix: source dasha boundary from domain service --- scripts/jyotish_api_server.py | 6 ++--- ..._commercial_domain_calculation_contract.py | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 666a7bde..80ddaa21 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -4482,10 +4482,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): }, 'planets': planets_data, 'houses': houses, 'shadbala': shadbala_summary, 'dasha': { - 'current_md': md_lord, - 'remaining_years': round(remaining, 2), + 'current_md': canonical_dasha['birth_balance']['lord'], + 'remaining_years': canonical_dasha['birth_balance']['remaining_years'], 'total_years': total_years, - 'start_date': dasha_start.isoformat() if hasattr(dasha_start, 'isoformat') else str(dasha_start), + 'start_date': canonical_dasha['periods'][0]['start'], 'periods': canonical_dasha['periods'], 'birth_balance': canonical_dasha['birth_balance'], 'calculation_contract': canonical_dasha['calculation_contract'], diff --git a/tests/test_commercial_domain_calculation_contract.py b/tests/test_commercial_domain_calculation_contract.py index a4630481..1305f00f 100644 --- a/tests/test_commercial_domain_calculation_contract.py +++ b/tests/test_commercial_domain_calculation_contract.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from datetime import datetime from pathlib import Path import pytest @@ -104,3 +105,28 @@ def test_api_sade_sati_uses_domain_true_saturn_transit(monkeypatch: pytest.Monke ) assert rest["sade_sati"]["provenance"]["data_layer"] == "true_transit_positions" assert rest["sade_sati"]["calculation_contract"]["algorithm"] == "sade_sati_true_saturn_transit" + + +def test_api_dasha_boundary_comes_from_domain_service(monkeypatch: pytest.MonkeyPatch) -> None: + 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, + ) + request = {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"} + chart = calculation_service.compute_chart(request) + expected = calculation_service.compute_vimshottari_timeline( + birth_dt=datetime(1990, 1, 1, 12, 0), + moon_lon=chart["planets"]["Moon"]["lon"], + ) + + rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(request) + + assert rest["dasha"]["current_md"] == expected["birth_balance"]["lord"] + assert rest["dasha"]["remaining_years"] == pytest.approx( + expected["birth_balance"]["remaining_years"], abs=1e-8 + ) + assert rest["dasha"]["start_date"] == expected["periods"][0]["start"] + assert rest["dasha"]["result_hash"] == expected["result_hash"] From 66068128d4392bdedeb2f881ff3f08817fe9d445 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 15:18:53 +0800 Subject: [PATCH 12/30] docs: record dasha contract sync --- .../cross_project_contract/sync_ledger.json | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/references/cross_project_contract/sync_ledger.json b/references/cross_project_contract/sync_ledger.json index 7246c011..0cb7d103 100644 --- a/references/cross_project_contract/sync_ledger.json +++ b/references/cross_project_contract/sync_ledger.json @@ -105,6 +105,26 @@ ], "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" } ] } From 1f46200586a89162ab86dc1a4d6ae482420a786b Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 16:23:38 +0800 Subject: [PATCH 13/30] Add active rectification API workflow --- jyotish-app/api-bridge.js | 10 +++ jyotish-app/public/api-bridge.js | 10 +++ scripts/jyotish_api_server.py | 52 ++++++++++++++++ tests/test_active_rectification_api.py | 85 ++++++++++++++++++++++++++ tests/test_frontend_productization.py | 2 + 5 files changed, 159 insertions(+) create mode 100644 tests/test_active_rectification_api.py diff --git a/jyotish-app/api-bridge.js b/jyotish-app/api-bridge.js index 66286f2c..e013df03 100644 --- a/jyotish-app/api-bridge.js +++ b/jyotish-app/api-bridge.js @@ -327,6 +327,14 @@ async function computeRectificationGate(payload) { return postJson('/api/rectification_gate', payload); } +async function computeActiveRectificationQuestions(payload) { + return postJson('/api/active_rectification_questions', payload); +} + +async function computeActiveRectificationScore(payload) { + return postJson('/api/active_rectification_score', payload); +} + async function computeCaseValidation(payload) { return postJson('/api/case_validation', payload); } @@ -495,6 +503,8 @@ window.JyotishAPI = { computeYogas, computeAspects, computeRectificationGate, + computeActiveRectificationQuestions, + computeActiveRectificationScore, computeCaseValidation, getRealCaseRevalidation, computeDivisionalYoga, diff --git a/jyotish-app/public/api-bridge.js b/jyotish-app/public/api-bridge.js index 66286f2c..e013df03 100644 --- a/jyotish-app/public/api-bridge.js +++ b/jyotish-app/public/api-bridge.js @@ -327,6 +327,14 @@ async function computeRectificationGate(payload) { return postJson('/api/rectification_gate', payload); } +async function computeActiveRectificationQuestions(payload) { + return postJson('/api/active_rectification_questions', payload); +} + +async function computeActiveRectificationScore(payload) { + return postJson('/api/active_rectification_score', payload); +} + async function computeCaseValidation(payload) { return postJson('/api/case_validation', payload); } @@ -495,6 +503,8 @@ window.JyotishAPI = { computeYogas, computeAspects, computeRectificationGate, + computeActiveRectificationQuestions, + computeActiveRectificationScore, computeCaseValidation, getRealCaseRevalidation, computeDivisionalYoga, diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 80ddaa21..fa9c8028 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -951,6 +951,8 @@ API_COMMAND_MAP = { 'yoga': '/api/yogas', 'aspects': '/api/aspects', 'rectification': '/api/rectification_gate', + 'active-rectification-questions': '/api/active_rectification_questions', + 'active-rectification-score': '/api/active_rectification_score', 'case-validation': '/api/case_validation', 'divisional-yoga': '/api/divisional_yoga', 'deep-varga-avastha': '/api/deep_varga_avastha', @@ -982,6 +984,8 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = { '/api/pancha_mahapurusha', '/api/prashna', '/api/rectification_gate', + '/api/active_rectification_questions', + '/api/active_rectification_score', '/api/relationship', '/api/remedies', '/api/sade_sati', @@ -1271,6 +1275,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): elif path == '/api/rectification_gate': result = self._compute_rectification_gate(body) self._json(result) + elif path == '/api/active_rectification_questions': + result = self._compute_active_rectification_questions(body) + self._json(result) + elif path == '/api/active_rectification_score': + result = self._compute_active_rectification_score(body) + self._json(result) elif path == '/api/case_validation': result = self._compute_case_validation(body) self._json(result) @@ -6208,6 +6218,46 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): }, } + def _compute_active_rectification_questions(self, body): + birth_time = body.get('birth_time') + if not isinstance(birth_time, str) or not birth_time.strip(): + raise BadRequest('birth_time must be a string') + uncertainty_minutes = self._get_int(body, 'uncertainty_minutes', 30) + if not 1 <= uncertainty_minutes <= 180: + raise BadRequest('uncertainty_minutes must be between 1 and 180') + step_minutes = self._get_int(body, 'step_minutes', 1) + if not 1 <= step_minutes <= 30: + raise BadRequest('step_minutes must be between 1 and 30') + try: + module = _load_local_module('active_rectification_questions') + result = module.build_questionnaire( + birth_time.strip(), + uncertainty_minutes=uncertainty_minutes, + step_minutes=step_minutes, + ) + except ValueError as e: + raise BadRequest('birth_time must be YYYY-MM-DD HH:MM') from e + return { + 'success': True, + 'endpoint': 'active_rectification_questions', + **result, + } + + def _compute_active_rectification_score(self, body): + questionnaire = body.get('questionnaire') + if not isinstance(questionnaire, dict): + raise BadRequest('questionnaire must be an object') + answers = body.get('answers') + if not isinstance(answers, dict): + raise BadRequest('answers must be an object') + module = _load_local_module('active_rectification_questions') + result = module.score_answers(questionnaire, answers) + return { + 'success': True, + 'endpoint': 'active_rectification_score', + **result, + } + def _compute_case_validation(self, body): planets, _, _ = self._normalized_planets_from_body(body) current_md = body.get('current_md', body.get('dasha_lord', '')) @@ -6968,6 +7018,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/pancha_mahapurusha': self._compute_pmc, '/api/prashna': self._compute_prashna, '/api/rectification_gate': self._compute_rectification_gate, + '/api/active_rectification_questions': self._compute_active_rectification_questions, + '/api/active_rectification_score': self._compute_active_rectification_score, '/api/relationship': self._compute_relationship, '/api/remedies': self._compute_remedies, '/api/sade_sati': self._compute_sade_sati, diff --git a/tests/test_active_rectification_api.py b/tests/test_active_rectification_api.py new file mode 100644 index 00000000..6790e0e3 --- /dev/null +++ b/tests/test_active_rectification_api.py @@ -0,0 +1,85 @@ +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)) + +from jyotish_api_server import BadRequest, JyotishAPIHandler # noqa: E402 + + +def _handler() -> JyotishAPIHandler: + return JyotishAPIHandler.__new__(JyotishAPIHandler) + + +def test_active_rectification_questions_api_builds_choice_workflow() -> None: + result = _handler()._compute_active_rectification_questions( + { + "birth_time": "1993-04-17 14:49", + "uncertainty_minutes": 30, + "step_minutes": 1, + } + ) + + assert result["success"] is True + assert result["endpoint"] == "active_rectification_questions" + assert result["scope"] == "active_birth_time_rectification_questionnaire" + assert result["candidate_scan"]["start"] == "1993-04-17 14:19" + assert result["candidate_scan"]["end"] == "1993-04-17 15:19" + assert result["candidate_scan"]["candidate_count"] == 61 + assert result["questions"] + assert {option["key"] for option in result["questions"][0]["options"]} == {"A", "B", "C", "D"} + assert "dynamic_candidate_cluster_scoring" in result["workflow"] + + +def test_active_rectification_score_api_returns_rankings_and_next_questions() -> None: + questionnaire = _handler()._compute_active_rectification_questions( + {"birth_time": "1993-04-17 14:49", "uncertainty_minutes": 30} + ) + scored = _handler()._compute_active_rectification_score( + { + "questionnaire": questionnaire, + "answers": { + "education_environment_shift": "A", + "residence_relocation_shift": "B", + "relationship_or_partner_entry": "D", + "career_responsibility_pressure": "A", + "research_tool_expression_shift": "C", + }, + } + ) + + assert scored["success"] is True + assert scored["endpoint"] == "active_rectification_score" + assert scored["scope"] == "active_birth_time_rectification_scoring" + assert scored["answered_count"] == 5 + assert scored["candidate_cluster_rankings"] + assert scored["next_round_questions"] + assert scored["candidate_cluster_rankings"][0]["score"] >= scored["candidate_cluster_rankings"][-1]["score"] + + +def test_active_rectification_questions_api_validates_request() -> None: + with pytest.raises(BadRequest, match="birth_time must be a string"): + _handler()._compute_active_rectification_questions({}) + + with pytest.raises(BadRequest, match="uncertainty_minutes must be between 1 and 180"): + _handler()._compute_active_rectification_questions( + {"birth_time": "1993-04-17 14:49", "uncertainty_minutes": 0} + ) + + with pytest.raises(BadRequest, match="step_minutes must be between 1 and 30"): + _handler()._compute_active_rectification_questions( + {"birth_time": "1993-04-17 14:49", "step_minutes": 31} + ) + + +def test_active_rectification_score_api_validates_payload() -> None: + with pytest.raises(BadRequest, match="questionnaire must be an object"): + _handler()._compute_active_rectification_score({"answers": {}}) + + with pytest.raises(BadRequest, match="answers must be an object"): + _handler()._compute_active_rectification_score({"questionnaire": {}}) diff --git a/tests/test_frontend_productization.py b/tests/test_frontend_productization.py index 48e3daa4..ccae9b82 100644 --- a/tests/test_frontend_productization.py +++ b/tests/test_frontend_productization.py @@ -1397,6 +1397,8 @@ def test_api_bridge_exports_productized_backend_actions() -> None: "computeYogas", "computeAspects", "computeRectificationGate", + "computeActiveRectificationQuestions", + "computeActiveRectificationScore", "computeCaseValidation", "computeDivisionalYoga", "computeKakshya", From 6304adfb23eeb645b0d5c18e7fc2e76590e75e69 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 18:26:25 +0800 Subject: [PATCH 14/30] Add active rectification web wizard --- jyotish-app/rectification.js | 126 ++++++++++++++++++++++++++ tests/test_frontend_productization.py | 27 ++++++ 2 files changed, 153 insertions(+) diff --git a/jyotish-app/rectification.js b/jyotish-app/rectification.js index 565a43fe..72272fd0 100644 --- a/jyotish-app/rectification.js +++ b/jyotish-app/rectification.js @@ -16,6 +16,9 @@ function fmtOffset(m) { return m === 0 ? t('rect.baseline') : `${m > 0 ? '+' : ' let rectEvents = []; let rectInterviewAnswers = {}; let rectRecommendedEvents = []; +let activeRectificationQuestionnaire = null; +let activeRectificationAnswers = {}; +let activeRectificationScore = null; export function renderRectificationTab(container) { const lang = getLang(); @@ -56,6 +59,27 @@ export function renderRectificationTab(container) { +
系统先按出生时间误差生成高信息量选择题;你只需点选答案,再进入下一轮收敛。
+Prashna 使用提问当下的时刻与当前星盘数据判断具体问题,适合一次只问一个清晰问题。
+Prashna 仅使用提问当下时刻与地点由后端排盘;不使用本命盘替代问事盘。
请填写一个明确问题。
'; + return; + } + if (!timestamp?.value || !lat?.value || !lon?.value || !timezone?.value) { + result.innerHTML = '请填写提问时刻、纬度、经度与 UTC 时区。
'; + return; + } if (questionText.length > 120) { result.innerHTML = '问题请控制在 120 字以内。
'; return; @@ -7198,11 +7210,14 @@ function renderPrashnaTab(chartData) { result.innerHTML = '正在铸造 Prashna 问事盘...
'; try { const data = await window.JyotishAPI?.computePrashna?.({ - question: questionType, question_text: questionText, - planets: chartData?.planets || {}, - asc_degree: chartData?.ascendant?.lon ?? chartData?.ascendant?.degree ?? 15.5, - horary_number: chartData?.kp_horary?.horary_number || '', + question_timestamp: timestamp.value, + lat: Number(lat.value), + lon: Number(lon.value), + timezone: Number(timezone.value), + ayanamsa: 'lahiri', + node_mode: 'mean', + location_convention: 'wgs84', }); if (!data) throw new Error('本地 API 未返回结果'); recordPrashnaWorkflow(data, questionText, questionType); diff --git a/scripts/gulika.py b/scripts/gulika.py new file mode 100644 index 00000000..f92f439b --- /dev/null +++ b/scripts/gulika.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Swiss-Ephemeris Gulika calculator using the Prasna Marga Ghatika table.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import swisseph as swe + +try: + from saham_daynight import determine_daytime +except ImportError: + from scripts.saham_daynight import determine_daytime + + +# Monday=0, matching datetime.weekday(). Values are the end of Saturn's share +# measured in Ghatika from the relevant sunrise/sunset (30 Ghatika per period). +GHATIKA_END = { + 0: {"day": 22, "night": 6}, + 1: {"day": 18, "night": 2}, + 2: {"day": 14, "night": 26}, + 3: {"day": 10, "night": 22}, + 4: {"day": 6, "night": 18}, + 5: {"day": 2, "night": 14}, + 6: {"day": 26, "night": 10}, +} + + +def _sidereal_ascendant(jd_ut: float, lat: float, lon: float) -> float: + swe.set_sid_mode(swe.SIDM_LAHIRI) + cusps, ascmc = swe.houses_ex(jd_ut, lat, lon, b"P", swe.FLG_SIDEREAL) + return float(ascmc[0]) % 360 + + +def calculate_gulika( + moment: datetime, + *, + lat: float, + lon: float, + tz: float, +) -> dict[str, Any]: + """Return Gulika from local moment/location using Swiss sunrise and sunset.""" + daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz) + is_day = bool(daynight["is_daytime"]) + period = "day" if is_day else "night" + ghatika_end = GHATIKA_END[moment.weekday()][period] + start_jd = daynight["sunrise_jd_ut"] if is_day else daynight["sunset_jd_ut"] + end_jd = daynight["sunset_jd_ut"] if is_day else daynight["sunrise_jd_ut"] + 1.0 + if end_jd <= start_jd: + end_jd += 1.0 + segment_jd = start_jd + (end_jd - start_jd) * (ghatika_end / 30.0) + longitude = _sidereal_ascendant(segment_jd, float(lat), float(lon)) + return { + "scope": "gulika_prasna_marga", + "status": "partial", + "longitude": round(longitude, 6), + "sign_idx": int(longitude / 30) % 12, + "degree_in_sign": round(longitude % 30, 6), + "period": period, + "weekday": moment.weekday(), + "ghatika_end": ghatika_end, + "segment_jd_ut": segment_jd, + "daynight_evidence": daynight, + "ayanamsa": "lahiri", + "rule_source": "references/prashna-complete-guide.md#3.5", + "boundary": "Formula is implemented from the local classical guide; external JHora/PyJHora numeric parity remains required before enabling Sphuta or verdict layers.", + } diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 4e3e88e7..ea4e56d2 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -5036,12 +5036,34 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): return calc_kp_analysis(planets, SIGNS[asc_idx]) def _compute_prashna(self, body): + try: + from prashna_context import PrashnaContextError, build_prashna_context + except ModuleNotFoundError: # pragma: no cover - package import path + from scripts.prashna_context import PrashnaContextError, build_prashna_context question_type = body.get('question', 'general') if not isinstance(question_type, str): raise BadRequest('question must be a string') question_text = body.get('question_text', '') if not isinstance(question_text, str): raise BadRequest('question_text must be a string') + if "question_text" in body and not isinstance(body["question_text"], str): + raise BadRequest('question_text must be a string') + if "planets" in body or "asc_degree" in body: + raise BadRequest("Prashna planets and ascendant are backend-computed; client values are forbidden") + try: + context = build_prashna_context(body) + except PrashnaContextError as exc: + raise BadRequest(str(exc)) from exc + return { + "success": True, + "status": "computed", + "prashna_context": context, + "verdict": { + "status": "blocked", + "reason": "Prashna adjudication is disabled until Tajika/Saham/Sphuta kernels pass classic golden cases.", + }, + } + # Legacy client-supplied-chart pipeline below is unreachable pending deletion. from prashna import ( QUESTION_CATEGORIES, analyze_lost_item, diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 241d037e..6b94f848 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -5085,14 +5085,26 @@ def cmd_full_reading(args): try: from tajika import calc_tajika_yogas, calc_all_sahams - # Tajika Yogas(用本命盘行星经度) - tc_yogas = calc_tajika_yogas(planet_lons) + # Seven-planet Tajika candidates require actual instantaneous speed. + tajika_planets = { + name: {"longitude": item.get("degree_raw", item.get("lon")), "speed": item.get("speed")} + for name, item in planets.items() + if isinstance(item, dict) and name in {"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"} + } + tc_yogas = calc_tajika_yogas(tajika_planets) report['modules']['tajika_yogas'] = tc_yogas # Sahams(特殊点)—— 需要出生时间 - birth_dt = getattr(args, 'birth_datetime', None) + birth_dt = _birth_datetime_from_args(args) if birth_dt and planet_lons: - sahams_result = calc_all_sahams(planet_lons, asc_deg, birth_dt) + sahams_result = calc_all_sahams( + planet_lons, + asc_deg, + birth_dt, + lat=getattr(args, 'lat', None), + lon=getattr(args, 'lon', None), + tz=getattr(args, 'tz', None), + ) report['modules']['sahams'] = sahams_result else: report['modules']['sahams'] = {'warning': 'birth_datetime or planet_lons missing, skip saham calc'} @@ -5871,48 +5883,30 @@ def cmd_full_reading(args): def cmd_prashna(args): """Prashna 问事占星:基于提问时刻的即时星盘分析""" try: - from prashna import cast_prashna, calc_arudha, calc_sphutas, calc_life_sphutas, calc_sahams, analyze_lost_item, kunda_verify, calc_gulika_simple + from prashna_context import PrashnaContextError, build_prashna_context except ImportError: - # 尝试从同目录导入 - import importlib.util, os - spec = importlib.util.spec_from_file_location("prashna", os.path.join(os.path.dirname(__file__), "prashna.py")) - prashna_mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(prashna_mod) - cast_prashna = prashna_mod.cast_prashna - calc_arudha = prashna_mod.calc_arudha - calc_sphutas = prashna_mod.calc_sphutas - calc_life_sphutas = prashna_mod.calc_life_sphutas - calc_sahams = prashna_mod.calc_sahams - analyze_lost_item = prashna_mod.analyze_lost_item - kunda_verify = prashna_mod.kunda_verify - calc_gulika_simple = prashna_mod.calc_gulika_simple - - if args.mode == 'chart': - return cast_prashna(args.datetime, args.lat, args.lon) - - # 其他模式需要先铸盘获取行星位置 - chart = cast_prashna(args.datetime, args.lat, args.lon) - if 'error' in chart: - return chart - - asc_lon = chart['ascendant']['lon'] - p_lons = {n: d['lon'] for n, d in chart['planets'].items()} - - if args.mode == 'arudha': - return {'arudha_lagna': calc_arudha(asc_lon, p_lons), - 'ascendant': chart['ascendant']} - elif args.mode == 'sphutas': - return calc_sphutas(p_lons, 0) - elif args.mode == 'sahams': - return calc_sahams(p_lons, asc_lon) - elif args.mode == 'lost-item': - return analyze_lost_item(p_lons, asc_lon) - elif args.mode == 'life': - return calc_life_sphutas(asc_lon, p_lons.get('Moon',0), p_lons.get('Sun',0), 0) - elif args.mode == 'kunda': - return kunda_verify(asc_lon) - else: - return cast_prashna(args.datetime, args.lat, args.lon) + from scripts.prashna_context import PrashnaContextError, build_prashna_context + try: + context = build_prashna_context({ + "question_text": args.question_text, + "question_timestamp": args.datetime, + "lat": args.lat, + "lon": args.lon, + "timezone": args.timezone, + "ayanamsa": args.ayanamsa, + "node_mode": args.node_mode, + "location_convention": args.location_convention, + }) + except PrashnaContextError as exc: + return {"scope": "prashna_context", "status": "blocked", "reason": str(exc)} + if args.mode != "chart": + return { + "scope": "prashna", + "status": "blocked", + "reason": f"{args.mode} is blocked pending validated Prashna kernel implementation", + "prashna_context": context, + } + return context # ============================================================================ @@ -6176,9 +6170,14 @@ def main(): # 23. prashna (v3.9新增) p = sub.add_parser('prashna', help='Prashna问事占星(提问时刻星盘+Arudha+Sphuta+Sahams)') - p.add_argument('--datetime', required=True, help='提问时间 YYYY-MM-DD HH:MM') + p.add_argument('--datetime', required=True, help='提问时间 ISO-8601,例如 2026-07-12T12:00:00+08:00') + p.add_argument('--question-text', required=True, help='用户原始问事文本') p.add_argument('--lat', type=float, required=True, help='纬度') p.add_argument('--lon', type=float, required=True, help='经度') + p.add_argument('--timezone', required=True, help='UTC offset,例如 8 或 +08:00') + p.add_argument('--ayanamsa', default='lahiri') + p.add_argument('--node-mode', default='mean', choices=['mean', 'true']) + p.add_argument('--location-convention', default='wgs84', choices=['wgs84']) p.add_argument('--mode', default='chart', choices=['chart','arudha','sphutas','sahams','lost-item','life','kunda'], help='分析模式') # 24. double-transit-pac (v3.9新增) diff --git a/scripts/prashna_context.py b/scripts/prashna_context.py new file mode 100644 index 00000000..38a58caf --- /dev/null +++ b/scripts/prashna_context.py @@ -0,0 +1,105 @@ +"""Production Prashna chart context: question moment only, Swiss backend only.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +try: + from scripts.domain_calculation_service import CalculationError, compute_chart +except ModuleNotFoundError: # pragma: no cover - CLI execution path + from domain_calculation_service import CalculationError, compute_chart +try: + from scripts.gulika import calculate_gulika +except ModuleNotFoundError: # pragma: no cover - CLI execution path + from gulika import calculate_gulika +try: + from scripts.prashna_sphuta import calculate_sphuta_evidence +except ModuleNotFoundError: # pragma: no cover - CLI execution path + from prashna_sphuta import calculate_sphuta_evidence + + +class PrashnaContextError(ValueError): + pass + + +def _timezone_offset(value: Any, moment: datetime) -> float: + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + raw = value.strip().upper().replace("UTC", "") + try: + return float(raw) + except ValueError: + pass + if moment.tzinfo is not None: + offset = moment.utcoffset() + if offset is not None: + return offset.total_seconds() / 3600 + raise PrashnaContextError("timezone must be a numeric UTC offset or present in question_timestamp") + + +def build_prashna_context(payload: dict[str, Any]) -> dict[str, Any]: + required = ("question_text", "question_timestamp", "lat", "lon", "timezone") + missing = [field for field in required if payload.get(field) in (None, "")] + if missing: + raise PrashnaContextError(f"missing required Prashna fields: {', '.join(missing)}") + if str(payload.get("location_convention") or "wgs84").lower() != "wgs84": + raise PrashnaContextError("location_convention must be wgs84") + try: + moment = datetime.fromisoformat(str(payload["question_timestamp"]).replace("Z", "+00:00")) + except ValueError as exc: + raise PrashnaContextError("question_timestamp must be ISO-8601") from exc + tz = _timezone_offset(payload["timezone"], moment) + if moment.tzinfo is not None: + timestamp_tz = moment.utcoffset().total_seconds() / 3600 + if abs(timestamp_tz - tz) > 0.001: + raise PrashnaContextError("timezone conflicts with question_timestamp offset") + moment = moment.replace(tzinfo=None) + try: + chart = compute_chart({ + "year": moment.year, "month": moment.month, "day": moment.day, + "hour": moment.hour, "minute": moment.minute, "second": moment.second, + "lat": float(payload["lat"]), "lon": float(payload["lon"]), "tz": tz, + "ayanamsa": str(payload.get("ayanamsa") or "lahiri"), + "node_mode": str(payload.get("node_mode") or "mean"), + }) + except (CalculationError, ValueError, TypeError) as exc: + raise PrashnaContextError(f"Swiss Prashna chart blocked: {exc}") from exc + try: + gulika = calculate_gulika(moment, lat=float(payload["lat"]), lon=float(payload["lon"]), tz=tz) + except Exception as exc: + gulika = { + "status": "blocked", + "reason": f"gulika_supporting_indicator_failed:{type(exc).__name__}", + } + if gulika.get("status") == "partial": + longitudes = { + name: item.get("degree_raw", item.get("lon")) + for name, item in chart["planets"].items() + if isinstance(item, dict) + } + sphuta = calculate_sphuta_evidence( + ascendant_longitude=chart["ascendant"].get("degree_raw", chart["ascendant"].get("lon")), + planet_longitudes=longitudes, + gulika_longitude=gulika["longitude"], + ) + else: + sphuta = {"status": "blocked", "reason": "gulika_supporting_indicator_unavailable"} + return { + "scope": "prashna_context", + "status": "computed", + "question_text": str(payload["question_text"])[:500], + "question_timestamp": str(payload["question_timestamp"]), + "location": {"lat": float(payload["lat"]), "lon": float(payload["lon"]), "timezone": tz, "location_convention": "wgs84"}, + "ayanamsa": str(payload.get("ayanamsa") or "lahiri"), + "node_mode": str(payload.get("node_mode") or "mean"), + "chart_source": "swiss_ephemeris_backend", + "ascendant": chart["ascendant"], + "planets": chart["planets"], + "calculation_contract": chart["calculation_contract"], + "result_hash": chart["result_hash"], + "supporting_indicators": {"gulika": gulika, "sphuta": sphuta}, + "blocked_layers": ["Kunda", "Prashna verdict"], + "boundary": "No client-supplied planets or ascendant are accepted. Gulika and formula-only Sphuta are supporting-only pending external numeric parity; verdict layers remain blocked.", + } diff --git a/scripts/prashna_sphuta.py b/scripts/prashna_sphuta.py new file mode 100644 index 00000000..e7c1f552 --- /dev/null +++ b/scripts/prashna_sphuta.py @@ -0,0 +1,44 @@ +"""Formula-only Prasna Marga Sphuta evidence, without verdict interpretation.""" +from __future__ import annotations + +from typing import Any + + +def _norm(value: float) -> float: + return float(value) % 360.0 + + +def calculate_sphuta_evidence( + *, + ascendant_longitude: float, + planet_longitudes: dict[str, Any], + gulika_longitude: float, +) -> dict[str, Any]: + required = ("Sun", "Moon", "Rahu") + missing = [name for name in required if name not in planet_longitudes] + if missing: + return {"status": "blocked", "reason": "missing_sphuta_planets", "missing": missing} + asc = _norm(ascendant_longitude) + moon = _norm(planet_longitudes["Moon"]) + sun = _norm(planet_longitudes["Sun"]) + rahu = _norm(planet_longitudes["Rahu"]) + gulika = _norm(gulika_longitude) + trisphuta = _norm(asc + moon + gulika) + catusphuta = _norm(trisphuta + sun) + pancasphuta = _norm(catusphuta + rahu) + return { + "scope": "prasna_marga_sphuta_evidence", + "status": "partial", + "points": { + "trisphuta": trisphuta, + "catusphuta": catusphuta, + "pancasphuta": pancasphuta, + }, + "formula_trace": { + "trisphuta": "Lagna + Moon + Gulika", + "catusphuta": "Trisphuta + Sun", + "pancasphuta": "Catusphuta + Rahu", + }, + "rule_source": "references/prashna-complete-guide.md#3.2-3.3", + "boundary": "Formula-only supporting evidence. No health, event, or Prashna verdict is permitted without external numeric parity and adjudication rules.", + } diff --git a/scripts/saham_daynight.py b/scripts/saham_daynight.py new file mode 100644 index 00000000..38ebea39 --- /dev/null +++ b/scripts/saham_daynight.py @@ -0,0 +1,41 @@ +"""Swiss Ephemeris sunrise/sunset evidence for Saham day/night formula selection.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import swisseph as swe + + +class SahamDayNightError(ValueError): + pass + + +def determine_daytime(moment: datetime, *, lat: float, lon: float, tz: float) -> dict[str, Any]: + if not -90 <= float(lat) <= 90 or not -180 <= float(lon) <= 180: + raise SahamDayNightError("invalid WGS84 latitude/longitude") + local = moment.replace(tzinfo=None) + jd = swe.julday(local.year, local.month, local.day, local.hour + local.minute / 60 + local.second / 3600 - float(tz)) + geopos = (float(lon), float(lat), 0.0) + rise_status, rise = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_RISE, geopos) + set_status, sunset = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_SET, geopos) + if rise_status != 0 or set_status != 0: + raise SahamDayNightError("sunrise_or_sunset_unavailable_for_location_date") + sunrise_jd, sunset_jd = rise[0], sunset[0] + # Normalize the next daily events around the queried instant. + while sunrise_jd > jd: + sunrise_jd -= 1.0 + while sunset_jd > jd: + sunset_jd -= 1.0 + is_day = sunrise_jd <= jd < sunset_jd if sunrise_jd < sunset_jd else not (sunset_jd <= jd < sunrise_jd) + return { + "scope": "saham_daynight_swiss", + "status": "computed", + "is_daytime": is_day, + "julian_day_ut": jd, + "sunrise_jd_ut": sunrise_jd, + "sunset_jd_ut": sunset_jd, + "method": "swisseph.rise_trans", + "boundary": "Formula-specific +30 degree exceptions must be applied by the Saham rule layer, not inferred from house placement.", + } diff --git a/scripts/tajika_kernel.py b/scripts/tajika_kernel.py new file mode 100644 index 00000000..14c01431 --- /dev/null +++ b/scripts/tajika_kernel.py @@ -0,0 +1,79 @@ +"""Strict seven-planet Tajika aspect kernel. + +This module deliberately exposes only the auditable interaction layer. Named +Tajika chains remain blocked until their classical definitions have golden +cases; it never treats nodes as Tajika planets. +""" + +from __future__ import annotations + +from typing import Any + + +SEVEN_PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") +DEEPTAMSA = {"Sun": 15.0, "Moon": 12.0, "Mars": 8.0, "Mercury": 7.0, "Jupiter": 9.0, "Venus": 7.0, "Saturn": 9.0} +ASPECT_ANGLES = (0.0, 60.0, 90.0, 120.0, 180.0) + + +def _signed_angle(value: float) -> float: + return (value + 180.0) % 360.0 - 180.0 + + +def _nearest_aspect(delta: float) -> tuple[float, float]: + candidates = [] + for aspect in ASPECT_ANGLES: + for target in ({0.0} if aspect in (0.0, 180.0) else {aspect, -aspect}): + candidates.append((target, _signed_angle(delta - target))) + return min(candidates, key=lambda item: abs(item[1])) + + +def calculate_tajika_interactions(planets: dict[str, dict[str, Any]]) -> dict[str, Any]: + missing = [planet for planet in SEVEN_PLANETS if planet not in planets or "longitude" not in planets[planet] or "speed" not in planets[planet]] + if missing: + return { + "scope": "tajika_seven_planet_kernel", + "status": "blocked", + "reason": "longitude_and_speed_required_for_all_seven_planets", + "missing": missing, + "nodes_excluded": True, + } + interactions = [] + for index, left in enumerate(SEVEN_PLANETS): + for right in SEVEN_PLANETS[index + 1:]: + left_lon, right_lon = float(planets[left]["longitude"]) % 360, float(planets[right]["longitude"]) % 360 + aspect, residual = _nearest_aspect(right_lon - left_lon) + orb = (DEEPTAMSA[left] + DEEPTAMSA[right]) / 2.0 + if abs(residual) > orb: + continue + relative_speed = float(planets[right]["speed"]) - float(planets[left]["speed"]) + future_residual = _signed_angle(residual + relative_speed) + applying = abs(future_residual) < abs(residual) + interactions.append({ + "planets": [left, right], + "aspect": abs(aspect), + "residual": round(residual, 6), + "average_deeptamsa": orb, + "motion": "applying" if applying else "separating", + "within_deeptamsa": True, + }) + return { + "scope": "tajika_seven_planet_kernel", + "status": "partial", + "nodes_excluded": True, + "interactions": interactions, + "candidate_yogas": [ + { + "name": "Ithasala_candidate" if row["motion"] == "applying" else "Easarapha_candidate", + "planets": row["planets"], + "aspect": row["aspect"], + "residual": row["residual"], + "average_deeptamsa": row["average_deeptamsa"], + "motion": row["motion"], + "rule_source": "references/tajika-yoga-complete-guide.md#2.1-2.2", + "status": "partial", + } + for row in interactions + ], + "blocked_named_yogas": ["Nakta", "Yamaya", "Manahoo", "Kamboola", "Ithasala/Easarapha adjudication"], + "boundary": "Candidate labels are derived only from seven-planet aspect, Deeptamsa and applying/separating evidence. Full named-yoga chains and event verdicts remain blocked pending classic golden cases.", + } diff --git a/tests/test_full_reading_saham_contract.py b/tests/test_full_reading_saham_contract.py new file mode 100644 index 00000000..db7fbd57 --- /dev/null +++ b/tests/test_full_reading_saham_contract.py @@ -0,0 +1,15 @@ +from pathlib import Path + + +def test_full_reading_derives_saham_datetime_from_standard_chart_args() -> None: + source = (Path(__file__).resolve().parents[1] / "scripts" / "jyotish_engine.py").read_text(encoding="utf-8") + section = source[source.index("# ── Step 4.8: Tajika Yogas + Sahams"):source.index("# ── Step 4.9:")] + assert "birth_dt = _birth_datetime_from_args(args)" in section + assert "getattr(args, 'birth_datetime'" not in section + + +def test_full_reading_supplies_actual_speeds_to_tajika_kernel() -> None: + source = (Path(__file__).resolve().parents[1] / "scripts" / "jyotish_engine.py").read_text(encoding="utf-8") + section = source[source.index("# ── Step 4.8: Tajika Yogas + Sahams"):source.index("# ── Step 4.9:")] + assert '"longitude": item.get("degree_raw", item.get("lon"))' in section + assert '"speed": item.get("speed")' in section diff --git a/tests/test_gulika.py b/tests/test_gulika.py new file mode 100644 index 00000000..0fa09021 --- /dev/null +++ b/tests/test_gulika.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from scripts.gulika import GHATIKA_END, calculate_gulika + + +def test_gulika_uses_prasna_marga_weekday_table() -> None: + assert GHATIKA_END[6] == {"day": 26, "night": 10} + assert GHATIKA_END[0] == {"day": 22, "night": 6} + + +def test_gulika_returns_sidereal_segment_ascendant_with_audit_trace() -> None: + result = calculate_gulika(datetime(1990, 6, 15, 12, 0), lat=39.9042, lon=116.4074, tz=8) + + assert result["status"] == "partial" + assert 0 <= result["longitude"] < 360 + assert result["ghatika_end"] in range(0, 31) + assert result["rule_source"].endswith("#3.5") diff --git a/tests/test_prashna_context.py b/tests/test_prashna_context.py new file mode 100644 index 00000000..6603e532 --- /dev/null +++ b/tests/test_prashna_context.py @@ -0,0 +1,32 @@ +from datetime import datetime + +import pytest + +from scripts.prashna_context import PrashnaContextError, build_prashna_context + + +def test_prashna_context_uses_backend_chart_from_question_moment(): + packet = build_prashna_context({ + "question_text": "Will this proceed?", + "question_timestamp": "2026-07-12T12:00:00+08:00", + "lat": 39.9042, + "lon": 116.4074, + "timezone": 8, + "ayanamsa": "lahiri", + "node_mode": "mean", + "location_convention": "wgs84", + }) + + assert packet["status"] == "computed" + assert packet["chart_source"] == "swiss_ephemeris_backend" + assert packet["ascendant"]["degree"] >= 0 + assert "Sun" in packet["planets"] + assert "question_timestamp" in packet + + +def test_prashna_context_rejects_missing_time_or_non_wgs84_location(): + base = {"question_text": "x", "lat": 1, "lon": 1, "timezone": 0} + with pytest.raises(PrashnaContextError, match="question_timestamp"): + build_prashna_context(base) + with pytest.raises(PrashnaContextError, match="location_convention"): + build_prashna_context({**base, "question_timestamp": "2026-01-01T00:00:00+00:00", "location_convention": "unknown"}) diff --git a/tests/test_prashna_entry_contract.py b/tests/test_prashna_entry_contract.py new file mode 100644 index 00000000..62185733 --- /dev/null +++ b/tests/test_prashna_entry_contract.py @@ -0,0 +1,62 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_cli_prashna_uses_question_moment_swiss_context_only(): + result = subprocess.run([ + sys.executable, "scripts/jyotish_engine.py", "prashna", + "--datetime", "2026-07-12T12:00:00+08:00", "--question-text", "Test question", + "--lat", "39.9042", "--lon", "116.4074", "--timezone", "8", + ], cwd=ROOT, text=True, capture_output=True, timeout=30, check=True) + payload = json.loads(result.stdout) + + assert payload["status"] == "computed" + assert payload["chart_source"] == "swiss_ephemeris_backend" + assert payload["supporting_indicators"]["gulika"]["status"] == "partial" + assert payload["supporting_indicators"]["sphuta"]["status"] == "partial" + assert "Kunda" in payload["blocked_layers"] + + +def test_cli_prashna_blocks_legacy_approximation_modes(): + result = subprocess.run([ + sys.executable, "scripts/jyotish_engine.py", "prashna", + "--datetime", "2026-07-12T12:00:00+08:00", "--question-text", "Test question", + "--lat", "39.9042", "--lon", "116.4074", "--timezone", "8", "--mode", "sphutas", + ], cwd=ROOT, text=True, capture_output=True, timeout=30, check=True) + payload = json.loads(result.stdout) + + assert payload["status"] == "blocked" + assert "sphutas" in payload["reason"] + + +def test_api_prashna_rejects_client_planets_and_computes_context(): + handler = JyotishAPIHandler.__new__(JyotishAPIHandler) + body = { + "question_text": "Test question", "question_timestamp": "2026-07-12T12:00:00+08:00", + "lat": 39.9042, "lon": 116.4074, "timezone": 8, + "ayanamsa": "lahiri", "node_mode": "mean", "location_convention": "wgs84", + } + result = handler._compute_prashna(body) + assert result["prashna_context"]["chart_source"] == "swiss_ephemeris_backend" + with pytest.raises(BadRequest, match="forbidden"): + handler._compute_prashna({**body, "planets": {"Sun": 0}}) + + +def test_web_prashna_collects_question_context_not_natal_chart(): + source = (ROOT / "jyotish-app" / "main.js").read_text(encoding="utf-8") + markup = (ROOT / "jyotish-app" / "index.html").read_text(encoding="utf-8") + + assert "question_timestamp: timestamp.value" in source + assert "planets: chartData?.planets" not in source + assert "asc_degree: chartData?.ascendant" not in source + for field in ("prashna-timestamp", "prashna-lat", "prashna-lon", "prashna-timezone"): + assert field in markup diff --git a/tests/test_prashna_sphuta.py b/tests/test_prashna_sphuta.py new file mode 100644 index 00000000..469902db --- /dev/null +++ b/tests/test_prashna_sphuta.py @@ -0,0 +1,23 @@ +from scripts.prashna_sphuta import calculate_sphuta_evidence + + +def test_sphuta_formula_evidence_uses_exact_gulika_input() -> None: + result = calculate_sphuta_evidence( + ascendant_longitude=10, + planet_longitudes={"Moon": 20, "Sun": 30, "Rahu": 40}, + gulika_longitude=50, + ) + + assert result["status"] == "partial" + assert result["points"] == {"trisphuta": 80.0, "catusphuta": 110.0, "pancasphuta": 150.0} + + +def test_sphuta_evidence_blocks_missing_required_planet() -> None: + result = calculate_sphuta_evidence( + ascendant_longitude=10, + planet_longitudes={"Moon": 20, "Sun": 30}, + gulika_longitude=50, + ) + + assert result["status"] == "blocked" + assert result["missing"] == ["Rahu"] diff --git a/tests/test_saham_daynight.py b/tests/test_saham_daynight.py new file mode 100644 index 00000000..8ad32924 --- /dev/null +++ b/tests/test_saham_daynight.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from scripts.saham_daynight import determine_daytime + + +def test_swiss_daynight_does_not_use_solar_house_proxy(): + noon = determine_daytime(datetime(2026, 7, 12, 12, 0), lat=39.9042, lon=116.4074, tz=8) + midnight = determine_daytime(datetime(2026, 7, 12, 0, 0), lat=39.9042, lon=116.4074, tz=8) + + assert noon["status"] == "computed" + assert noon["is_daytime"] is True + assert midnight["is_daytime"] is False + assert noon["method"] == "swisseph.rise_trans" diff --git a/tests/test_tajika_kernel.py b/tests/test_tajika_kernel.py new file mode 100644 index 00000000..14e41981 --- /dev/null +++ b/tests/test_tajika_kernel.py @@ -0,0 +1,33 @@ +from scripts.tajika_kernel import calculate_tajika_interactions + + +def _seven(): + return { + "Sun": {"longitude": 0, "speed": 1.0}, "Moon": {"longitude": 49, "speed": 13.0}, + "Mars": {"longitude": 180, "speed": 0.5}, "Mercury": {"longitude": 260, "speed": 1.2}, + "Jupiter": {"longitude": 310, "speed": 0.08}, "Venus": {"longitude": 130, "speed": 1.0}, + "Saturn": {"longitude": 220, "speed": 0.03}, "Rahu": {"longitude": 60, "speed": -0.05}, + } + + +def test_kernel_detects_cross_sign_aspect_and_excludes_nodes(): + result = calculate_tajika_interactions(_seven()) + + pair = next(row for row in result["interactions"] if row["planets"] == ["Sun", "Moon"]) + assert pair["aspect"] == 60.0 + assert pair["motion"] == "applying" + candidate = next(row for row in result["candidate_yogas"] if row["planets"] == ["Sun", "Moon"]) + assert candidate["name"] == "Ithasala_candidate" + assert candidate["status"] == "partial" + assert result["nodes_excluded"] is True + assert all("Rahu" not in row["planets"] for row in result["interactions"]) + + +def test_kernel_blocks_missing_speed_instead_of_guessing_motion(): + planets = _seven() + del planets["Venus"]["speed"] + + result = calculate_tajika_interactions(planets) + + assert result["status"] == "blocked" + assert "Venus" in result["missing"] From dee14b3c2a06003eac4640fee2b940ef79effb87 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 22:10:55 +0800 Subject: [PATCH 22/30] Add candidate time sensitivity scan --- scripts/candidate_time_sensitivity_scan.py | 111 ++++++++++++++++++ tests/test_candidate_time_sensitivity_scan.py | 27 +++++ 2 files changed, 138 insertions(+) create mode 100644 scripts/candidate_time_sensitivity_scan.py create mode 100644 tests/test_candidate_time_sensitivity_scan.py diff --git a/scripts/candidate_time_sensitivity_scan.py b/scripts/candidate_time_sensitivity_scan.py new file mode 100644 index 00000000..295ed4b0 --- /dev/null +++ b/scripts/candidate_time_sensitivity_scan.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Scan actual local-chart differences across a birth-time candidate range.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from collections import Counter +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +ENGINE = ROOT / "scripts" / "jyotish_engine.py" +_VARGAS = ("D4", "D9", "D10", "D24", "D30") + + +def _engine_json(command: str, payload: dict[str, Any], *, timeout: int = 20) -> dict[str, Any]: + args = ["python3", str(ENGINE), command] + for key in ("year", "month", "day", "hour", "minute", "lat", "lon", "tz"): + args.extend([f"--{key}", str(payload[key])]) + if command == "varga-full": + args.extend(["--divisions", ",".join(_VARGAS)]) + completed = subprocess.run(args, cwd=ROOT, capture_output=True, text=True, timeout=timeout, check=True) + return json.loads(completed.stdout) + + +def _all_varga_ascendants(payload: dict[str, Any]) -> dict[str, str | None]: + values = {varga: None for varga in _VARGAS} + try: + raw = _engine_json("varga-full", payload) + except subprocess.CalledProcessError: + return values + for name, chart in raw.items(): + if not isinstance(chart, dict): + continue + for varga in _VARGAS: + if name.startswith(varga + "_"): + values[varga] = (chart.get("Ascendant") or {}).get("sign") + return values + + +def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = 30, step_minutes: int = 1) -> dict[str, Any]: + required = ("year", "month", "day", "hour", "minute", "lat", "lon", "tz") + missing = [key for key in required if payload.get(key) is None] + if missing: + raise ValueError(f"missing candidate scan fields: {', '.join(missing)}") + center = datetime(int(payload["year"]), int(payload["month"]), int(payload["day"]), int(payload["hour"]), int(payload["minute"])) + step_minutes = max(int(step_minutes), 1) + uncertainty_minutes = max(int(uncertainty_minutes), 1) + rows: list[dict[str, Any]] = [] + for offset in range(-uncertainty_minutes, uncertainty_minutes + 1, step_minutes): + moment = center + timedelta(minutes=offset) + point = {**payload, "year": moment.year, "month": moment.month, "day": moment.day, "hour": moment.hour, "minute": moment.minute} + chart = _engine_json("chart", point) + asc = chart.get("ascendant", {}) + divisional = _all_varga_ascendants(point) + rows.append({ + "time": moment.strftime("%Y-%m-%d %H:%M"), + "offset_minutes": offset, + "d1_ascendant": asc.get("sign"), + "d1_degree_in_sign": asc.get("degree_in_sign"), + "divisional_ascendants": divisional, + }) + signatures = [tuple([row["d1_ascendant"], *row["divisional_ascendants"].values()]) for row in rows] + unavailable_vargas = [varga.upper() for varga in _VARGAS if all(row["divisional_ascendants"][varga.upper()] is None for row in rows)] + supported_vargas = [varga.lower() for varga in _VARGAS if varga.upper() not in unavailable_vargas] + modal = Counter(signatures).most_common(1)[0][0] + for row, signature in zip(rows, signatures): + row["sensitivity_count"] = sum(left != right for left, right in zip(signature, modal)) + row["sensitive_layers"] = [ + name for name, current, typical in zip(("D1", "D4", "D9", "D10", "D24", "D30"), signature, modal) + if current != typical + ] + transitions = [] + for previous, current in zip(rows, rows[1:]): + changed = [name for name in ("d1_ascendant", "divisional_ascendants") if previous[name] != current[name]] + if changed: + transitions.append({"between": [previous["time"], current["time"]], "changed": changed}) + return { + "scope": "candidate_time_sensitivity_scan", + "status": "local_computed", + "engine": "local_jyotish_engine", + "candidate_count": len(rows), + "center_time": center.strftime("%Y-%m-%d %H:%M"), + "uncertainty_minutes": uncertainty_minutes, + "step_minutes": step_minutes, + "rows": rows, + "transitions": transitions, + "supported_vargas": [varga.upper() for varga in supported_vargas], + "unavailable_vargas": unavailable_vargas, + "pending_layers": ["UL", "A7", "A10", "KP_cusp"], + "boundary": "Actual local D1/Varga differences only. Unsupported Varga CLI flags are explicitly unavailable. Event answers still require an explicit event-to-candidate adjudication model before minute-level rectification.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + for field, cast in (("year", int), ("month", int), ("day", int), ("hour", int), ("minute", int), ("lat", float), ("lon", float), ("tz", float)): + parser.add_argument(f"--{field}", required=True, type=cast) + parser.add_argument("--uncertainty-minutes", type=int, default=30) + parser.add_argument("--step-minutes", type=int, default=1) + args = parser.parse_args() + print(json.dumps(scan_candidate_times(vars(args), uncertainty_minutes=args.uncertainty_minutes, step_minutes=args.step_minutes), ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_candidate_time_sensitivity_scan.py b/tests/test_candidate_time_sensitivity_scan.py new file mode 100644 index 00000000..da8a0476 --- /dev/null +++ b/tests/test_candidate_time_sensitivity_scan.py @@ -0,0 +1,27 @@ +from scripts import candidate_time_sensitivity_scan as scanner + + +def test_scanner_reports_real_divisional_transitions(monkeypatch): + def fake_engine(command, payload, timeout=20): + minute = payload["minute"] + if command == "chart": + return {"ascendant": {"sign": "Leo", "degree_in_sign": 10 + minute / 100}} + ascendant = "Aries" if minute % 2 else "Taurus" + return { + "D4_Turyamsa": {"Ascendant": {"sign": ascendant}}, + "D9_Navamsa": {"Ascendant": {"sign": ascendant}}, + "D10_Dasamsa": {"Ascendant": {"sign": ascendant}}, + "D24_Siddhamsa": {"Ascendant": {"sign": ascendant}}, + "D30_Trimsamsa": {"Ascendant": {"sign": ascendant}}, + } + + monkeypatch.setattr(scanner, "_engine_json", fake_engine) + report = scanner.scan_candidate_times( + {"year": 2000, "month": 1, "day": 1, "hour": 12, "minute": 1, "lat": 1, "lon": 1, "tz": 0}, + uncertainty_minutes=1, + ) + + assert report["candidate_count"] == 3 + assert report["transitions"] + assert report["pending_layers"] == ["UL", "A7", "A10", "KP_cusp"] + assert report["rows"][0]["divisional_ascendants"]["D9"] in {"Aries", "Taurus"} From ed502e3ab2e1f33076294cd45e510400b82c45b9 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 22:36:20 +0800 Subject: [PATCH 23/30] Add career VedAstro radar --- scripts/career_vedastro_radar.py | 78 +++++++++++++++++++++++++++++ tests/test_career_vedastro_radar.py | 54 ++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 scripts/career_vedastro_radar.py create mode 100644 tests/test_career_vedastro_radar.py diff --git a/scripts/career_vedastro_radar.py b/scripts/career_vedastro_radar.py new file mode 100644 index 00000000..bdc4d770 --- /dev/null +++ b/scripts/career_vedastro_radar.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""VedAstro-assisted career timing radar. + +External VedAstro signals are secondary evidence only. They do not change +local scores, dominant labels, or final career/prashna adjudication by +themselves. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import vedastro_service_adapter + + +def build_career_radar_packet(case: dict[str, Any], *, start_date: str, end_date: str, case_id: str = "user_chart") -> dict[str, Any]: + result = vedastro_service_adapter.run_range_scan_for_case( + case, + "career", + start_date, + end_date, + case_id=case_id, + ) + policy = result.get("adjudicator_policy") if isinstance(result.get("adjudicator_policy"), dict) else {} + can_change_score = bool(policy.get("can_change_score", False)) + status = result.get("status", "blocked") + return { + "scope": "career_vedastro_radar", + "status": "ok" if status == "ok" else "blocked", + "blocked_reason": None if status == "ok" else result.get("reason") or status, + "domain": "career", + "adjudicator_use": "secondary_evidence_only", + "can_change_score": can_change_score, + "can_set_final_verdict": False, + "start_date": start_date, + "end_date": end_date, + "vedastro_range_scan_result": result, + "technique_audit_row": { + "technique": "VedAstro Career Range Scan", + "used": status == "ok", + "status": status, + "role": "external_secondary_evidence", + "confidence_effect": "raises_attention_only_not_final_score" if status == "ok" else "blocked_no_effect", + }, + } + + +def _load_case(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case-json", type=Path, required=True) + parser.add_argument("--start-date", required=True) + parser.add_argument("--end-date", required=True) + parser.add_argument("--case-id", default="user_chart") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + packet = build_career_radar_packet( + _load_case(args.case_json), + start_date=args.start_date, + end_date=args.end_date, + case_id=args.case_id, + ) + text = json.dumps(packet, ensure_ascii=False, indent=2, sort_keys=True) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text + "\n", encoding="utf-8") + print(text) + return 0 if packet["status"] == "ok" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_career_vedastro_radar.py b/tests/test_career_vedastro_radar.py new file mode 100644 index 00000000..61a9ae2d --- /dev/null +++ b/tests/test_career_vedastro_radar.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +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 career_vedastro_radar # noqa: E402 + + +def test_career_radar_wraps_vedastro_range_scan_as_secondary_evidence(monkeypatch) -> None: + def fake_run(case, domain, start_date, end_date, case_id="user_chart"): + return { + "status": "ok", + "domain": domain, + "operation": "range_scan", + "evidence_ledger": [{"event_id": "CareerExpansionWindow", "date": "2026-09-01"}], + "adjudicator_policy": {"can_change_score": False}, + } + + monkeypatch.setattr(career_vedastro_radar.vedastro_service_adapter, "run_range_scan_for_case", fake_run) + + packet = career_vedastro_radar.build_career_radar_packet( + {"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 36.4, "lon": 114.2, "tz": 8}, + start_date="2026-07-16", + end_date="2026-12-31", + ) + + assert packet["status"] == "ok" + assert packet["domain"] == "career" + assert packet["adjudicator_use"] == "secondary_evidence_only" + assert packet["can_change_score"] is False + assert packet["vedastro_range_scan_result"]["evidence_ledger"][0]["event_id"] == "CareerExpansionWindow" + + +def test_career_radar_preserves_blocked_boundary(monkeypatch) -> None: + def fake_run(case, domain, start_date, end_date, case_id="user_chart"): + return {"status": "blocked", "reason": "official_endpoint_not_configured", "evidence_ledger": []} + + monkeypatch.setattr(career_vedastro_radar.vedastro_service_adapter, "run_range_scan_for_case", fake_run) + + packet = career_vedastro_radar.build_career_radar_packet( + {"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 36.4, "lon": 114.2, "tz": 8}, + start_date="2026-07-16", + end_date="2026-12-31", + ) + + assert packet["status"] == "blocked" + assert packet["can_change_score"] is False + assert packet["blocked_reason"] == "official_endpoint_not_configured" From 20fe32fc531c3b1b4d3efa283fa43997b95076e4 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 22:41:10 +0800 Subject: [PATCH 24/30] Add strict evidence service boundary --- .../interpretation_source_inventory_gate.py | 4 +-- scripts/strict_evidence_service.py | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 scripts/strict_evidence_service.py diff --git a/scripts/interpretation_source_inventory_gate.py b/scripts/interpretation_source_inventory_gate.py index 4f27209d..aa24a9ec 100644 --- a/scripts/interpretation_source_inventory_gate.py +++ b/scripts/interpretation_source_inventory_gate.py @@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from mcp_server import _existing_interpretation_source_pack # noqa: E402 +from scripts.strict_evidence_service import existing_interpretation_source_pack # noqa: E402 REQUIRED_LAYERS = [ @@ -100,7 +100,7 @@ CANDIDATE_KEYWORDS = [ def build_report() -> dict[str, Any]: - source_pack = _existing_interpretation_source_pack() + source_pack = existing_interpretation_source_pack() inventory = source_pack.get("interpretation_source_inventory") if isinstance(source_pack, dict) else {} if not isinstance(inventory, dict): inventory = {} diff --git a/scripts/strict_evidence_service.py b/scripts/strict_evidence_service.py new file mode 100644 index 00000000..86a1b90d --- /dev/null +++ b/scripts/strict_evidence_service.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Stable strict-evidence service boundary. + +This module is the import target for engine/API code. The current implementation +delegates to the legacy MCP implementation while the large helper stack is being +extracted out of `mcp_server.py`. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def collect_strict_evidence(route: str, result: dict[str, Any]) -> dict[str, Any]: + from mcp_server import _collect_strict_evidence + + return _collect_strict_evidence(route, result) + + +def existing_interpretation_source_pack() -> dict[str, Any]: + from mcp_server import _existing_interpretation_source_pack + + return _existing_interpretation_source_pack() From a7fdfcb033554164aac27179c8abc6cf57302f17 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 22:51:58 +0800 Subject: [PATCH 25/30] Unify CLI chart calculation contract --- scripts/jyotish_engine.py | 24 +++-- tests/test_calculation_p0_regressions.py | 117 +++++++++++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 tests/test_calculation_p0_regressions.py diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 6b94f848..f745c2f2 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -735,12 +735,24 @@ def _birth_datetime_from_args(args): def _compute_chart_from_args(args): - return compute_chart_data( - args.year, args.month, args.day, args.hour, args.minute, - args.lat, args.lon, args.tz, getattr(args, 'node_mode', 'mean'), - second=_arg_second(args), - ayanamsa_name=_current_ayanamsa_name(args), - ) + from domain_calculation_service import compute_chart + + result = compute_chart({ + 'year': args.year, + 'month': args.month, + 'day': args.day, + 'hour': args.hour, + 'minute': args.minute, + 'second': _arg_second(args), + 'lat': args.lat, + 'lon': args.lon, + 'tz': args.tz, + 'node_mode': getattr(args, 'node_mode', 'mean'), + 'ayanamsa': _current_ayanamsa_name(args), + }) + asc_idx = SIGNS.index(result['ascendant']['sign']) + birth = result['birth_info'] + return result, asc_idx, birth['julian_day'], birth['ayanamsa'] def _current_ayanamsa_name(args=None): diff --git a/tests/test_calculation_p0_regressions.py b/tests/test_calculation_p0_regressions.py new file mode 100644 index 00000000..05338e08 --- /dev/null +++ b/tests/test_calculation_p0_regressions.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import domain_calculation_service as calculation_service # noqa: E402 +import jyotish_api_server # noqa: E402 +from jyotish_api_server import JyotishAPIHandler # noqa: E402 +from jyotish_engine import _compute_chart_from_args # noqa: E402 + +BIRTH = { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 28.6139, + "lon": 77.2090, + "tz": 5.5, + "ayanamsa": "lahiri", +} + + +def test_true_node_changes_effective_rahu_and_contract() -> None: + mean = calculation_service.compute_chart({**BIRTH, "node_mode": "mean"}) + true = calculation_service.compute_chart({**BIRTH, "node_mode": "true"}) + + assert mean["planets"]["Rahu"]["lon"] != pytest.approx( + true["planets"]["Rahu"]["lon"], abs=1e-8 + ) + assert mean["calculation_contract"]["effective"]["node_mode"] == "mean" + assert true["calculation_contract"]["effective"]["node_mode"] == "true" + assert mean["result_hash"] != true["result_hash"] + + +def test_vimshottari_uses_birth_balance_as_canonical_timeline() -> None: + birth_dt = datetime(1990, 1, 1, 12, 0) + result = calculation_service.compute_vimshottari_timeline( + birth_dt=birth_dt, + moon_lon=100.0, + current_date=birth_dt, + ) + + first = result["periods"][0] + assert first["lord"] == "Saturn" + assert first["start"] == "1980-07-02" + assert first["end"] == "1999-07-02" + assert result["birth_balance"]["remaining_years"] == pytest.approx(9.5) + assert result["calculation_contract"]["algorithm"] == "vimshottari_birth_balance" + + +def test_sade_sati_uses_real_saturn_transit_for_reference_date() -> None: + result = calculation_service.compute_sade_sati( + moon_degree=300.0, + asc_degree=330.0, + reference_date="2026-07-11", + tz=5.5, + ayanamsa="lahiri", + ) + oracle = calculation_service.compute_transit_longitude( + planet="Saturn", + reference_date="2026-07-11", + tz=5.5, + ayanamsa="lahiri", + ) + + assert result["transit_saturn_lon"] == pytest.approx(oracle["longitude"], abs=1e-8) + assert result["provenance"]["data_layer"] == "true_transit_positions" + assert result["provenance"]["reference_date"] == "2026-07-11" + + +def test_timezone_inference_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + calculation_service, + "_lookup_timezone_name", + lambda _lat, _lon: None, + ) + + with pytest.raises(calculation_service.TimezoneInferenceError, match="timezone inference"): + calculation_service.infer_timezone_offset( + lat=0.0, + lon=0.0, + local_datetime=datetime(1990, 1, 1, 12, 0), + ) + + +def test_chart_hash_matches_domain_cli_and_rest( + monkeypatch: pytest.MonkeyPatch, +) -> None: + 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 = calculation_service.compute_chart({**BIRTH, "node_mode": "true"}) + cli, _asc_idx, _jd, _ayanamsa = _compute_chart_from_args( + SimpleNamespace(**BIRTH, node_mode="true") + ) + rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync( + {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"} + ) + + assert cli["result_hash"] == expected["result_hash"] + assert rest["result_hash"] == expected["result_hash"] + assert rest["birth"]["node_mode"] == "true" + assert rest["calculation_contract"]["effective"]["node_mode"] == "true" From e013fed79c51939ff7799fba03f646fe51eb95f1 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 23:40:01 +0800 Subject: [PATCH 26/30] Add API security and async job contracts --- jyotish-app/ai-chat.js | 1 - jyotish-app/api-bridge.js | 18 ++ jyotish-app/auth.js | 3 +- scripts/jyotish_api_server.py | 249 +++++++++++++++++++++++++-- scripts/report_builder.py | 8 + tests/test_api_async_job_contract.py | 105 +++++++++++ tests/test_runtime_security_p0.py | 156 +++++++++++++++++ web/evidence_packet.html | 26 +++ web/index.html | 9 + web/rectification.html | 15 ++ 10 files changed, 573 insertions(+), 17 deletions(-) create mode 100644 tests/test_api_async_job_contract.py create mode 100644 tests/test_runtime_security_p0.py create mode 100644 web/evidence_packet.html create mode 100644 web/index.html create mode 100644 web/rectification.html diff --git a/jyotish-app/ai-chat.js b/jyotish-app/ai-chat.js index a15533e4..11aaa57a 100644 --- a/jyotish-app/ai-chat.js +++ b/jyotish-app/ai-chat.js @@ -444,7 +444,6 @@ function buildAISetupGuidance() { function getApiBase() { if (window.JYOTISH_API_BASE) return window.JYOTISH_API_BASE; if (import.meta.env?.VITE_JYOTISH_API_BASE) return import.meta.env.VITE_JYOTISH_API_BASE; - if (window.Capacitor?.isNativePlatform?.()) return localStorage.getItem('jyotish_api_base') || ''; return ''; // 同域部署 } diff --git a/jyotish-app/api-bridge.js b/jyotish-app/api-bridge.js index e013df03..2df6e136 100644 --- a/jyotish-app/api-bridge.js +++ b/jyotish-app/api-bridge.js @@ -47,6 +47,7 @@ async function postJson(path, payload, { requireModernChart = false } = {}) { continue; } activeApiBase = base; + if (data?.mode === 'async_submitted') return pollAsyncJob(data, { base }); return data; } catch (error) { lastAttempt = `${base}${path}`; @@ -60,6 +61,22 @@ async function postJson(path, payload, { requireModernChart = false } = {}) { throw lastError || new Error(buildAPIRecoveryMessage(path, '本地 API 未连接', lastAttempt)); } +async function pollAsyncJob(job, { base = activeApiBase, timeoutMs = 120000, intervalMs = 500 } = {}) { + if (!job?.poll_path || !job?.access_token) throw new Error('Async job response missing poll capability'); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resp = await fetch(`${base}${job.poll_path}`, { + headers: { Authorization: `Bearer ${job.access_token}` }, + }); + const data = await parseApiResponse(resp); + if (!resp.ok) throw new Error(buildAPIRecoveryMessage(job.poll_path, data?.error || `Job poll failed (${resp.status})`)); + if (data.status === 'completed') return data.result || data; + if (data.status === 'failed') throw new Error(data.error || 'Async job failed'); + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + throw new Error('Async job timed out'); +} + async function fetchJson(path) { let lastError = null; let lastAttempt = null; @@ -511,6 +528,7 @@ window.JyotishAPI = { computeKakshya, computeBhavaBala, computeTransitTriggers, + pollAsyncJob, // AI 解读 aiReading, aiFullReading, diff --git a/jyotish-app/auth.js b/jyotish-app/auth.js index d061ddfd..349c19dd 100644 --- a/jyotish-app/auth.js +++ b/jyotish-app/auth.js @@ -12,7 +12,6 @@ import { escapeAttr, escapeHtml } from './security.js'; const API_BASE = ''; // 同域部署,留空;Capacitor 打包时改为服务器地址 const TOKEN_KEY = 'jyotish_auth_token'; const USER_KEY = 'jyotish_auth_user'; -const API_BASE_KEY = 'jyotish_api_base'; // ============================================================================ // 状态 @@ -61,7 +60,7 @@ export function getUser() { return _user; } export function isLoggedIn() { return !!_token && !!_user; } export function getApiBase() { - return window.JYOTISH_API_BASE || import.meta.env?.VITE_JYOTISH_API_BASE || localStorage.getItem(API_BASE_KEY) || API_BASE; + return window.JYOTISH_API_BASE || import.meta.env?.VITE_JYOTISH_API_BASE || API_BASE; } export function onAuthChange(cb) { _onAuthChange = cb; } diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index ea4e56d2..6fc58409 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -15,10 +15,13 @@ import json, sys, os, math import importlib.util import hashlib import re +import sqlite3 +import secrets import threading import time +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta -from http.server import HTTPServer, BaseHTTPRequestHandler +from http.server import HTTPServer, BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse @@ -57,6 +60,75 @@ _LOCAL_MODULE_CACHE = {} _API_CHART_CACHE_SCOPE = 'api_chart_response' _HIGH_RIGOR_JOB_SCOPE = 'high_rigor_workflow' _UNIFIED_CONSULTATION_ORCHESTRATOR = UnifiedConsultationOrchestrator() +_ASYNC_JOB_WORKERS = max(int(os.environ.get('JYOTISH_ASYNC_JOB_WORKERS', '2')), 1) +_ASYNC_JOB_QUEUE_SIZE = max(int(os.environ.get('JYOTISH_ASYNC_JOB_QUEUE_SIZE', '8')), 0) +_ASYNC_JOB_EXECUTOR = ThreadPoolExecutor( + max_workers=_ASYNC_JOB_WORKERS, + thread_name_prefix='jyotish-job', +) +_ASYNC_JOB_CAPACITY = threading.BoundedSemaphore(_ASYNC_JOB_WORKERS + _ASYNC_JOB_QUEUE_SIZE) +_RATE_LIMIT_LOCK = threading.Lock() +_RATE_LIMIT_BUCKETS: dict[str, tuple[float, int]] = {} + + +def summarize_execution_status(result: dict | None) -> dict: + result = result if isinstance(result, dict) else {} + fallback = str(result.get('fallback_reason') or '') + official = 'official_blocked' if 'VedAstro official snapshot blocked' in fallback else result.get('official_evidence_status', 'unknown') + return { + 'official_evidence_status': official, + 'fallback_reason': result.get('fallback_reason'), + } + + +def build_evidence_packet_view(job_record: dict | None) -> dict: + """Public, token-protected job view. Excludes prompt internals and raw input.""" + job_record = job_record or {} + result = job_record.get('result') + result = result if isinstance(result, dict) else {} + return { + 'scope': 'evidence_packet_view', + 'job_id': job_record.get('job_id'), + 'status': job_record.get('status', 'unknown'), + 'execution_status': summarize_execution_status(result), + 'machine_evidence_packet': result.get('machine_evidence_packet') or {}, + 'technique_audit': result.get('technique_audit') or result.get('technique_audit_table') or [], + 'warnings': result.get('warnings') or [], + } + + +def _submit_background_job(callback): + if not _ASYNC_JOB_CAPACITY.acquire(blocking=False): + raise JobQueueFull('Async job queue is full') + try: + future = _ASYNC_JOB_EXECUTOR.submit(callback) + except Exception: + _ASYNC_JOB_CAPACITY.release() + raise + future.add_done_callback(lambda _future: _ASYNC_JOB_CAPACITY.release()) + return future + + +def _rate_limit_per_minute() -> int: + raw = str(os.environ.get('JYOTISH_API_RATE_LIMIT_PER_MINUTE', '120')).strip() + try: + return max(int(raw), 0) + except ValueError: + return 120 + + +def enforce_rate_limit(client_id: str, *, now: float | None = None) -> None: + limit = _rate_limit_per_minute() + if limit == 0: + return + now = time.time() if now is None else now + with _RATE_LIMIT_LOCK: + window, count = _RATE_LIMIT_BUCKETS.get(client_id, (now, 0)) + if now - window >= 60: + window, count = now, 0 + if count >= limit: + raise RateLimited('Rate limit exceeded') + _RATE_LIMIT_BUCKETS[client_id] = (window, count + 1) def _western_evidence_packet_from_body( @@ -752,29 +824,140 @@ def _async_job_path(scope: str, job_id: str) -> Path: return _async_job_dir(scope) / f'{job_id}.json' -def _load_high_rigor_job_record(job_id: str) -> dict | None: - return _load_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id) +def _async_job_ttl_seconds() -> float: + raw = str(os.environ.get('JYOTISH_ASYNC_JOB_TTL_SECONDS', '3600')).strip() + try: + return max(float(raw), 1.0) + except ValueError: + return 3600.0 + + +def _async_job_backend() -> str: + return "sqlite" if os.environ.get("JYOTISH_ASYNC_JOB_BACKEND", "file").strip().lower() == "sqlite" else "file" + + +def _sqlite_job_db_path() -> Path: + return Path(REPO_ROOT) / "scratch" / "local" / "async_jobs.sqlite3" + + +def _sqlite_job_connection() -> sqlite3.Connection: + path = _sqlite_job_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(path, timeout=10) + connection.execute( + "CREATE TABLE IF NOT EXISTS async_jobs (scope TEXT NOT NULL, job_id TEXT NOT NULL, expires_at REAL, payload TEXT NOT NULL, PRIMARY KEY (scope, job_id))" + ) + try: + os.chmod(path, 0o600) + except OSError: + pass + return connection + + +def prune_expired_async_jobs() -> dict: + """Best-effort startup cleanup for local job records; never reads payloads.""" + removed = 0 + scanned = 0 + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + scanned = connection.execute("SELECT COUNT(*) FROM async_jobs").fetchone()[0] + removed = connection.execute( + "DELETE FROM async_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?", (time.time(),) + ).rowcount + return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed} + for scope in (_HIGH_RIGOR_JOB_SCOPE, _API_CHART_CACHE_SCOPE): + directory = _async_job_dir(scope) + if not directory.is_dir(): + continue + for path in directory.glob('*.json'): + scanned += 1 + try: + record = json.loads(path.read_text(encoding='utf-8')) + expires_at = record.get('expires_at_unix') if isinstance(record, dict) else None + if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at): + path.unlink() + removed += 1 + except (OSError, json.JSONDecodeError): + continue + return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed} + + +def _new_async_job_identity(prefix: str) -> dict: + return { + 'job_id': f'{prefix}_{secrets.token_hex(16)}', + 'access_token': secrets.token_urlsafe(32), + } + + +def _access_token_hash(token: str) -> str: + return hashlib.sha256(token.encode('utf-8')).hexdigest() + + +def _load_high_rigor_job_record(job_id: str, *, access_token: str = '') -> dict | None: + return _load_async_job_record( + _HIGH_RIGOR_JOB_SCOPE, + job_id, + access_token=access_token, + ) def _write_high_rigor_job_record(job_id: str, payload: dict) -> dict: return _write_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id, payload) -def _load_async_job_record(scope: str, job_id: str) -> dict | None: - path = _async_job_path(scope, job_id) - if not path.exists(): - return None - try: - return json.loads(path.read_text(encoding='utf-8')) - except (OSError, json.JSONDecodeError): +def _load_async_job_record(scope: str, job_id: str, *, access_token: str = '') -> dict | None: + path = None + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + row = connection.execute( + "SELECT payload FROM async_jobs WHERE scope = ? AND job_id = ?", (scope, job_id) + ).fetchone() + if row is None: + return None + try: + record = json.loads(row[0]) + except json.JSONDecodeError: + return None + else: + path = _async_job_path(scope, job_id) + if not path.exists(): + return None + try: + record = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return None + expires_at = record.get('expires_at_unix') + if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at): + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + connection.execute("DELETE FROM async_jobs WHERE scope = ? AND job_id = ?", (scope, job_id)) + elif path is not None: + try: + path.unlink() + except OSError: + pass return None + expected = record.get('access_token_hash') + if not isinstance(expected, str) or not access_token: + raise JobAccessDenied('Async job access token required') + if not secrets.compare_digest(expected, _access_token_hash(access_token)): + raise JobAccessDenied('Async job access token invalid') + return record def _write_async_job_record(scope: str, job_id: str, payload: dict) -> dict: - _async_job_path(scope, job_id).write_text( - json.dumps(payload, ensure_ascii=False, sort_keys=True), - encoding='utf-8', - ) + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + connection.execute( + "INSERT OR REPLACE INTO async_jobs (scope, job_id, expires_at, payload) VALUES (?, ?, ?, ?)", + (scope, job_id, payload.get("expires_at_unix"), json.dumps(payload, ensure_ascii=False, sort_keys=True)), + ) + return payload + path = _async_job_path(scope, job_id) + temp_path = path.with_suffix(f'.{secrets.token_hex(8)}.tmp') + temp_path.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True), encoding='utf-8') + os.chmod(temp_path, 0o600) + os.replace(temp_path, path) return payload @@ -1098,6 +1281,26 @@ class BadRequest(ValueError): """Client-side request validation failed.""" +class Forbidden(PermissionError): + """Request failed the local API trust boundary.""" + + +class UnsupportedMediaType(ValueError): + """Request body media type is not supported.""" + + +class JobAccessDenied(PermissionError): + """Async job capability token is missing or invalid.""" + + +class JobQueueFull(RuntimeError): + """Bounded async worker queue has no remaining capacity.""" + + +class RateLimited(RuntimeError): + """Client exceeded the local fixed-window request budget.""" + + class JyotishAPIHandler(BaseHTTPRequestHandler): server_version = 'JyotishAPI/6.9.14' @@ -1121,6 +1324,19 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): if origin in allowed: self.send_header('Access-Control-Allow-Origin', origin) + def _enforce_request_security(self, *, require_json=False): + origin = self.headers.get('Origin') + allowed = getattr(self.server, 'allowed_origins', DEFAULT_ALLOWED_ORIGINS) + if origin and origin not in allowed: + raise Forbidden('Origin is not allowed') + host = (self.headers.get('Host') or '').split(':', 1)[0].strip('[]').lower() + if host and host not in {'localhost', '127.0.0.1', '::1'}: + raise Forbidden('Host is not allowed') + if require_json: + content_type = (self.headers.get('Content-Type') or '').split(';', 1)[0].strip().lower() + if content_type != 'application/json': + raise UnsupportedMediaType('Content-Type must be application/json') + def _vedastro_status(self): adapter = _load_local_module('vedastro_service_adapter') endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip() @@ -1234,6 +1450,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): def do_POST(self): path = urlparse(self.path).path try: + self._enforce_request_security(require_json=True) body = self._read_json_body() if path == '/api/chart': result = self._compute_chart(body) @@ -1378,6 +1595,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): self._error_json(f'Unknown endpoint: {path}', 404, 'ERR_NOT_FOUND') except BadRequest as e: self._error_json(str(e), 400, 'ERR_BAD_REQUEST') + except Forbidden as e: + self._error_json(str(e), 403, 'ERR_FORBIDDEN') + except UnsupportedMediaType as e: + self._error_json(str(e), 415, 'ERR_UNSUPPORTED_MEDIA_TYPE') except Exception: import logging logging.exception("[api_server] request failed for %s", path) diff --git a/scripts/report_builder.py b/scripts/report_builder.py index bc0c22ea..bf3eb519 100644 --- a/scripts/report_builder.py +++ b/scripts/report_builder.py @@ -29,6 +29,7 @@ import sys import re import glob import argparse +from urllib.parse import urlparse try: import markdown @@ -323,6 +324,13 @@ def build_cover(name, lagna, gender, status, pkg, desc, lang="cn"):仅显示已完成任务的可审计计算状态、证据包和技法审计。不会展示内部提示词或原始出生输入。
+ +-
-
-
先确认出生资料,再选择直接排盘或主动问询式生时校正。外部引擎状态将在证据包中明示。
+可使用本地城市库;未收录时请手填经纬度。此操作不调用第三方地理服务。
等待检查
先扫描候选时间,再回答选择题。结果只缩小候选簇,不宣称已经精确到分钟。
+report
', + encoding="utf-8", + ) + report_url = html.as_uri() + blocked: list[str] = [] + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + context = browser.new_context(java_script_enabled=False) + page = context.new_page() + page.route( + "**/*", + lambda route: route.continue_() + if is_allowed_report_resource_url(route.request.url, report_url=report_url) + else (blocked.append(route.request.url), route.abort())[1], + ) + page.goto(report_url, wait_until="networkidle") + context.close() + browser.close() + except Exception as exc: # Browser binary/startup is an environment boundary. + return {"scope": "report_renderer_isolation_poc", "status": "blocked", "reason": f"chromium_unavailable:{type(exc).__name__}"} + return { + "scope": "report_renderer_isolation_poc", + "status": "pass" if _ProbeHandler.requests == 0 and len(blocked) >= 2 else "fail", + "http_probe_requests": _ProbeHandler.requests, + "blocked_resource_count": len(blocked), + "blocked_schemes": sorted({url.split(":", 1)[0] for url in blocked}), + } + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--strict", action="store_true", help="Return nonzero unless the isolation probe passes.") + args = parser.parse_args() + result = run_poc() + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + raise SystemExit(0 if not args.strict or result["status"] == "pass" else 1) diff --git a/tests/test_configure_vedastro_secret.py b/tests/test_configure_vedastro_secret.py new file mode 100644 index 00000000..c251dee7 --- /dev/null +++ b/tests/test_configure_vedastro_secret.py @@ -0,0 +1,18 @@ +from scripts.configure_vedastro_secret import update_env_text + + +def test_update_env_text_adds_and_replaces_vedastro_settings(): + text = "VEDASTRO_API_ENDPOINT=https://old.example/api\nOTHER=value\n" + updated = update_env_text( + text, + { + "VEDASTRO_API_KEY": "sample-secret", + "VEDASTRO_API_ENDPOINT": "https://api.vedastro.org/api", + "VEDASTRO_ENABLE_NETWORK": "1", + }, + ) + assert "VEDASTRO_API_ENDPOINT=https://api.vedastro.org/api" in updated + assert "VEDASTRO_API_KEY=sample-secret" in updated + assert "VEDASTRO_ENABLE_NETWORK=1" in updated + assert "OTHER=value" in updated + assert "https://old.example/api" not in updated diff --git a/tests/test_report_renderer_isolation_poc.py b/tests/test_report_renderer_isolation_poc.py new file mode 100644 index 00000000..108a6edd --- /dev/null +++ b/tests/test_report_renderer_isolation_poc.py @@ -0,0 +1,9 @@ +from scripts.report_renderer_isolation_poc import run_poc + + +def test_report_renderer_isolation_poc_never_claims_pass_without_browser() -> None: + result = run_poc() + assert result["status"] in {"pass", "fail", "blocked"} + if result["status"] == "pass": + assert result["http_probe_requests"] == 0 + assert result["blocked_resource_count"] >= 2 From 1d61fbe5359678744bda7bf25c83a32203f03ff1 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Fri, 17 Jul 2026 00:27:57 +0800 Subject: [PATCH 29/30] Add OCR transcript extraction helper --- scripts/ocr_extract.py | 151 ++++++++++++++++++++++++++++++++++++++ tests/test_ocr_extract.py | 51 +++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 scripts/ocr_extract.py create mode 100644 tests/test_ocr_extract.py diff --git a/scripts/ocr_extract.py b/scripts/ocr_extract.py new file mode 100644 index 00000000..b71b4334 --- /dev/null +++ b/scripts/ocr_extract.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Extract text from screenshots without requiring Homebrew-installed Tesseract.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + + +DEFAULT_SHORTCUT_NAME = "Extract Text from Image" +VALID_BACKENDS = {"auto", "manual", "shortcuts", "tesseract"} + + +def choose_backend(requested: str = "auto") -> str: + if requested != "auto": + if requested not in VALID_BACKENDS: + raise ValueError(f"unsupported backend: {requested}") + return requested + if shutil.which("shortcuts"): + return "shortcuts" + if shutil.which("tesseract"): + return "tesseract" + return "manual" + + +def _manual_transcript_path(image: Path, transcript_dir: Path | None) -> Path: + base = transcript_dir or image.parent + return base / f"{image.stem}.txt" + + +def _extract_manual(image: Path, transcript_dir: Path | None) -> dict[str, Any]: + transcript = _manual_transcript_path(image, transcript_dir) + if not transcript.is_file(): + return { + "image_path": str(image), + "text": "", + "backend": "manual", + "status": "blocked", + "reason": "manual_transcript_missing", + "expected_transcript_path": str(transcript), + } + return { + "image_path": str(image), + "text": transcript.read_text(encoding="utf-8"), + "backend": "manual", + "status": "ok", + } + + +def _extract_shortcuts(image: Path, shortcut_name: str) -> dict[str, Any]: + if not shutil.which("shortcuts"): + return {"image_path": str(image), "text": "", "backend": "shortcuts", "status": "blocked", "reason": "shortcuts_cli_missing"} + completed = subprocess.run( + ["shortcuts", "run", shortcut_name, "-i", str(image)], + capture_output=True, + text=True, + check=False, + timeout=120, + ) + text = completed.stdout + if completed.returncode != 0: + return { + "image_path": str(image), + "text": text, + "backend": "shortcuts", + "status": "blocked", + "reason": "shortcuts_run_failed", + "stderr": completed.stderr.strip(), + "shortcut_name": shortcut_name, + } + return {"image_path": str(image), "text": text, "backend": "shortcuts", "status": "ok"} + + +def _extract_tesseract(image: Path) -> dict[str, Any]: + if not shutil.which("tesseract"): + return {"image_path": str(image), "text": "", "backend": "tesseract", "status": "blocked", "reason": "tesseract_missing"} + completed = subprocess.run( + ["tesseract", str(image), "stdout", "-l", "eng+chi_sim"], + capture_output=True, + text=True, + check=False, + timeout=120, + ) + if completed.returncode != 0: + return { + "image_path": str(image), + "text": completed.stdout, + "backend": "tesseract", + "status": "blocked", + "reason": "tesseract_run_failed", + "stderr": completed.stderr.strip(), + } + return {"image_path": str(image), "text": completed.stdout, "backend": "tesseract", "status": "ok"} + + +def extract_one(image: Path, *, backend: str = "auto", transcript_dir: Path | None = None, shortcut_name: str = DEFAULT_SHORTCUT_NAME) -> dict[str, Any]: + selected = choose_backend(backend) + if selected == "manual": + return _extract_manual(image, transcript_dir) + if selected == "shortcuts": + return _extract_shortcuts(image, shortcut_name) + if selected == "tesseract": + return _extract_tesseract(image) + raise ValueError(f"unsupported backend: {selected}") + + +def extract_many( + images: list[Path], + *, + output: Path | None = None, + backend: str = "auto", + transcript_dir: Path | None = None, + shortcut_name: str = DEFAULT_SHORTCUT_NAME, +) -> dict[str, Any]: + items = [extract_one(image, backend=backend, transcript_dir=transcript_dir, shortcut_name=shortcut_name) for image in images] + if output: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("\n".join(json.dumps(item, ensure_ascii=False, sort_keys=True) for item in items) + "\n", encoding="utf-8") + return { + "status": "ok" if items and all(item["status"] == "ok" for item in items) else "blocked", + "backend": choose_backend(backend), + "items": items, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("images", nargs="+", type=Path) + parser.add_argument("--backend", choices=sorted(VALID_BACKENDS), default="auto") + parser.add_argument("--transcript-dir", type=Path) + parser.add_argument("--shortcut-name", default=DEFAULT_SHORTCUT_NAME) + parser.add_argument("--output", type=Path, default=Path("scratch/local/ocr_extract/ocr.jsonl")) + args = parser.parse_args(argv) + report = extract_many( + args.images, + output=args.output, + backend=args.backend, + transcript_dir=args.transcript_dir, + shortcut_name=args.shortcut_name, + ) + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if report["status"] == "ok" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ocr_extract.py b/tests/test_ocr_extract.py new file mode 100644 index 00000000..c48a5f6b --- /dev/null +++ b/tests/test_ocr_extract.py @@ -0,0 +1,51 @@ +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 ocr_extract # noqa: E402 + + +def test_manual_transcript_backend_writes_jsonl(tmp_path: Path) -> None: + image = tmp_path / "IMG_3502.PNG" + image.write_bytes(b"not-a-real-image") + transcript = tmp_path / "IMG_3502.txt" + transcript.write_text("Arudha Lagna\nUpapada\n", encoding="utf-8") + output = tmp_path / "ocr.jsonl" + + report = ocr_extract.extract_many([image], output=output, transcript_dir=tmp_path, backend="manual") + + assert report["status"] == "ok" + assert report["items"][0]["backend"] == "manual" + rows = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()] + assert rows == [ + { + "image_path": str(image), + "text": "Arudha Lagna\nUpapada\n", + "backend": "manual", + "status": "ok", + } + ] + + +def test_missing_manual_transcript_is_blocked(tmp_path: Path) -> None: + image = tmp_path / "IMG_3503.PNG" + image.write_bytes(b"not-a-real-image") + + report = ocr_extract.extract_many([image], transcript_dir=tmp_path, backend="manual") + + assert report["status"] == "blocked" + assert report["items"][0]["reason"] == "manual_transcript_missing" + + +def test_backend_auto_prefers_shortcuts_before_tesseract(monkeypatch) -> None: + monkeypatch.setattr(ocr_extract.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"shortcuts", "tesseract"} else None) + + assert ocr_extract.choose_backend("auto") == "shortcuts" From 9bb054b0aeab832e7b5d993fff7fc3a6aac221ba Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Fri, 17 Jul 2026 01:03:50 +0800 Subject: [PATCH 30/30] Add Rangacharya comparison variant --- references/rangacharya_source_cards.json | 88 ++++++++++++ references/rangacharya_source_manifest.json | 127 ++++++++++++++++ references/technique_registry.json | 34 +++++ scripts/jyotish_api_server.py | 13 ++ scripts/rangacharya.py | 143 +++++++++++++++++++ scripts/rangacharya_readiness.py | 58 ++++++++ tests/test_jaimini_rangacharya_api.py | 50 +++++++ tests/test_rangacharya_adjudication_guard.py | 16 +++ tests/test_rangacharya_readiness.py | 14 ++ tests/test_rangacharya_registry.py | 17 +++ tests/test_rangacharya_source_cards.py | 50 +++++++ tests/test_rangacharya_source_manifest.py | 50 +++++++ tests/test_rangacharya_variant.py | 42 ++++++ 13 files changed, 702 insertions(+) create mode 100644 references/rangacharya_source_cards.json create mode 100644 references/rangacharya_source_manifest.json create mode 100644 scripts/rangacharya.py create mode 100644 scripts/rangacharya_readiness.py create mode 100644 tests/test_jaimini_rangacharya_api.py create mode 100644 tests/test_rangacharya_adjudication_guard.py create mode 100644 tests/test_rangacharya_readiness.py create mode 100644 tests/test_rangacharya_registry.py create mode 100644 tests/test_rangacharya_source_cards.py create mode 100644 tests/test_rangacharya_source_manifest.py create mode 100644 tests/test_rangacharya_variant.py diff --git a/references/rangacharya_source_cards.json b/references/rangacharya_source_cards.json new file mode 100644 index 00000000..cbac20db --- /dev/null +++ b/references/rangacharya_source_cards.json @@ -0,0 +1,88 @@ +{ + "schema_version": 1, + "created": "2026-07-16", + "cards": [ + { + "id": "rangacharya_core_arudha", + "title": "Rangacharya Arudha core counting", + "status": "transcribed", + "adjudication_enabled": false, + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Pending formula-level transcription and source verification.", + "notes": "Covers AL, A7, A10, UL, and A1-A12 variant counting." + } + }, + { + "id": "active_effective_lagna", + "title": "Active Lagna and Effective Lagna", + "status": "transcribed", + "adjudication_enabled": false, + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Pending formula-level transcription and source verification.", + "notes": "Runtime may report placeholder metadata only." + } + }, + { + "id": "prakriti_sanmukha", + "title": "Prakriti Chakra and Sanmukha", + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "formula direction and exception rules need source-card verification", + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Blocked until rule text is verified.", + "notes": "No runtime calculation allowed yet." + } + }, + { + "id": "rangacharya_special_mappings", + "title": "Special divisional and graha mappings", + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "Krishnamisra/Somanatha Navamsa, Parivritti/Somanatha Drekkana, Pancansa, and Graha Chakra need separate fixtures", + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Blocked until mapping tables are verified.", + "notes": "Raw descriptive output only after fixtures exist." + } + }, + { + "id": "rangacharya_named_yogas", + "title": "Named Rangacharya yogas", + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "named yogas need formula-level source cards and golden fixtures", + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Blocked until each yoga has its own source card.", + "notes": "Includes Dhana, Nirdhana, Kemadruma, Vahana, Bandhana, Dustamarana, Saukhya, Buddhi, Raja, Manipravala, Amatya, Senadhipatya, Chandradhi/Lagnadhi, Karakamsa, Yogada, Kevala, and Aspecting Graha." + } + }, + { + "id": "rangacharya_ul_family_rules", + "title": "UL relationship and family rules", + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "relationship, child, miscarriage, and adoption claims need real-case calibration", + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Blocked until source and case validation.", + "notes": "May only become rule-hit reporting before calibration." + } + }, + { + "id": "article_warehouse_future_tracks", + "title": "Future article-warehouse tracks", + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "registered only; out of Rangacharya runtime scope", + "evidence": { + "source_ids": ["local_article_warehouse_20260716"], + "formula_text": "Blocked until hash, license, and technique-level extraction.", + "notes": "Includes Tithi Lord, Panchapakshi, Rashi Tulya Navamsa, Bhrigu Pada Dasha, Tajika, Darakaraka, and spouse rules." + } + } + ] +} diff --git a/references/rangacharya_source_manifest.json b/references/rangacharya_source_manifest.json new file mode 100644 index 00000000..f3223aa4 --- /dev/null +++ b/references/rangacharya_source_manifest.json @@ -0,0 +1,127 @@ +{ + "schema_version": 1, + "created": "2026-07-16", + "sources": [ + { + "id": "uploaded_screenshots_20260716", + "kind": "user_uploaded_screenshots", + "paths": [ + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3502.PNG", + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3503.PNG", + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3504.PNG", + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3505.PNG", + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3506.PNG", + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3507.PNG" + ], + "sha256": { + "IMG_3502.PNG": "1cf628c3f5dfac372c719eba442f0cc8325114fabea4cc01855e00247bc9c031", + "IMG_3503.PNG": "f3c1f3c67a3add9c6c586e60e18186e86549ca4a5b5a1e6a6c07cc4fe223e1de", + "IMG_3504.PNG": "6c884011b1fe458da2f23a4e3e5acaf48c6ea72cd402b51b2942b3a28cee5de1", + "IMG_3505.PNG": "16af004f553fde2c1ab5d0bc7968deba46040a02e7726f735f174523e6bdccee", + "IMG_3506.PNG": "a8732bc73767983130683152079eb44697d4686435452e71d2e742f8b16ed888", + "IMG_3507.PNG": "1ff2bbc69a3fe1dab17d742437000d9938d9b5bb5e818831dafc9bf0e05a152e" + }, + "license": "user_private_reference", + "privacy": "private", + "runtime_use": "reference_only_until_formula_verified", + "extraction_status": "located_and_hashed_ocr_blocked_tesseract_missing" + }, + { + "id": "local_article_warehouse_20260716", + "kind": "local_research_archive", + "path": "/Users/wuyongnaren/文件仓库/印度占星文章", + "license": "unknown", + "privacy": "local_research", + "runtime_use": "manifest_only_until_hash_and_license_review" + }, + { + "id": "kimi_agent_archive_20260716", + "kind": "local_training_archive", + "path": "/Users/wuyongnaren/Downloads/_整理候选/安装包与压缩包/Kimi_Agent_高维印度占星师.zip", + "license": "unknown", + "privacy": "local_research", + "runtime_use": "reference_only_until_license_review" + }, + { + "id": "vedastro_official", + "kind": "external_oracle", + "url": "https://github.com/VedAstro/VedAstro", + "license": "MIT", + "privacy": "public", + "runtime_use": "oracle_raw_reference" + }, + { + "id": "pyjhora_official", + "kind": "external_oracle", + "url": "https://github.com/naturalstupid/PyJHora", + "license": "AGPL-3.0", + "privacy": "public", + "runtime_use": "isolated_external_process_only" + }, + { + "id": "jyotishganit_official", + "kind": "external_oracle", + "url": "https://github.com/northtara/jyotishganit", + "license": "MIT", + "privacy": "public", + "runtime_use": "oracle_raw_reference" + }, + { + "id": "dashaflow_official", + "kind": "external_formula_reference", + "url": "https://github.com/adarshj322/dashaflow", + "license": "MIT", + "privacy": "public", + "runtime_use": "formula_reference_only" + } + ], + "validation_ladder": [ + "transcribed", + "source_verified", + "golden_verified", + "engine_cross_checked", + "case_calibrated", + "adjudication_enabled", + "blocked" + ], + "rules": [ + { + "id": "rangacharya_core_arudha", + "label": "Rangacharya Arudha core counting", + "source_ids": ["uploaded_screenshots_20260716"], + "status": "transcribed", + "adjudication_enabled": false + }, + { + "id": "active_effective_lagna", + "label": "Active Lagna and Effective Lagna", + "source_ids": ["uploaded_screenshots_20260716"], + "status": "transcribed", + "adjudication_enabled": false + }, + { + "id": "rangacharya_special_mappings", + "label": "Krishnamisra/Somanatha Navamsa, Parivritti/Somanatha Drekkana, Pancansa Graha, Graha Chakra", + "source_ids": ["uploaded_screenshots_20260716"], + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "needs formula-level source cards before runtime use" + }, + { + "id": "rangacharya_named_yogas", + "label": "Dhana/Nirdhana/Kemadruma and other named yogas", + "source_ids": ["uploaded_screenshots_20260716"], + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "needs formula-level source cards before runtime use" + }, + { + "id": "article_warehouse_future_tracks", + "label": "Tithi Lord, Panchapakshi, Rashi Tulya Navamsa, Bhrigu Pada Dasha, Tajika, Darakaraka, spouse rules", + "source_ids": ["local_article_warehouse_20260716"], + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "registered for later source governance; out of Rangacharya Phase 1 runtime scope" + } + ] +} diff --git a/references/technique_registry.json b/references/technique_registry.json index a49cafe1..6ab387c1 100644 --- a/references/technique_registry.json +++ b/references/technique_registry.json @@ -1695,6 +1695,40 @@ }, "conclusion_policy": "Supporting evidence only; it can raise/lower confidence but cannot by itself decide an event or timing claim." }, + "rangacharya_jaimini_variant": { + "audit_label": "Rangacharya Jaimini Variant", + "commands": [ + "jaimini" + ], + "conclusion_policy": "Display current-vs-variant differences only; do not use for verdicts or timing.", + "domains": [ + "jaimini", + "arudha", + "experimental", + "audit" + ], + "entry_type": "experimental_variant", + "evidence_role": "comparison_only", + "knowledge_refs": [ + "references/rangacharya_source_manifest.json", + "references/rangacharya_source_cards.json", + "docs/superpowers/specs/2026-07-16-rangacharya-vedastro-design.md" + ], + "missing_impact": "Rangacharya-specific Arudha and named-yoga rules remain unavailable for adjudication until source cards, golden fixtures, oracle comparison, and case calibration pass.", + "name": "Rangacharya / Iranganti Jaimini Variant", + "note": "Rangacharya variant has a tested current-vs-variant diff path and API variant output, but adjudication remains disabled until source-card validation and golden fixtures pass.", + "output_paths": [ + "result.rangacharya", + "result.rangacharya_diff" + ], + "status": "comparison-only", + "user_visibility": "expert_audit", + "verification_level": { + "calculation": "experimental", + "prediction": "blocked", + "rule": "blocked" + } + }, "rashi_tulya_navamsa": { "name": "Rashi Tulya Navamsa / 本命对分盘分析", "domains": [ diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 6fc58409..7aa4602f 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -5947,6 +5947,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): allowed_modes = {'all', 'karaka', 'dasha', 'karakamsha', 'arudha', 'special'} if mode not in allowed_modes: raise BadRequest(f'mode must be one of: {", ".join(sorted(allowed_modes))}') + variant = body.get('variant', 'current') + if not isinstance(variant, str): + raise BadRequest('variant must be a string') + variant = variant.strip().lower() or 'current' + allowed_variants = {'current', 'rangacharya', 'all'} + if variant not in allowed_variants: + raise BadRequest(f'variant must be one of: {", ".join(sorted(allowed_variants))}') antardasha = bool(body.get('antardasha', False)) year = self._get_int(body, 'year', datetime.now().year, 1800, 2400) month = self._get_int(body, 'month', 1, 1, 12) @@ -5976,6 +5983,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): if mode in ('all', 'arudha'): result['arudha_padas'] = jaimini.calc_arudha_padas(asc_sign_idx, planet_lons) result['graha_padas'] = jaimini.calc_graha_padas(planet_lons) + if variant in ('rangacharya', 'all'): + rangacharya = _load_local_module('rangacharya') + rangacharya_result = rangacharya.calc_rangacharya_variant(asc_sign_idx, planet_lons) + result['rangacharya'] = rangacharya_result + current_arudha = result.get('arudha_padas') or jaimini.calc_arudha_padas(asc_sign_idx, planet_lons) + result['rangacharya_diff'] = rangacharya.diff_current_vs_rangacharya(current_arudha, rangacharya_result) if mode in ('all', 'special'): result['special_lagnas'] = jaimini.calc_special_lagnas(asc_sign_idx, hour, minute + second / 60.0) return { diff --git a/scripts/rangacharya.py b/scripts/rangacharya.py new file mode 100644 index 00000000..a8f32cba --- /dev/null +++ b/scripts/rangacharya.py @@ -0,0 +1,143 @@ +"""Experimental Rangacharya/Jaimini variant. + +All outputs are blocked from adjudication until formula-level validation passes. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Mapping + + +SIGNS = [ + "Aries", + "Taurus", + "Gemini", + "Cancer", + "Leo", + "Virgo", + "Libra", + "Scorpio", + "Sagittarius", + "Capricorn", + "Aquarius", + "Pisces", +] + +SOURCE_CARDS_PATH = Path(__file__).resolve().parent.parent / "references" / "rangacharya_source_cards.json" + + +class RangacharyaValidationError(RuntimeError): + pass + + +def _source_cards() -> Dict[str, Dict[str, Any]]: + try: + data = json.loads(SOURCE_CARDS_PATH.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return {} + return {str(card.get("id")): dict(card) for card in data.get("cards", []) if card.get("id")} + + +def _card_meta(card_id: str) -> Dict[str, Any]: + card = _source_cards().get(card_id, {}) + status = str(card.get("status") or "blocked") + meta = { + "source_card_id": card_id, + "source_card_status": status, + "validation_status": status, + "adjudication_enabled": False, + } + if status != "source_verified": + meta["blocked_reason"] = card.get("blocked_reason") or "source card is not verified for adjudication" + return meta + + +def _sign_name(index: int) -> str: + return SIGNS[index % 12] + + +def _placeholder_pada(label: str, asc_sign_idx: int, source_house: int) -> Dict[str, Any]: + sign_idx = (asc_sign_idx + source_house - 1) % 12 + return { + "label": label, + "sign": _sign_name(sign_idx), + "sign_index": sign_idx, + "source_house": source_house, + "note": "Rangacharya formula pending source-card implementation", + **_card_meta("rangacharya_core_arudha"), + } + + +def calc_rangacharya_variant(asc_sign_idx: int, planet_longitudes: Mapping[str, float]) -> Dict[str, Any]: + asc_sign_idx %= 12 + arudha_padas = { + "AL": _placeholder_pada("AL", asc_sign_idx, 1), + "A7": _placeholder_pada("A7", asc_sign_idx, 7), + "A10": _placeholder_pada("A10", asc_sign_idx, 10), + "UL": _placeholder_pada("UL", asc_sign_idx, 12), + } + return { + "variant": "rangacharya", + "status": "experimental_not_for_adjudication", + "adjudication_enabled": False, + "source_status": "transcribed", + "active_lagna": { + "sign": _sign_name(asc_sign_idx), + **_card_meta("active_effective_lagna"), + }, + "effective_lagna": { + "sign": _sign_name(asc_sign_idx), + **_card_meta("active_effective_lagna"), + }, + "arudha_padas": arudha_padas, + "input_planets_present": sorted(planet_longitudes), + } + + +def _flatten(prefix: str, value: Any) -> Dict[str, Any]: + if not isinstance(value, dict): + return {prefix: value} + rows: Dict[str, Any] = {} + for key, child in value.items(): + child_key = f"{prefix}.{key}" if prefix else str(key) + rows.update(_flatten(child_key, child)) + return rows + + +def diff_current_vs_rangacharya(current: Mapping[str, Any], variant: Mapping[str, Any]) -> Dict[str, Any]: + current_flat = _flatten("", dict(current)) + variant_flat = _flatten("", dict(variant.get("arudha_padas", variant))) + differences = [] + for key in sorted(set(current_flat) | set(variant_flat)): + current_value = current_flat.get(key) + variant_value = variant_flat.get(key) + if current_value != variant_value: + differences.append({"key": key, "current": current_value, "rangacharya": variant_value}) + return { + "current_algorithm": "current_jaimini", + "variant_algorithm": "rangacharya", + "adjudication_enabled": False, + "differences": differences, + } + + +def validation_summary(result: Mapping[str, Any]) -> Dict[str, Any]: + statuses = [] + for key, value in _flatten("", dict(result)).items(): + if key.endswith("validation_status"): + statuses.append(str(value)) + blocking = sorted({status for status in statuses if status != "adjudication_enabled"}) + return { + "adjudication_enabled": bool(result.get("adjudication_enabled")) and not blocking, + "blocking_statuses": blocking, + } + + +def assert_adjudication_allowed(result: Mapping[str, Any]) -> None: + summary = validation_summary(result) + if not summary["adjudication_enabled"]: + raise RangacharyaValidationError( + "Rangacharya variant is not adjudication-enabled; validation gates are incomplete" + ) diff --git a/scripts/rangacharya_readiness.py b/scripts/rangacharya_readiness.py new file mode 100644 index 00000000..874acced --- /dev/null +++ b/scripts/rangacharya_readiness.py @@ -0,0 +1,58 @@ +"""Readiness report for the experimental Rangacharya variant.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict + + +ROOT = Path(__file__).resolve().parent.parent +CARDS_PATH = ROOT / "references" / "rangacharya_source_cards.json" +MANIFEST_PATH = ROOT / "references" / "rangacharya_source_manifest.json" + + +def _load_json(path: Path) -> Dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def build_report() -> Dict[str, Any]: + cards_payload = _load_json(CARDS_PATH) + manifest_payload = _load_json(MANIFEST_PATH) + cards = {} + blocked = [] + transcribed = [] + for card in cards_payload.get("cards", []): + card_id = str(card.get("id") or "") + if not card_id: + continue + status = str(card.get("status") or "blocked") + adjudication_enabled = bool(card.get("adjudication_enabled")) + cards[card_id] = { + "status": status, + "adjudication_enabled": adjudication_enabled, + "blocked_reason": card.get("blocked_reason") or "", + } + if status == "blocked" or not adjudication_enabled: + blocked.append(card_id) + if status == "transcribed": + transcribed.append(card_id) + return { + "scope": "rangacharya_readiness", + "manifest_available": bool(manifest_payload), + "source_cards_available": bool(cards_payload), + "adjudication_enabled": bool(cards) and not blocked, + "card_count": len(cards), + "blocked_count": len(blocked), + "transcribed_count": len(transcribed), + "blocked_cards": blocked, + "transcribed_cards": transcribed, + "cards": cards, + } + + +if __name__ == "__main__": + print(json.dumps(build_report(), ensure_ascii=False, indent=2, sort_keys=True)) diff --git a/tests/test_jaimini_rangacharya_api.py b/tests/test_jaimini_rangacharya_api.py new file mode 100644 index 00000000..1f649542 --- /dev/null +++ b/tests/test_jaimini_rangacharya_api.py @@ -0,0 +1,50 @@ +import os +import sys + + +SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts") +if SCRIPTS not in sys.path: + sys.path.insert(0, SCRIPTS) + +from jyotish_api_server import JyotishAPIHandler # noqa: E402 + + +def _handler() -> JyotishAPIHandler: + return JyotishAPIHandler.__new__(JyotishAPIHandler) + + +def _planets(): + return { + "Sun": {"lon": 10.0}, + "Moon": {"lon": 45.0}, + "Mars": {"lon": 80.0}, + "Mercury": {"lon": 110.0}, + "Jupiter": {"lon": 145.0}, + "Venus": {"lon": 200.0}, + "Saturn": {"lon": 250.0}, + "Rahu": {"lon": 300.0}, + "Ketu": {"lon": 120.0}, + } + + +def test_jaimini_default_does_not_include_rangacharya(): + result = _handler()._compute_jaimini({ + "mode": "arudha", + "ascendant": {"lon": 0.0}, + "planets": _planets(), + }) + assert "rangacharya" not in result["result"] + assert "rangacharya_diff" not in result["result"] + + +def test_jaimini_variant_all_includes_current_variant_and_diff(): + result = _handler()._compute_jaimini({ + "mode": "arudha", + "variant": "all", + "ascendant": {"lon": 0.0}, + "planets": _planets(), + }) + assert result["result"]["rangacharya"]["adjudication_enabled"] is False + assert result["result"]["rangacharya_diff"]["adjudication_enabled"] is False + assert result["result"]["rangacharya"]["arudha_padas"]["AL"]["source_card_status"] == "transcribed" + assert result["result"]["rangacharya"]["active_lagna"]["source_card_id"] == "active_effective_lagna" diff --git a/tests/test_rangacharya_adjudication_guard.py b/tests/test_rangacharya_adjudication_guard.py new file mode 100644 index 00000000..0e624bdd --- /dev/null +++ b/tests/test_rangacharya_adjudication_guard.py @@ -0,0 +1,16 @@ +import pytest + +from scripts import rangacharya + + +def test_assert_adjudication_allowed_rejects_default_variant(): + result = rangacharya.calc_rangacharya_variant(0, {"Sun": 10.0}) + with pytest.raises(rangacharya.RangacharyaValidationError): + rangacharya.assert_adjudication_allowed(result) + + +def test_validation_summary_lists_blocking_rules(): + result = rangacharya.calc_rangacharya_variant(0, {"Sun": 10.0}) + summary = rangacharya.validation_summary(result) + assert summary["adjudication_enabled"] is False + assert summary["blocking_statuses"] diff --git a/tests/test_rangacharya_readiness.py b/tests/test_rangacharya_readiness.py new file mode 100644 index 00000000..0aaf82db --- /dev/null +++ b/tests/test_rangacharya_readiness.py @@ -0,0 +1,14 @@ +from scripts import rangacharya_readiness + + +def test_readiness_reports_blocked_until_no_adjudication_cards(): + report = rangacharya_readiness.build_report() + assert report["scope"] == "rangacharya_readiness" + assert report["adjudication_enabled"] is False + assert report["blocked_count"] >= 1 + assert "rangacharya_core_arudha" in report["cards"] + + +def test_readiness_does_not_expose_secrets(): + report = rangacharya_readiness.build_report() + assert "sk_live_" not in str(report) diff --git a/tests/test_rangacharya_registry.py b/tests/test_rangacharya_registry.py new file mode 100644 index 00000000..5965c181 --- /dev/null +++ b/tests/test_rangacharya_registry.py @@ -0,0 +1,17 @@ +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +REGISTRY = ROOT / "references" / "technique_registry.json" + + +def test_rangacharya_variant_registered_as_comparison_only(): + techniques = json.loads(REGISTRY.read_text(encoding="utf-8"))["techniques"] + entry = techniques["rangacharya_jaimini_variant"] + assert entry["status"] == "comparison-only" + assert entry["verification_level"]["calculation"] == "experimental" + assert entry["verification_level"]["prediction"] == "blocked" + assert entry["conclusion_policy"] == "Display current-vs-variant differences only; do not use for verdicts or timing." + assert entry["evidence_role"] == "comparison_only" + assert "references/rangacharya_source_cards.json" in entry["knowledge_refs"] diff --git a/tests/test_rangacharya_source_cards.py b/tests/test_rangacharya_source_cards.py new file mode 100644 index 00000000..2fb570c7 --- /dev/null +++ b/tests/test_rangacharya_source_cards.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path + + +CARDS = Path("references/rangacharya_source_cards.json") + + +REQUIRED_CARD_IDS = { + "rangacharya_core_arudha", + "active_effective_lagna", + "prakriti_sanmukha", + "rangacharya_special_mappings", + "rangacharya_named_yogas", + "rangacharya_ul_family_rules", + "article_warehouse_future_tracks", +} + + +def _cards(): + return json.loads(CARDS.read_text(encoding="utf-8")) + + +def test_source_cards_exist_with_required_schema(): + data = _cards() + assert data["schema_version"] == 1 + assert isinstance(data["cards"], list) + for card in data["cards"]: + assert card["id"] + assert card["status"] in { + "transcribed", + "source_verified", + "golden_verified", + "engine_cross_checked", + "case_calibrated", + "blocked", + } + assert card["adjudication_enabled"] is False + assert "evidence" in card + + +def test_source_cards_cover_phase2_rule_groups(): + data = _cards() + found = {card["id"] for card in data["cards"]} + assert REQUIRED_CARD_IDS <= found + + +def test_source_cards_do_not_contain_secrets(): + text = CARDS.read_text(encoding="utf-8") + assert "sk_live_" not in text + assert "VEDASTRO_API_KEY" not in text diff --git a/tests/test_rangacharya_source_manifest.py b/tests/test_rangacharya_source_manifest.py new file mode 100644 index 00000000..0f13d149 --- /dev/null +++ b/tests/test_rangacharya_source_manifest.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path + + +MANIFEST = Path("references/rangacharya_source_manifest.json") + + +def test_manifest_exists_and_has_required_sections(): + data = json.loads(MANIFEST.read_text(encoding="utf-8")) + assert data["schema_version"] == 1 + assert "sources" in data + assert "rules" in data + assert "validation_ladder" in data + + +def test_manifest_does_not_contain_secrets(): + text = MANIFEST.read_text(encoding="utf-8") + assert "sk_live_" not in text + assert "api_key" not in text.lower() + + +def test_rules_default_below_adjudication(): + data = json.loads(MANIFEST.read_text(encoding="utf-8")) + for rule in data["rules"]: + assert rule["status"] in { + "transcribed", + "source_verified", + "golden_verified", + "engine_cross_checked", + "case_calibrated", + "blocked", + } + assert rule["status"] != "adjudication_enabled" + assert rule["adjudication_enabled"] is False + + +def test_screenshot_source_paths_are_located_and_hashed(): + data = json.loads(MANIFEST.read_text(encoding="utf-8")) + source = next(item for item in data["sources"] if item["id"] == "uploaded_screenshots_20260716") + assert source["extraction_status"] == "located_and_hashed_ocr_blocked_tesseract_missing" + assert len(source["paths"]) == 6 + assert all("/文件仓库/印度占星文章/260716/" in path for path in source["paths"]) + assert set(source["sha256"]) == { + "IMG_3502.PNG", + "IMG_3503.PNG", + "IMG_3504.PNG", + "IMG_3505.PNG", + "IMG_3506.PNG", + "IMG_3507.PNG", + } diff --git a/tests/test_rangacharya_variant.py b/tests/test_rangacharya_variant.py new file mode 100644 index 00000000..a263a848 --- /dev/null +++ b/tests/test_rangacharya_variant.py @@ -0,0 +1,42 @@ +from scripts import rangacharya + + +SAMPLE_LONGS = { + "Sun": 10.0, + "Moon": 45.0, + "Mars": 80.0, + "Mercury": 110.0, + "Jupiter": 145.0, + "Venus": 200.0, + "Saturn": 250.0, + "Rahu": 300.0, + "Ketu": 120.0, +} + + +def test_variant_result_is_experimental_and_not_for_adjudication(): + result = rangacharya.calc_rangacharya_variant(0, SAMPLE_LONGS) + assert result["variant"] == "rangacharya" + assert result["adjudication_enabled"] is False + assert result["status"] == "experimental_not_for_adjudication" + + +def test_variant_includes_core_sections(): + result = rangacharya.calc_rangacharya_variant(0, SAMPLE_LONGS) + assert "source_status" in result + assert "arudha_padas" in result + assert "active_lagna" in result + assert "effective_lagna" in result + assert result["arudha_padas"]["AL"]["source_card_id"] == "rangacharya_core_arudha" + assert result["arudha_padas"]["AL"]["source_card_status"] == "transcribed" + assert result["active_lagna"]["source_card_id"] == "active_effective_lagna" + assert result["effective_lagna"]["source_card_id"] == "active_effective_lagna" + + +def test_diff_marks_algorithm_names(): + current = {"AL": {"sign": "Aries"}} + variant = {"arudha_padas": {"AL": {"sign": "Taurus"}}} + diff = rangacharya.diff_current_vs_rangacharya(current, variant) + assert diff["current_algorithm"] == "current_jaimini" + assert diff["variant_algorithm"] == "rangacharya" + assert diff["differences"][0]["key"] == "AL.sign"