From ec9c7cf80ac1aa6e37add5f13904f08c4d28da12 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Wed, 15 Jul 2026 19:13:05 +0800 Subject: [PATCH] refactor strict evidence and extend parity layers --- .../jyotish/scripts/run_pyjhora_compare.py | 40 ++- .../jyotish/scripts/run_skill_baseline.py | 9 + docs/research/pre_work_error_ledger.md | 3 +- ...ashtakavarga_shadbala_parity_2026_07_15.md | 48 ++++ mcp_server.py | 16 +- .../oracle/western_oracle_adapter_contract.md | 22 +- scripts/consultation_workflow_service.py | 32 +++ .../interpretation_source_inventory_gate.py | 4 +- scripts/jyotish_api_server.py | 10 +- scripts/jyotish_engine.py | 30 ++- scripts/shadbala_oracle_closure_status.py | 16 +- scripts/skill_release_package.py | 2 +- scripts/strict_evidence_service.py | 29 +++ scripts/varga.py | 2 +- scripts/western_timing_engine.py | 239 ++++++++++++++++++ tests/test_pyjhora_compare_cli.py | 25 ++ tests/test_runtime_import_boundaries.py | 14 + tests/test_shadbala_oracle_closure_status.py | 15 +- tests/test_varga_bphs.py | 21 ++ tests/test_western_timing_engine.py | 65 +++++ 20 files changed, 600 insertions(+), 42 deletions(-) create mode 100644 docs/research/pyjhora_d2_d4_ashtakavarga_shadbala_parity_2026_07_15.md create mode 100644 scripts/consultation_workflow_service.py create mode 100644 scripts/strict_evidence_service.py diff --git a/benchmarks/jyotish/scripts/run_pyjhora_compare.py b/benchmarks/jyotish/scripts/run_pyjhora_compare.py index 3806f713..26f6d911 100644 --- a/benchmarks/jyotish/scripts/run_pyjhora_compare.py +++ b/benchmarks/jyotish/scripts/run_pyjhora_compare.py @@ -105,7 +105,7 @@ def build_pyjhora_sample(sample, *, node_mode='mean'): swe = patch_swisseph() from jhora import utils, const from jhora.panchanga import drik - from jhora.horoscope.chart import charts + from jhora.horoscope.chart import ashtakavarga, charts, strength from jhora.horoscope.dhasa.graha import vimsottari # Align benchmark口径: Lahiri + mean sidereal year. PyJHora default is TRUE_PUSHYA. @@ -126,8 +126,14 @@ def build_pyjhora_sample(sample, *, node_mode='mean'): current_jd = utils.julian_day_number((ty, tm, td), (0, 0, 0)) rasi = parse_chart_positions(charts.rasi_chart(jd, place)) + rasi_rows = charts.rasi_chart(jd, place) + d2 = parse_chart_positions(charts.hora_chart(rasi_rows, chart_method=2)) + d4 = parse_chart_positions(charts.chaturthamsa_chart(rasi_rows, chart_method=1)) d9 = parse_chart_positions(charts.divisional_chart(jd, place, divisional_chart_factor=9, chart_method=1)) d10 = parse_chart_positions(charts.divisional_chart(jd, place, divisional_chart_factor=10, chart_method=1)) + house_to_planets = utils.get_house_planet_list_from_planet_positions(rasi_rows) + bav, sav, _prastara = ashtakavarga.get_ashtaka_varga(house_to_planets) + shadbala = strength.shad_bala(jd, place) asc = rasi.get('Ascendant') or {} planets = {} @@ -174,6 +180,8 @@ def build_pyjhora_sample(sample, *, node_mode='mean'): 'ayanamsa': 'LAHIRI', 'd9_method': 'PyJHora divisional_chart chart_method=1', 'd10_method': 'PyJHora divisional_chart chart_method=1', + 'd2_method': 'PyJHora hora_chart chart_method=2 traditional_parasara', + 'd4_method': 'PyJHora chaturthamsa_chart chart_method=1 traditional_parasara', 'dasha_year': 'mean sidereal year', 'compat': 'monkeypatch swisseph keyword API + missing constants; dummy timezonefinder only for import', 'license_note': 'PyJHora is AGPL-3.0; used only as external benchmark, not vendored into skill.' @@ -183,7 +191,12 @@ def build_pyjhora_sample(sample, *, node_mode='mean'): 'degree_in_sign': asc.get('degree_in_sign'), }, 'planets': planets, - 'varga': {'D9': d9, 'D10': d10}, + 'varga': {'D2': d2, 'D4': d4, 'D9': d9, 'D10': d10}, + 'ashtakavarga': { + 'bav': {name: list(bav[index]) for index, name in enumerate(['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Lagna'])}, + 'sav': list(sav), + }, + 'shadbala': {name: float(shadbala[6][index]) for index, name in enumerate(['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'])}, 'dasha': dasha, } @@ -234,7 +247,7 @@ def compare_one(sample_id, local, pyjhora): for field in ['sign', 'nakshatra', 'nakshatra_pada']: compare_scalar(rows, sample_id, 'planet', p, field, l.get(field), y.get(field)) compare_scalar(rows, sample_id, 'planet', p, 'degree_in_sign', l.get('degree_in_sign'), y.get('degree_in_sign'), tolerance=0.15) - for varga_name in ['D9', 'D10']: + for varga_name in ['D2', 'D4', 'D9', 'D10']: for body in ['Ascendant'] + PLANETS: l = (local['varga'].get(varga_name) or {}).get(body) or {} y = (pyjhora['varga'].get(varga_name) or {}).get(body) or {} @@ -245,6 +258,24 @@ def compare_one(sample_id, local, pyjhora): pass compare_scalar(rows, sample_id, varga_name, body, 'sign', l.get('sign'), y.get('sign'), boundary_sensitive=boundary_sensitive) compare_scalar(rows, sample_id, varga_name, body, 'degree_in_sign', l.get('degree_in_sign'), y.get('degree_in_sign'), tolerance=0.2, boundary_sensitive=boundary_sensitive) + for planet in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Lagna']: + for sign_idx, sign in enumerate(SIGNS): + compare_scalar( + rows, sample_id, 'Ashtakavarga_BAV', planet, sign, + (local.get('ashtakavarga', {}).get('bav', {}).get(planet) or [None] * 12)[sign_idx], + (pyjhora.get('ashtakavarga', {}).get('bav', {}).get(planet) or [None] * 12)[sign_idx], + ) + for sign_idx, sign in enumerate(SIGNS): + compare_scalar( + rows, sample_id, 'Ashtakavarga_SAV', 'SAV', sign, + (local.get('ashtakavarga', {}).get('sav') or [None] * 12)[sign_idx], + (pyjhora.get('ashtakavarga', {}).get('sav') or [None] * 12)[sign_idx], + ) + for planet in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn']: + compare_scalar( + rows, sample_id, 'Shadbala', planet, 'total_virupas', + local.get('shadbala', {}).get(planet), pyjhora.get('shadbala', {}).get(planet), tolerance=0.5, + ) # PyJHora dasha is useful as external signal, but currently has different default starting convention/seed in some cases. # Keep fields in matrix, with generous date tolerance; differences are classified below in report. for field in ['mahadasha_lord', 'antardasha_lord']: @@ -330,6 +361,7 @@ def main(argv=None): parser = argparse.ArgumentParser(description='Compare public benchmark samples against PyJHora.') parser.add_argument('--sample-id', action='append', default=[], help='Run only a named benchmark sample; repeatable.') parser.add_argument('--build-local', action='store_true', help='Explicitly generate missing local canonical baselines.') + parser.add_argument('--refresh-local', action='store_true', help='Explicitly rebuild selected local canonical baselines.') parser.add_argument('--node-mode', choices=['mean', 'true'], default='mean', help='Match the node convention before comparing.') parser.add_argument('--output-prefix', default='', help='Optional filename prefix for resumable batch artifacts.') args = parser.parse_args(argv) @@ -344,7 +376,7 @@ def main(argv=None): all_rows = [] for sample in samples: local_path = LOCAL_CANON / f"{sample['id']}.canonical.json" - if not local_path.exists() and args.build_local: + if (not local_path.exists() and args.build_local) or args.refresh_local: from run_skill_baseline import run_sample baseline = run_sample(sample) if not baseline.get('ok'): diff --git a/benchmarks/jyotish/scripts/run_skill_baseline.py b/benchmarks/jyotish/scripts/run_skill_baseline.py index 367619c5..aa5f25e5 100644 --- a/benchmarks/jyotish/scripts/run_skill_baseline.py +++ b/benchmarks/jyotish/scripts/run_skill_baseline.py @@ -80,6 +80,8 @@ def canonicalize(sample, data): planets = chart.get('planets', {}) d9 = safe_get(modules, 'varga_full', 'D9_Navamsa', default={}) or {} d10 = safe_get(modules, 'varga_full', 'D10_Dasamsa', default={}) or {} + d2 = safe_get(modules, 'varga_full', 'D2_Hora', default={}) or {} + d4 = safe_get(modules, 'varga_full', 'D4_Turyamsa', default={}) or {} current = safe_get(modules, 'dasha', 'current_dasha', default={}) or {} ad = current.get('antardasha') or {} special = modules.get('special_lagnas', {}) or {} @@ -99,9 +101,16 @@ def canonicalize(sample, data): 'ascendant': chart.get('ascendant'), 'planets': {}, 'varga': { + 'D2': {k: d2.get(k) for k in ['Ascendant'] + PLANETS}, + 'D4': {k: d4.get(k) for k in ['Ascendant'] + PLANETS}, 'D9': {k: d9.get(k) for k in ['Ascendant'] + PLANETS}, 'D10': {k: d10.get(k) for k in ['Ascendant'] + PLANETS}, }, + 'ashtakavarga': { + 'bav': {name: safe_get(modules, 'ashtakavarga', 'bav', name, 'bindus', default=[]) for name in PLANETS[:7] + ['Lagna']}, + 'sav': [safe_get(modules, 'ashtakavarga', 'sav', 'scores', sign) for sign in ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']], + }, + 'shadbala': {name: safe_get(modules, 'shadbala', 'planets', name, 'total_virupas') for name in PLANETS[:7]}, 'dasha': { 'mahadasha_lord': current.get('lord'), 'mahadasha_start': current.get('start'), diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index b9c62eca..09241616 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -97,9 +97,10 @@ For large architecture or release work, also read: | ERR-064 | Western cross-system support accepted external JSON but did not calculate a tropical natal chart from standard birth input, inviting agents to treat missing Western data as a completed cross-check. | mitigated 2026-07-14 | `western_chart_engine.py` computes Swiss Ephemeris tropical natal evidence for direct-chart/rectification only. It remains `partial` until separately materialized timing evidence exists; Prashna and external-payload precedence are regression-tested. | | ERR-065 | A newly added release-critical script can be absent from a zip before its first Git commit because the package enumerates tracked files only. | mitigated 2026-07-14 | List `scripts/western_chart_engine.py` in `REQUIRED_CONTRACTS`; the release-package test proves both editions include it before commit. | | ERR-066 | The pre-work fragment sweep invoked a retired `audit-capabilities --mode strict` contract, so governance tests and the mandatory preflight failed before real checks ran. | mitigated 2026-07-14 | Invoke the supported `--mode validate`; `tests/test_preflight_fragment_scan.py` and `pre_work_check.py` must remain green before substantive work. | -| ERR-067 | A generic “Western timing” label can imply techniques that have not been computed. | mitigated 2026-07-15 | Native timing requires explicit transit, solar-return, secondary-progression, or solar-arc fields; boundaries still name unavailable progressed angles, converse, parans, midpoints, duration, and interpretation. | +| ERR-067 | A generic “Western timing” label can imply techniques that have not been computed. | mitigated 2026-07-15 | Native timing requires explicit fields for each layer. Current native layers cover transit, solar-return, secondary-progression, solar-arc, converse, midpoint, lunar-return and daily duration scan; progressed angles, parans and interpretation remain explicitly blocked/partial. | | ERR-068 | PyJHora comparison reports hard-coded `2026-06-03` as generation time, making fresh external benchmark artifacts appear stale and weakening audit traceability. | mitigated 2026-07-15 | `write_report()` records an injected-or-current UTC ISO timestamp; keep the deterministic timestamp regression. | | ERR-069 | Yoga validation tests and helper runner still imported rules from a `.workbuddy` mirror, so full pytest could fail or silently validate a divergent checkout. | mitigated 2026-07-15 | Resolve repo root from each file location; retain runtime-boundary and focused Yoga regressions. | +| ERR-070 | PyJHora parity for D2/D4/BAV/SAV can pass while Shadbala total virupas still mismatch, so a row-filled Shadbala oracle packet can be mistaken for absolute-value parity. | active external formula blocker | Keep `docs/research/pyjhora_d2_d4_ashtakavarga_shadbala_parity_2026_07_15.md`; do not claim Shadbala external absolute closure until component-level formulas reconcile with PyJHora/JHora raw values. | ## Fragment Sweep Command Set diff --git a/docs/research/pyjhora_d2_d4_ashtakavarga_shadbala_parity_2026_07_15.md b/docs/research/pyjhora_d2_d4_ashtakavarga_shadbala_parity_2026_07_15.md new file mode 100644 index 00000000..0b1b4658 --- /dev/null +++ b/docs/research/pyjhora_d2_d4_ashtakavarga_shadbala_parity_2026_07_15.md @@ -0,0 +1,48 @@ +# PyJHora D2/D4/Ashtakavarga/Shadbala Parity Note 2026-07-15 + +## Scope + +Same-chart replay for `smoke_beijing_1990_noon` after extending `run_pyjhora_compare.py` +and local canonical generation to include D2, D4, BAV/SAV, and Shadbala totals. + +Command: + +```bash +python3 benchmarks/jyotish/scripts/run_pyjhora_compare.py --sample-id smoke_beijing_1990_noon --refresh-local --output-prefix pyjhora_d4_fix +``` + +## Result + +| Section | Rows | Status | +|---|---:|---| +| D2 | 20 | match | +| D4 | 20 | match | +| D9 | 20 | match | +| D10 | 20 | match | +| Ashtakavarga BAV | 96 | match | +| Ashtakavarga SAV | 12 | match | +| Shadbala total virupas | 7 | mismatch | + +Total matrix: `232 match / 7 mismatch / 239 fields`. + +The D4 mismatch was fixed by aligning local BPHS/Parashara Chaturthamsa to the +PyJHora traditional mapping: + +```text +D4 target sign = natal sign + 3 * quarter_index +``` + +## Shadbala Boundary + +The remaining mismatch is not a missing row problem. It is an absolute-value +formula mismatch between local `modules.shadbala.planets.*.total_virupas` and +PyJHora `strength.shad_bala(...)[6]`. + +Current boundary: + +- D2/D4/D9/D10: externally replayed against PyJHora for the smoke chart. +- Ashtakavarga BAV/SAV: externally replayed against PyJHora for the smoke chart. +- Shadbala totals: available locally, but not PyJHora absolute-value parity. + +Do not claim Shadbala external absolute closure from this replay until component +level formula reconciliation is completed. diff --git a/mcp_server.py b/mcp_server.py index 4989f062..24d58ea2 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -716,12 +716,10 @@ def _execute_mcp_consultation_workflow( western_evidence_packet: dict[str, Any] | None = None, western_oracle_payload: dict[str, Any] | None = None, ) -> Dict[str, Any]: - from jyotish_api_server import JyotishAPIHandler, execute_consultation_workflow + from consultation_workflow_service import execute_consultation_workflow - handler = JyotishAPIHandler.__new__(JyotishAPIHandler) result = execute_consultation_workflow( - handler, - body={ + { "question": question, "year": year, "month": month, @@ -4264,12 +4262,12 @@ def strict_workflow( result["chart"] = chart result["strict_workflow"] = _collect_strict_evidence(route, chart) try: - from jyotish_api_server import JyotishAPIHandler + from consultation_workflow_service import build_runtime_evidence_helpers - handler = JyotishAPIHandler.__new__(JyotishAPIHandler) - vedastro_official = handler._high_rigor_vedastro_official_summary(chart) - vedastro_archive_manifest = handler._compute_vedastro_gateway_archives() - interpretation_coverage = handler._interpretation_source_runtime_coverage(chart) + runtime_helpers = build_runtime_evidence_helpers(chart) + vedastro_official = runtime_helpers["vedastro_official"] + vedastro_archive_manifest = runtime_helpers["vedastro_archive_manifest"] + interpretation_coverage = runtime_helpers["interpretation_coverage"] machine_evidence_packet = _UNIFIED_CONSULTATION_ORCHESTRATOR.machine_evidence_packet( chart=chart, route_packet=result.get("routing") if isinstance(result.get("routing"), dict) else route_packet, diff --git a/references/oracle/western_oracle_adapter_contract.md b/references/oracle/western_oracle_adapter_contract.md index 805e57f2..53cadda9 100644 --- a/references/oracle/western_oracle_adapter_contract.md +++ b/references/oracle/western_oracle_adapter_contract.md @@ -34,7 +34,14 @@ To add only requested native time evidence, pass: "transit_date": "2026-07-09", "solar_return_year": 2026, "secondary_progression_date": "2026-07-09", - "solar_arc_date": "2026-07-09" + "solar_arc_date": "2026-07-09", + "converse_secondary_progression_date": "2026-07-09", + "converse_solar_arc_date": "2026-07-09", + "midpoint_date": "2026-07-09", + "lunar_return_start_date": "2026-07-01", + "duration_scan_start_date": "2026-07-01", + "duration_scan_end_date": "2026-07-31", + "parans_date": "2026-07-09" } } ``` @@ -44,9 +51,16 @@ To add only requested native time evidence, pass: return chart at the supplied birthplace/location. Both are calculation data, not event verdicts. `secondary_progression_date` uses one ephemeris day per tropical year for progressed planets. `solar_arc_date` applies the true -secondary-progressed-Sun arc to natal planets/ASC/MC. Both remain `partial`: -progressed angles, converses, parans, midpoints, duration, and interpretation -are not asserted. +secondary-progressed-Sun arc to natal planets/ASC/MC. +`converse_secondary_progression_date` and `converse_solar_arc_date` add the +matching backward-progressed layers. `midpoint_date` emits natal midpoint +geometry and transit midpoint conjunction/opposition hits. `lunar_return_start_date` +finds the next exact tropical lunar return. `duration_scan_start_date` plus +`duration_scan_end_date` groups daily transit-to-natal aspect windows. +These remain evidence layers, not event verdicts. Progressed angles are marked +blocked until a method is selected; `parans_date` currently returns a structured +`blocked` state because a latitude-aware rising/setting/culminating solver is +not yet implemented. ## Accepted Input diff --git a/scripts/consultation_workflow_service.py b/scripts/consultation_workflow_service.py new file mode 100644 index 00000000..88558203 --- /dev/null +++ b/scripts/consultation_workflow_service.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Shared consultation workflow boundary for API and MCP callers.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = ROOT / "scripts" +for path in (ROOT, SCRIPTS_DIR): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + + +def execute_consultation_workflow(body: dict[str, Any], *, surface: str = "api") -> dict[str, Any]: + from jyotish_api_server import JyotishAPIHandler, execute_consultation_workflow as _execute + + handler = JyotishAPIHandler.__new__(JyotishAPIHandler) + return _execute(handler, body=body, surface=surface) + + +def build_runtime_evidence_helpers(chart: dict[str, Any]) -> dict[str, Any]: + from jyotish_api_server import JyotishAPIHandler + + handler = JyotishAPIHandler.__new__(JyotishAPIHandler) + return { + "vedastro_official": handler._high_rigor_vedastro_official_summary(chart), + "vedastro_archive_manifest": handler._compute_vedastro_gateway_archives(), + "interpretation_coverage": handler._interpretation_source_runtime_coverage(chart), + } 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/jyotish_api_server.py b/scripts/jyotish_api_server.py index 236bbe5b..bf964646 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -204,6 +204,13 @@ def _western_evidence_packet_from_body( solar_return_year=timing_request.get('solar_return_year'), secondary_progression_date=timing_request.get('secondary_progression_date'), solar_arc_date=timing_request.get('solar_arc_date'), + converse_secondary_progression_date=timing_request.get('converse_secondary_progression_date'), + converse_solar_arc_date=timing_request.get('converse_solar_arc_date'), + midpoint_date=timing_request.get('midpoint_date'), + lunar_return_start_date=timing_request.get('lunar_return_start_date'), + duration_scan_start_date=timing_request.get('duration_scan_start_date'), + duration_scan_end_date=timing_request.get('duration_scan_end_date'), + parans_date=timing_request.get('parans_date'), ) if timing: packet['timing_techniques'] = timing @@ -211,7 +218,8 @@ def _western_evidence_packet_from_body( packet['missing_sections'] = [item for item in packet['missing_sections'] if item != 'timing_techniques'] packet['boundary'] = ( 'Native calculations include only explicitly requested transit, solar-return, secondary-progression, ' - 'and solar-arc layers; they do not infer duration, outcomes, or interpretation.' + 'solar-arc, midpoint, lunar-return and daily duration-scan layers; parans remain blocked until a ' + 'dedicated latitude-aware event solver is implemented. Outputs do not infer outcomes or interpretation.' ) return packet except Exception as exc: # pragma: no cover - defensive boundary diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 394ec4d8..90002f73 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -1200,9 +1200,13 @@ def _base_strict_narrative_payload(route_label, strict, *, fallback_headline, st monthly_frame = strict.get('monthly_adjudication_summary') if isinstance(strict, dict) else {} monthly_frame = monthly_frame if isinstance(monthly_frame, dict) else {} event_judgement = strict.get('event_judgement') if isinstance(strict, dict) else {} + event_judgement = event_judgement if isinstance(event_judgement, dict) else {} adjudication = strict.get('adjudication_stages') if isinstance(strict, dict) else {} + adjudication = adjudication if isinstance(adjudication, dict) else {} boundary_contract = strict.get('prediction_boundary_contract') if isinstance(strict, dict) else {} + boundary_contract = boundary_contract if isinstance(boundary_contract, dict) else {} confidence_boundary = boundary_contract.get('confidence_boundary') if isinstance(boundary_contract, dict) else {} + confidence_boundary = confidence_boundary if isinstance(confidence_boundary, dict) else {} confidence_cap = strict.get('confidence_cap') or event_judgement.get('confidence_cap') or 'unknown' dominant_label = event_judgement.get('dominant_label') if isinstance(event_judgement, dict) else None @@ -1657,11 +1661,21 @@ def _build_ai_prompt_pack(report): if isinstance(primary_strict_contract, dict) else {} ) + try: + from strict_evidence_service import existing_interpretation_source_pack + fallback_source_pack = existing_interpretation_source_pack() + except Exception: + fallback_source_pack = {} interpretation_source_audit = ( primary_audit.get('interpretation_source_pack') if isinstance(primary_audit, dict) and isinstance(primary_audit.get('interpretation_source_pack'), dict) else {} ) + fallback_domain_layers = ( + fallback_source_pack.get('domain_invocation_layers') + if isinstance(fallback_source_pack, dict) and isinstance(fallback_source_pack.get('domain_invocation_layers'), dict) + else {} + ) guided_topics = modules.get('guided_topics') if isinstance(modules.get('guided_topics'), list) else build_guided_topics(report) capability_evidence_pool = build_capability_evidence_pool_summary() @@ -1743,7 +1757,7 @@ def _build_ai_prompt_pack(report): 'missing_refs': interpretation_source_audit.get('missing_refs') or [], }, 'prediction_boundary_contract': primary_prediction_boundary_contract or {}, - 'domain_invocation_layers': primary_domain_invocation_layers or {}, + 'domain_invocation_layers': primary_domain_invocation_layers or fallback_domain_layers or {}, 'output_template_contract': primary_output_template_contract or {}, 'mevg_collection_queue': primary_mevg_collection_queue or {}, 'real_case_calibration_layer': primary_real_case_calibration_layer or {}, @@ -2038,20 +2052,20 @@ def _attach_vedastro_official_full_snapshot(report, args): def _load_strict_evidence_collector(): try: - from mcp_server import _collect_strict_evidence as collector + from strict_evidence_service import collect_strict_evidence as collector return collector except Exception: - mcp_path = os.path.join(ROOT_DIR, 'mcp_server.py') - if not os.path.exists(mcp_path): + service_path = os.path.join(SCRIPT_DIR, 'strict_evidence_service.py') + if not os.path.exists(service_path): raise - spec = importlib.util.spec_from_file_location("jyotish_root_mcp_server", mcp_path) + spec = importlib.util.spec_from_file_location("jyotish_strict_evidence_service", service_path) if spec is None or spec.loader is None: - raise ImportError(f"Unable to load mcp_server from {mcp_path}") + raise ImportError(f"Unable to load strict_evidence_service from {service_path}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - collector = getattr(module, "_collect_strict_evidence", None) + collector = getattr(module, "collect_strict_evidence", None) if collector is None: - raise ImportError("mcp_server._collect_strict_evidence not found") + raise ImportError("strict_evidence_service.collect_strict_evidence not found") return collector diff --git a/scripts/shadbala_oracle_closure_status.py b/scripts/shadbala_oracle_closure_status.py index b476571d..fb35d1ee 100644 --- a/scripts/shadbala_oracle_closure_status.py +++ b/scripts/shadbala_oracle_closure_status.py @@ -200,19 +200,23 @@ def build_status(oracle_file: str) -> dict[str, Any]: "summary": { "shadbala_task_count": len(shadbala_tasks), "external_verified_shadbala_tasks": len(external_verified), - "can_claim_shadbala_absolute_closure": True, + "external_packet_fields_complete": True, + "same_chart_parity_status": "blocked", + "same_chart_parity_reason": "PyJHora same-chart replay still mismatches local Shadbala total virupas.", + "can_claim_shadbala_absolute_closure": False, "production_tuning_allowed": False, "required_planets": REQUIRED_PLANETS, "required_components": REQUIRED_COMPONENTS, }, "first_priority": None, "next_actions": [ - "Shadbala external absolute-value closure is complete for the current target set.", + "Reconcile Shadbala component-level formulas against PyJHora/JHora same-chart raw values.", "Keep global calibration blocked until Tajika/Sahams and other oracle fronts pass validation.", ], "boundary": ( "This board isolates Shadbala absolute values. Dasha boundary dates are a separate closure task. " - "Production tuning remains forbidden until external component-level evidence is complete." + "Packet fields are complete for the current target set, but absolute closure remains blocked " + "until same-chart parity passes." ), } @@ -283,15 +287,17 @@ def render_markdown(report: dict[str, Any]) -> str: "", f"- shadbala_task_count: `{summary['shadbala_task_count']}`", f"- external_verified_shadbala_tasks: `{summary['external_verified_shadbala_tasks']}`", + f"- external_packet_fields_complete: `{str(summary.get('external_packet_fields_complete', False)).lower()}`", + f"- same_chart_parity_status: `{summary.get('same_chart_parity_status', 'not_checked')}`", f"- can_claim_shadbala_absolute_closure: `{str(summary['can_claim_shadbala_absolute_closure']).lower()}`", f"- production_tuning_allowed: `{str(summary['production_tuning_allowed']).lower()}`", "", ] if first is None: lines.extend([ - "## Closure Complete", + "## Packet Complete; Parity Blocked", "", - "Shadbala external absolute-value closure is complete for the current target set.", + "Shadbala external packet fields are complete for the current target set, but same-chart parity is still blocked.", "", "## Next Actions", "", diff --git a/scripts/skill_release_package.py b/scripts/skill_release_package.py index 9a2e706f..ae1957d3 100644 --- a/scripts/skill_release_package.py +++ b/scripts/skill_release_package.py @@ -48,7 +48,7 @@ Do not add private birth data, API keys, or desktop oracle screenshots to this p 如果没有 VedAstro official_raw_response,请标记 official_blocked 或 local_fallback。 如果我提供西方占星导出,请作为 western_oracle_payload 进入统一主链,不要把单边西占信号说成双系统互证。 如果我没有西占导出,请自动计算热带本命证据包(ASC/MC、宫位、主要相位、容许度),并明确它只完成本命层;流年、次限、太阳弧、日返仍须单独计算或导入。 -如需西占时间技术,请传 western_timing:`{"transit_date":"YYYY-MM-DD","solar_return_year":YYYY,"secondary_progression_date":"YYYY-MM-DD","solar_arc_date":"YYYY-MM-DD"}`;当前支持指定日 transit、精确太阳回归、次限行星与真实太阳弧;未实现的角度推进、converse、paran/midpoint 不得标成已用。 +如需西占时间技术,请传 western_timing:`{"transit_date":"YYYY-MM-DD","solar_return_year":YYYY,"secondary_progression_date":"YYYY-MM-DD","solar_arc_date":"YYYY-MM-DD","converse_secondary_progression_date":"YYYY-MM-DD","converse_solar_arc_date":"YYYY-MM-DD","midpoint_date":"YYYY-MM-DD","lunar_return_start_date":"YYYY-MM-DD","duration_scan_start_date":"YYYY-MM-DD","duration_scan_end_date":"YYYY-MM-DD","parans_date":"YYYY-MM-DD"}`;当前支持指定日 transit、精确太阳回归、次限行星、真实太阳弧、converse 次限/太阳弧、midpoints、月返和每日过境持续窗口;parans 与高级次限角度仍返回 blocked,不得标成已用。 ## Highest Quality Mode 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() diff --git a/scripts/varga.py b/scripts/varga.py index 83a7df40..4bf6ccfc 100644 --- a/scripts/varga.py +++ b/scripts/varga.py @@ -62,7 +62,7 @@ def varga_map(si, pi, div): o = _odd(si) if div==2: return (4 if o else 3) if pi==0 else (3 if o else 4) if div==3: return (si+pi*4)%12 # Drekkana: same → +4 → +8, no odd/even distinction - if div==4: return (si+pi)%12 if o else (si+8+pi)%12 + if div==4: return (si+pi*3)%12 if div==7: return (si+pi)%12 if o else (si+6+pi)%12 if div==9: # BPHS Navamsa: movable=same, fixed=9th from sign (+8), dual=5th from sign (+4) diff --git a/scripts/western_timing_engine.py b/scripts/western_timing_engine.py index 1a718279..82dc0e48 100644 --- a/scripts/western_timing_engine.py +++ b/scripts/western_timing_engine.py @@ -187,12 +187,231 @@ def calculate_solar_arc_directions(*, target_date: str, **birth: Any) -> dict[st } +def calculate_converse_secondary_progressions(*, target_date: str, **birth: Any) -> dict[str, Any]: + """Calculate converse progressed planets using one ephemeris day per tropical year backward.""" + natal_chart = build_tropical_natal_chart(**birth) + target_jd, local = _target_jd(target_date, birth["timezone"]) + birth_jd = _birth_jd(**birth) + elapsed_years = (target_jd - birth_jd) / 365.242189 + progressed_jd = birth_jd - elapsed_years + planets = _progressed_planets(progressed_jd) + natal_points = { + **natal_chart["natal"]["planets"], + "ascendant": natal_chart["natal"]["angles"]["ascendant"], + "mc": natal_chart["natal"]["angles"]["mc"], + } + return { + "technique": "converse_secondary_progressions", + "status": "partial", + "method": "one_ephemeris_day_per_tropical_year_backward", + "target_date": target_date, + "target_local_time": local.isoformat(), + "elapsed_tropical_years": round(elapsed_years, 8), + "progressed_julian_day_ut": round(progressed_jd, 8), + "progressed_planets": planets, + "aspects": _cross_aspects(planets, natal_points), + "progressed_angles": { + "status": "blocked", + "reason": "Progressed angle method is not selected; quotidian/solar-arc/Naibod variants are not interchangeable.", + }, + "boundary": "Converse progressed planets only; progressed angles and interpretation remain blocked until a method is selected.", + } + + +def calculate_converse_solar_arc_directions(*, target_date: str, **birth: Any) -> dict[str, Any]: + """Direct natal points backward by the converse secondary-progressed Sun arc.""" + natal_chart = build_tropical_natal_chart(**birth) + progressions = calculate_converse_secondary_progressions(target_date=target_date, **birth) + natal_sun = natal_chart["natal"]["planets"]["sun"]["longitude"] + progressed_sun = progressions["progressed_planets"]["sun"]["longitude"] + arc = _longitude(natal_sun - progressed_sun) + natal_points = { + **natal_chart["natal"]["planets"], + "ascendant": natal_chart["natal"]["angles"]["ascendant"], + "mc": natal_chart["natal"]["angles"]["mc"], + } + directed = {name: _point(point["longitude"] - arc) for name, point in natal_points.items()} + return { + "technique": "converse_solar_arc_directions", + "status": "partial", + "method": "converse_secondary_progressed_sun_arc", + "target_date": target_date, + "natal_sun_longitude": natal_sun, + "converse_progressed_sun_longitude": progressed_sun, + "converse_solar_arc_degrees": round(arc, 8), + "directed_points": directed, + "aspects": _cross_aspects(directed, natal_points), + "boundary": "Backward solar arc applied to natal planets/ASC/MC. Interpretation and parans remain separate audited layers.", + } + + +def _midpoint_longitude(first: float, second: float) -> float: + diff = _longitude(second - first) + if diff > 180.0: + diff -= 360.0 + return _longitude(first + diff / 2.0) + + +def calculate_midpoints(*, target_date: str | None = None, orb: float = 1.5, **birth: Any) -> dict[str, Any]: + """Calculate natal midpoint tree and optional transit conjunction/opposition hits.""" + natal_chart = build_tropical_natal_chart(**birth) + natal_points = { + **natal_chart["natal"]["planets"], + "ascendant": natal_chart["natal"]["angles"]["ascendant"], + "mc": natal_chart["natal"]["angles"]["mc"], + } + names = [name for name in [*_PLANETS.keys(), "ascendant", "mc"] if name in natal_points] + midpoints: dict[str, dict[str, Any]] = {} + for index, first_name in enumerate(names): + for second_name in names[index + 1:]: + key = f"{first_name}/{second_name}" + lon = _midpoint_longitude(natal_points[first_name]["longitude"], natal_points[second_name]["longitude"]) + midpoints[key] = _point(lon) + result: dict[str, Any] = { + "technique": "midpoints", + "status": "used", + "method": "shortest_arc_direct_midpoints", + "orb_degrees": float(orb), + "natal_midpoints": midpoints, + "boundary": "Midpoint geometry only; hits are conjunction/opposition contacts, not interpretations.", + } + if target_date: + transit = calculate_transit_to_natal(target_date=target_date, **birth) + hits: list[dict[str, Any]] = [] + for transit_name, transit_point in transit["transit_planets"].items(): + for midpoint_name, midpoint in midpoints.items(): + separation = abs(transit_point["longitude"] - midpoint["longitude"]) + separation = min(separation, 360.0 - separation) + for aspect, exact in {"conjunction": 0.0, "opposition": 180.0}.items(): + hit_orb = abs(separation - exact) + if hit_orb <= orb: + hits.append({ + "transit_planet": transit_name, + "midpoint": midpoint_name, + "aspect": aspect, + "orb": round(hit_orb, 6), + "separation": round(separation, 6), + }) + result["target_date"] = target_date + result["transit_midpoint_hits"] = sorted(hits, key=lambda row: (row["orb"], row["transit_planet"], row["midpoint"])) + return result + + +def calculate_lunar_return(*, start_date: str, **birth: Any) -> dict[str, Any]: + """Find the next exact tropical lunar return after a local start date.""" + natal_chart = build_tropical_natal_chart(**birth) + natal_moon = natal_chart["natal"]["planets"]["moon"]["longitude"] + start_jd, _ = _target_jd(start_date, birth["timezone"]) + return_jd = swe.mooncross_ut(natal_moon, start_jd, swe.FLG_SWIEPH) + return_local = _jd_to_local(return_jd, birth["timezone"]) + return_birth = { + **birth, + "year": return_local.year, + "month": return_local.month, + "day": return_local.day, + "hour": return_local.hour, + "minute": return_local.minute, + "second": return_local.second, + } + return_chart = build_tropical_natal_chart(**return_birth) + returned_moon = return_chart["natal"]["planets"]["moon"]["longitude"] + delta = abs(_longitude(returned_moon - natal_moon)) + delta = min(delta, 360.0 - delta) + return { + "technique": "lunar_return", + "status": "used", + "method": "Swiss Ephemeris mooncross_ut tropical longitude", + "start_date": start_date, + "return_julian_day_ut": round(return_jd, 8), + "return_local_time": return_local.isoformat(), + "natal_moon_longitude": natal_moon, + "return_moon_longitude": returned_moon, + "moon_longitude_delta": round(delta, 8), + "return_chart": return_chart, + "boundary": "Exact lunar return time and chart only; monthly topics require separate audited interpretation.", + } + + +def calculate_transit_duration_scan(*, start_date: str, end_date: str, max_days: int = 370, **birth: Any) -> dict[str, Any]: + """Scan daily transit-to-natal aspect activity and group consecutive windows.""" + start = datetime.fromisoformat(start_date) + end = datetime.fromisoformat(end_date) + if end < start: + raise ValueError("end_date must be on or after start_date") + days = (end.date() - start.date()).days + 1 + if days > max_days: + raise ValueError(f"duration scan range exceeds max_days={max_days}") + daily_hits: list[dict[str, Any]] = [] + active: dict[tuple[str, str, str], dict[str, Any]] = {} + windows: list[dict[str, Any]] = [] + for offset in range(days): + current = (start + timedelta(days=offset)).date().isoformat() + transit = calculate_transit_to_natal(target_date=current, **birth) + keys = set() + for aspect in transit["aspects"]: + key = (aspect["transit_planet"], aspect["natal_point"], aspect["aspect"]) + keys.add(key) + if key not in active: + active[key] = {"start_date": current, "min_orb": aspect["orb"]} + else: + active[key]["min_orb"] = min(active[key]["min_orb"], aspect["orb"]) + for key in list(active): + if key not in keys: + row = active.pop(key) + windows.append({ + "transit_planet": key[0], + "natal_point": key[1], + "aspect": key[2], + "start_date": row["start_date"], + "end_date": (start + timedelta(days=offset - 1)).date().isoformat(), + "min_orb": round(row["min_orb"], 6), + }) + daily_hits.append({"date": current, "hit_count": len(transit["aspects"]), "aspects": transit["aspects"]}) + final_date = end.date().isoformat() + for key, row in active.items(): + windows.append({ + "transit_planet": key[0], + "natal_point": key[1], + "aspect": key[2], + "start_date": row["start_date"], + "end_date": final_date, + "min_orb": round(row["min_orb"], 6), + }) + return { + "technique": "transit_duration_scan", + "status": "used", + "method": "daily local-midnight transit snapshots grouped into consecutive aspect windows", + "start_date": start_date, + "end_date": end_date, + "days_scanned": days, + "daily_hits": daily_hits, + "windows": sorted(windows, key=lambda row: (row["start_date"], row["min_orb"], row["transit_planet"])), + "boundary": "Daily scan only; exact ingress/egress times require sub-daily root finding.", + } + + +def calculate_parans_status(*, target_date: str | None = None, **birth: Any) -> dict[str, Any]: + return { + "technique": "parans", + "status": "blocked", + "target_date": target_date, + "reason": "Parans need a dedicated rising/setting/culminating engine and latitude-aware event solver; not yet implemented in this repository.", + } + + def build_timing_techniques( *, transit_date: str | None = None, solar_return_year: int | None = None, secondary_progression_date: str | None = None, solar_arc_date: str | None = None, + converse_secondary_progression_date: str | None = None, + converse_solar_arc_date: str | None = None, + midpoint_date: str | None = None, + lunar_return_start_date: str | None = None, + duration_scan_start_date: str | None = None, + duration_scan_end_date: str | None = None, + parans_date: str | None = None, **birth: Any, ) -> dict[str, Any]: """Materialize only the requested, independently auditable timing layers.""" @@ -207,4 +426,24 @@ def build_timing_techniques( ) if solar_arc_date: techniques["solar_arc_directions"] = calculate_solar_arc_directions(target_date=solar_arc_date, **birth) + if converse_secondary_progression_date: + techniques["converse_secondary_progressions"] = calculate_converse_secondary_progressions( + target_date=converse_secondary_progression_date, **birth + ) + if converse_solar_arc_date: + techniques["converse_solar_arc_directions"] = calculate_converse_solar_arc_directions( + target_date=converse_solar_arc_date, **birth + ) + if midpoint_date: + techniques["midpoints"] = calculate_midpoints(target_date=midpoint_date, **birth) + if lunar_return_start_date: + techniques["lunar_return"] = calculate_lunar_return(start_date=lunar_return_start_date, **birth) + if duration_scan_start_date and duration_scan_end_date: + techniques["transit_duration_scan"] = calculate_transit_duration_scan( + start_date=duration_scan_start_date, + end_date=duration_scan_end_date, + **birth, + ) + if parans_date: + techniques["parans"] = calculate_parans_status(target_date=parans_date, **birth) return techniques diff --git a/tests/test_pyjhora_compare_cli.py b/tests/test_pyjhora_compare_cli.py index fb62bb02..fa6e81bd 100644 --- a/tests/test_pyjhora_compare_cli.py +++ b/tests/test_pyjhora_compare_cli.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone from pathlib import Path from benchmarks.jyotish.scripts.run_pyjhora_compare import write_report +from benchmarks.jyotish.scripts.run_pyjhora_compare import compare_one ROOT = Path(__file__).resolve().parents[1] @@ -17,6 +18,7 @@ def test_pyjhora_compare_help_is_non_executing(): assert result.returncode == 0 assert "--build-local" in result.stdout + assert "--refresh-local" in result.stdout assert "--output-prefix" in result.stdout assert "FileNotFoundError" not in result.stderr @@ -30,3 +32,26 @@ def test_pyjhora_report_uses_supplied_utc_generation_timestamp(): assert "生成时间:2026-07-15T04:30:00+00:00" in report assert "生成时间:2026-06-03" not in report + + +def test_pyjhora_comparison_includes_d2_d4_bav_sav_and_shadbala_rows(): + bodies = ["Ascendant", "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"] + chart = {body: {"sign": "Aries", "degree_in_sign": 1.0} for body in bodies} + planet = {body: {"sign": "Aries", "degree_in_sign": 1.0, "nakshatra": "Ashwini", "nakshatra_pada": 1} for body in bodies[1:]} + local = { + "ascendant": chart["Ascendant"], "planets": planet, + "varga": {"D2": chart, "D4": chart, "D9": chart, "D10": chart}, + "dasha": {}, + "ashtakavarga": {"bav": {name: [1] * 12 for name in ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Lagna"]}, "sav": [7] * 12}, + "shadbala": {name: 100.0 for name in ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"]}, + } + pyjhora = { + "ascendant": chart["Ascendant"], "planets": planet, + "varga": {"D2": chart, "D4": chart, "D9": chart, "D10": chart}, + "dasha": {}, + "ashtakavarga": local["ashtakavarga"], "shadbala": local["shadbala"], + } + + sections = {row["section"] for row in compare_one("fixture", local, pyjhora)} + + assert {"D2", "D4", "Ashtakavarga_BAV", "Ashtakavarga_SAV", "Shadbala"} <= sections diff --git a/tests/test_runtime_import_boundaries.py b/tests/test_runtime_import_boundaries.py index 7053f9c8..c35d5301 100644 --- a/tests/test_runtime_import_boundaries.py +++ b/tests/test_runtime_import_boundaries.py @@ -43,3 +43,17 @@ def test_mcp_docstring_marks_workbuddy_as_distribution_mirror_not_runtime_source assert "/.workbuddy/skills/jyotish-vedic-astrology/mcp_server.py" not in text assert "distribution mirror" in text assert "reference only" in text + + +def test_engine_does_not_import_mcp_server_for_strict_evidence() -> None: + text = (ROOT / "scripts" / "jyotish_engine.py").read_text(encoding="utf-8", errors="ignore") + assert "from mcp_server import" not in text + assert "mcp_server.py" not in text + assert "strict_evidence_service" in text + + +def test_mcp_server_does_not_instantiate_api_handler_directly() -> None: + text = (ROOT / "mcp_server.py").read_text(encoding="utf-8", errors="ignore") + assert "from jyotish_api_server import" not in text + assert "JyotishAPIHandler" not in text + assert "consultation_workflow_service" in text diff --git a/tests/test_shadbala_oracle_closure_status.py b/tests/test_shadbala_oracle_closure_status.py index 11a4dd9b..e0142381 100644 --- a/tests/test_shadbala_oracle_closure_status.py +++ b/tests/test_shadbala_oracle_closure_status.py @@ -36,13 +36,15 @@ def test_shadbala_oracle_closure_status_identifies_first_absolute_value_packet() report = json.loads(completed.stdout) assert report["scope"] == "shadbala_external_absolute_value_closure_status" assert report["schema_version"] == 1 - assert report["summary"]["shadbala_task_count"] == 4 - assert report["summary"]["external_verified_shadbala_tasks"] == 4 - assert report["summary"]["can_claim_shadbala_absolute_closure"] is True + assert report["summary"]["shadbala_task_count"] == 2 + assert report["summary"]["external_verified_shadbala_tasks"] == 2 + assert report["summary"]["external_packet_fields_complete"] is True + assert report["summary"]["same_chart_parity_status"] == "blocked" + assert report["summary"]["can_claim_shadbala_absolute_closure"] is False assert report["summary"]["required_planets"] == ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"] assert report["summary"]["required_components"] == ["sthana", "dig", "kala", "chesta", "naisargika", "drik", "total_rupa"] assert report["first_priority"] is None - assert report["next_actions"][0] == "Shadbala external absolute-value closure is complete for the current target set." + assert report["next_actions"][0] == "Reconcile Shadbala component-level formulas against PyJHora/JHora same-chart raw values." def test_shadbala_oracle_closure_status_markdown_can_be_written(tmp_path: Path) -> None: @@ -53,5 +55,6 @@ def test_shadbala_oracle_closure_status_markdown_can_be_written(tmp_path: Path) assert output.exists() markdown = output.read_text(encoding="utf-8") assert "# Shadbala External Absolute-Value Closure Status" in markdown - assert "can_claim_shadbala_absolute_closure: `true`" in markdown - assert "closure is complete for the current target set" in markdown + assert "same_chart_parity_status: `blocked`" in markdown + assert "can_claim_shadbala_absolute_closure: `false`" in markdown + assert "same-chart parity is still blocked" in markdown diff --git a/tests/test_varga_bphs.py b/tests/test_varga_bphs.py index 66dfb16d..ced649f2 100644 --- a/tests/test_varga_bphs.py +++ b/tests/test_varga_bphs.py @@ -44,6 +44,13 @@ def drekkana_ref(lon: float) -> int: return (sign_index + part_index * 4) % 12 +def chaturthamsa_ref(lon: float) -> int: + sign_index = int((lon % 360) / 30) % 12 + degree_in_sign = (lon % 360) - sign_index * 30 + part_index = int(degree_in_sign / (30 / 4)) + return (sign_index + part_index * 3) % 12 + + @given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False)) def test_navamsa_matches_bphs_reference(lon: float) -> None: result = calc_varga(lon, 9) @@ -66,6 +73,20 @@ def test_drekkana_uses_same_plus_four_plus_eight(lon: float) -> None: assert result["sign_idx"] == drekkana_ref(lon) +@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False)) +def test_chaturthamsa_matches_pyjhora_parashara_reference(lon: float) -> None: + result = calc_varga(lon, 4) + assert result["sign_idx"] == chaturthamsa_ref(lon) + assert result["sign"] == SIGNS[chaturthamsa_ref(lon)] + assert 0 <= result["degree_in_sign"] < 30 + + +def test_chaturthamsa_reference_boundary_examples() -> None: + assert [varga_map(0, part, 4) for part in range(4)] == [0, 3, 6, 9] + assert [varga_map(8, part, 4) for part in range(4)] == [8, 11, 2, 5] + assert [varga_map(11, part, 4) for part in range(4)] == [11, 2, 5, 8] + + def test_varga_map_boundary_examples() -> None: assert varga_map(0, 0, 9) == 0 # Aries Navamsa starts Aries assert varga_map(1, 0, 9) == 9 # Taurus Navamsa starts Capricorn (9th from sign) diff --git a/tests/test_western_timing_engine.py b/tests/test_western_timing_engine.py index e243d0fe..7510c6eb 100644 --- a/tests/test_western_timing_engine.py +++ b/tests/test_western_timing_engine.py @@ -4,9 +4,15 @@ from __future__ import annotations from scripts.western_timing_engine import ( build_timing_techniques, + calculate_converse_secondary_progressions, + calculate_converse_solar_arc_directions, + calculate_lunar_return, + calculate_midpoints, + calculate_parans_status, calculate_secondary_progressions, calculate_solar_arc_directions, calculate_solar_return, + calculate_transit_duration_scan, calculate_transit_to_natal, ) @@ -57,3 +63,62 @@ def test_solar_arc_uses_secondary_progressed_sun_arc() -> None: assert directions["method"] == "secondary_progressed_sun_arc" assert 0 < directions["solar_arc_degrees"] < 40 assert directions["directed_points"]["sun"]["longitude"] != directions["natal_sun_longitude"] + + +def test_converse_progressions_and_solar_arc_are_auditable() -> None: + progressions = calculate_converse_secondary_progressions(**_BIRTH, target_date="2026-07-09") + assert progressions["technique"] == "converse_secondary_progressions" + assert progressions["progressed_angles"]["status"] == "blocked" + directions = calculate_converse_solar_arc_directions(**_BIRTH, target_date="2026-07-09") + assert directions["technique"] == "converse_solar_arc_directions" + assert 0 < directions["converse_solar_arc_degrees"] < 40 + assert directions["directed_points"]["sun"]["longitude"] != directions["natal_sun_longitude"] + + +def test_midpoints_emit_geometry_and_optional_transit_hits() -> None: + midpoints = calculate_midpoints(**_BIRTH, target_date="2026-07-09") + assert midpoints["technique"] == "midpoints" + assert "sun/moon" in midpoints["natal_midpoints"] + assert isinstance(midpoints["transit_midpoint_hits"], list) + + +def test_lunar_return_calculates_next_exact_return_chart() -> None: + lunar_return = calculate_lunar_return(**_BIRTH, start_date="2026-07-01") + assert lunar_return["technique"] == "lunar_return" + assert lunar_return["moon_longitude_delta"] < 0.01 + assert lunar_return["return_chart"]["natal"]["planets"]["moon"]["sign"] + + +def test_transit_duration_scan_groups_daily_windows() -> None: + scan = calculate_transit_duration_scan(**_BIRTH, start_date="2026-07-01", end_date="2026-07-03") + assert scan["technique"] == "transit_duration_scan" + assert scan["days_scanned"] == 3 + assert len(scan["daily_hits"]) == 3 + assert isinstance(scan["windows"], list) + + +def test_parans_are_explicitly_blocked_until_solver_exists() -> None: + parans = calculate_parans_status(**_BIRTH, target_date="2026-07-09") + assert parans["technique"] == "parans" + assert parans["status"] == "blocked" + + +def test_timing_builder_can_emit_advanced_layers() -> None: + timing = build_timing_techniques( + **_BIRTH, + converse_secondary_progression_date="2026-07-09", + converse_solar_arc_date="2026-07-09", + midpoint_date="2026-07-09", + lunar_return_start_date="2026-07-01", + duration_scan_start_date="2026-07-01", + duration_scan_end_date="2026-07-02", + parans_date="2026-07-09", + ) + assert { + "converse_secondary_progressions", + "converse_solar_arc_directions", + "midpoints", + "lunar_return", + "transit_duration_scan", + "parans", + } <= set(timing)