From defa9fff524a2c50f0895023717477bea81db941 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Sun, 12 Jul 2026 17:46:19 +0800 Subject: [PATCH] harden prashna and tajika calculation boundaries --- docs/research/pre_work_error_ledger.md | 1 + jyotish-app/index.html | 14 ++++- jyotish-app/main.js | 23 ++++++-- references/technique_registry.json | 41 ++++++++++---- scripts/jyotish_api_server.py | 21 +++++++ scripts/jyotish_engine.py | 34 +++++++++++- scripts/prashna.py | 33 ++++++++++- scripts/prashna_context.py | 76 ++++++++++++++++++++++++++ scripts/saham_daynight.py | 41 ++++++++++++++ scripts/tajika.py | 53 +++++++++++++++--- scripts/tajika_kernel.py | 66 ++++++++++++++++++++++ tests/test_api_server_security.py | 8 +-- tests/test_prashna_context.py | 32 +++++++++++ tests/test_prashna_entry_contract.py | 60 ++++++++++++++++++++ tests/test_saham_daynight.py | 13 +++++ tests/test_tajika.py | 41 +++++++------- tests/test_tajika_kernel.py | 30 ++++++++++ 17 files changed, 535 insertions(+), 52 deletions(-) create mode 100644 scripts/prashna_context.py create mode 100644 scripts/saham_daynight.py create mode 100644 scripts/tajika_kernel.py create mode 100644 tests/test_prashna_context.py create mode 100644 tests/test_prashna_entry_contract.py create mode 100644 tests/test_saham_daynight.py create mode 100644 tests/test_tajika_kernel.py diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 7805f662..4030d7bf 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -80,6 +80,7 @@ For large architecture or release work, also read: | ERR-047 | Initial `slow` marker partition for `test_api_server_security.py` still exceeded the 120-second desktop budget; heavy paths extend beyond VedAstro/high-rigor prefix groups. | active profiling blocker | Profile test node IDs in bounded subprocess batches, mark only measured heavy tests, and keep fast-security acceptance separate from long CI integration coverage. | | ERR-048 | Candidate-time scanner assumed all documented D4/D24/D30 divisions were exposed by `jyotish_engine.py varga`; actual `--d4` failed at runtime. | mitigated 2026-07-12 | Candidate scans must record unsupported Varga flags as `unavailable_vargas`; only successfully computed D1/D9/D10 fields may drive local sensitivity output until a unified Varga contract exists. | | ERR-049 | PyJHora benchmark runner executed on `--help`, used a wrong repository-root path in `run_skill_baseline.py`, and failed when reused without pre-created output directories. | mitigated 2026-07-12 | Keep `tests/test_pyjhora_compare_cli.py`; require explicit `--build-local`, safe argparse help, correct repo root, and directory creation inside `run_sample()`. | +| ERR-050 | Prashna CLI/API/UI could synthesize or accept a non-question chart; legacy Tajika/Saham/Sphuta/Kunda paths also exposed approximate values as usable evidence. | mitigated 2026-07-12 | Require backend Swiss `PrashnaContext` with question text/time/location/timezone; reject client planets/ascendant. Block legacy Sphuta/Kunda/Gulika/Panchavargiya and no-location Saham paths; keep seven-planet Tajika interactions partial until named-yoga golden cases and formula parity exist. | ## Fragment Sweep Command Set diff --git a/jyotish-app/index.html b/jyotish-app/index.html index e1e869cc..01aaa3b1 100644 --- a/jyotish-app/index.html +++ b/jyotish-app/index.html @@ -614,10 +614,22 @@ +
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/references/technique_registry.json b/references/technique_registry.json index a49cafe1..b461279e 100644 --- a/references/technique_registry.json +++ b/references/technique_registry.json @@ -420,7 +420,7 @@ "upagraha", "event" ], - "status": "covered", + "status": "blocked", "knowledge_refs": [ "references/prashna-complete-guide.md" ], @@ -452,7 +452,7 @@ "timing", "life" ], - "status": "covered", + "status": "blocked", "knowledge_refs": [ "references/prashna-complete-guide.md", "references/single-event-inquiry-protocol.md" @@ -1136,7 +1136,7 @@ "event", "yoga" ], - "status": "complete", + "status": "partial", "knowledge_refs": [], "commands": [ "full-reading" @@ -1147,7 +1147,7 @@ "audit_label": "Tajika Yogas", "missing_impact": "Tajika year-chart yoga detection incomplete; annual timing confidence capped.", "version": "7.0", - "note": "v7.0 — 10种年度Yoga完整检测(Itasala/Ishkavala/Vasala/Tambira/Kambira/Dakshina/Vama/Ubhaya/Vedha/Kuta)+Radda+Tajika容许度+Vedha敏感点表+经典相位规则", + "note": "Production partial: legacy Tajika kernel is under replacement; no event verdict until seven-planet applying/separating and Deeptamsa golden cases pass.", "entry_type": "supporting_indicator", "evidence_role": "secondary", "user_visibility": "expert_audit", @@ -1166,7 +1166,7 @@ "event", "tajika" ], - "status": "covered", + "status": "partial", "knowledge_refs": [], "commands": [ "full-reading" @@ -1175,10 +1175,10 @@ "modules.sahams" ], "audit_label": "Sahams", - "missing_impact": "Sahams 已有输出,但 Tajika/事件定时里的解释层仍应作为辅助证据,不能单独承担精确落点结论。", - "limitation": "Sahams 当前已扩展到 36 个常见点位,但解释层、权重口径与传统软件黑盒一致性仍未完成系统外部对标,不宜单独作为精确事件定位依据。", + "missing_impact": "Sahams 仅在后端提供真实提问时刻与地点、并经 Swiss 日出日落判定时可计算;仍不能单独承担精确落点结论。", + "limitation": "历史无地点/太阳落宫昼夜代理已阻断。公式级 +30 度例外、去重与传统软件 oracle 对标未完成,因此保持 partial。", "version": "6.9.3", - "note": "v6.9.3 — tajika.py 从 7 扩展到 36 种 Saham(含 Putra/Jnana/Moksha/Dhan/Maya 等),但解释成熟度仍以 covered 管理", + "note": "仅允许带时间地点的 Swiss 日出日落上下文;公式例外与 oracle 对标未完成。", "entry_type": "supporting_indicator", "evidence_role": "secondary", "user_visibility": "expert_audit", @@ -1189,6 +1189,23 @@ }, "conclusion_policy": "Supporting evidence only; it can raise/lower confidence but cannot by itself decide an event or timing claim." }, + "panchavargiya_bala": { + "name": "Panchavargiya Bala / 五分盘力量", + "domains": ["tajika", "strength", "varga"], + "status": "blocked", + "knowledge_refs": [], + "commands": ["full-reading"], + "output_paths": ["modules.tajika_strength"], + "audit_label": "Panchavargiya Bala", + "missing_impact": "私有分盘代理已禁用;在统一 varga-full 核心与外部 golden oracle 完成前,不提供 Panchavargiya 或混合强度结论。", + "version": "7.0", + "note": "blocked pending unified Varga core and oracle parity", + "entry_type": "supporting_indicator", + "evidence_role": "secondary", + "user_visibility": "expert_audit", + "verification_level": {"calculation": "blocked", "rule": "blocked", "prediction": "blocked"}, + "conclusion_policy": "Do not render a strength verdict." + }, "darakaraka": { "name": "Darakaraka (DK) / 配偶星", "domains": [ @@ -1863,7 +1880,7 @@ "standalone", "full_reading_integration" ], - "status": "complete", + "status": "blocked", "knowledge_refs": [], "commands": [ "full-reading", @@ -1875,7 +1892,7 @@ "audit_label": "Prashna Integration", "missing_impact": "Prashna 已接入 full-reading,但问事分支的传统判断链、时机裁决与外部实盘闭环仍不均匀。", "version": "7.0", - "note": "v7.0 — KP Sublord完整3层判定(Star/Sub/Sub-Sub)+Nadi Prashna(Moon-Jupiter角度)+Sphuta(Gulika/Yamaghantaka)+时机评分系统+dashaflow/jyotishganit参考对齐", + "note": "Production blocked: question-moment Swiss chart is available, but classical adjudication is disabled pending kernel and golden-case validation.", "entry_type": "composite_adjudicator", "evidence_role": "context", "user_visibility": "ordinary_topic_router", @@ -2188,9 +2205,9 @@ "conclusion_policy": "Supporting evidence only; it can raise/lower confidence but cannot by itself decide an event or timing claim." }, "prashna": { - "status": "covered", + "status": "blocked", "version": "6.9.3", - "note": "prashna.py — KP sublord答案+Arudha+12问事分类", + "note": "Production blocked pending validated Prashna kernel; only backend-computed question-moment context is available.", "name": "prashna.py", "domains": [ "prashna" diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 3cfe9012..e50edc23 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -4944,6 +4944,27 @@ 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 + 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. question_type = body.get('question', 'general') if not isinstance(question_type, str): raise BadRequest('question must be a string') diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 6a663f6b..17f4cda8 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -5878,6 +5878,33 @@ def cmd_full_reading(args): # ============================================================================ def cmd_prashna(args): """Prashna 问事占星:基于提问时刻的即时星盘分析""" + try: + from prashna_context import PrashnaContextError, build_prashna_context + except ImportError: + 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 + + # Legacy fallback below is intentionally unreachable until removed after migration. try: from prashna import cast_prashna, calc_arudha, calc_sphutas, calc_life_sphutas, calc_sahams, analyze_lost_item, kunda_verify, calc_gulika_simple except ImportError: @@ -6184,9 +6211,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.py b/scripts/prashna.py index 14780f8c..c3b1dd6b 100644 --- a/scripts/prashna.py +++ b/scripts/prashna.py @@ -836,6 +836,14 @@ def calc_arudha(asc_lon: float, planet_lons: Dict) -> Dict: def calc_sphutas(planet_lons: Dict, asc_lon: float = 0.0) -> Dict: + """Blocked until exact Gulika and PrashnaContext support are available.""" + return { + "status": "blocked", + "reason": "exact_gulika_required_for_sphuta_calculation", + "blocked_layers": ["Gulika", "Trisphuta", "Catusphuta", "Pancasphuta"], + } + + # Legacy approximate implementation retained below only for source history. sun = _planet_lon(planet_lons, "Sun") moon = _planet_lon(planet_lons, "Moon") rahu = _planet_lon(planet_lons, "Rahu") @@ -875,6 +883,14 @@ def calc_life_sphutas(asc_lon: float, moon_lon: float, sun_lon: float, gulika_lo def calc_sahams(planet_lons: Dict, asc_lon: float) -> Dict: + """Blocked legacy entry: it lacks question time and location.""" + return { + "status": "blocked", + "reason": "question_timestamp_and_location_required_for_sahams", + "blocked_layers": ["Sahams"], + } + + # Legacy formulas retained below only for source history. sun = _planet_lon(planet_lons, "Sun") moon = _planet_lon(planet_lons, "Moon") mars = _planet_lon(planet_lons, "Mars") @@ -943,6 +959,14 @@ def analyze_lost_item(planet_lons: Dict, asc_lon: float) -> Dict: def kunda_verify(asc_lon: float) -> Dict: + """Blocked until the documented Lagna-arc x 81 calculation is implemented.""" + return { + "status": "blocked", + "reason": "exact_kunda_lagna_arc_verification_not_implemented", + "blocked_layers": ["Kunda"], + } + + # Legacy Pada-only proxy retained below only for source history. nak_idx = int(_norm(asc_lon) / NAK_SPAN) % 27 pada = int((_norm(asc_lon) % NAK_SPAN) / (NAK_SPAN / 4)) + 1 strength = "清晰" if pada in (2, 3) else "需复核" @@ -956,7 +980,14 @@ def kunda_verify(asc_lon: float) -> Dict: def cast_prashna(question_datetime: str, lat: float = 0.0, lon: float = 0.0) -> Dict: - """Dependency-free fallback Prashna chart for legacy CLI paths.""" + """Blocked legacy fallback; production callers must use PrashnaContext.""" + return { + "status": "blocked", + "reason": "deterministic_prashna_fallback_removed_use_prashna_context", + "required_entry": "scripts.prashna_context.build_prashna_context", + } + + # Legacy deterministic positions retained below only for source history. try: dt = datetime.fromisoformat(str(question_datetime).replace(" ", "T")) except ValueError: diff --git a/scripts/prashna_context.py b/scripts/prashna_context.py new file mode 100644 index 00000000..9ab327b1 --- /dev/null +++ b/scripts/prashna_context.py @@ -0,0 +1,76 @@ +"""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 + + +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 + 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"], + "blocked_layers": ["Gulika", "Trisphuta", "Kunda", "Prashna verdict"], + "boundary": "No client-supplied planets or ascendant are accepted. Approximate Prashna layers are blocked pending validated implementations.", + } 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.py b/scripts/tajika.py index b6912e38..14d8b800 100644 --- a/scripts/tajika.py +++ b/scripts/tajika.py @@ -255,13 +255,16 @@ def calc_tajika_strength_layers( asc_lon: float = 0.0, year_lord: Optional[str] = None, ) -> Dict: - """ - 计算 Varshaphala 用户端所需的 Harsha Bala 与 Panchavargiya Bala 摘要层。 + """Block the legacy private-Varga strength proxy pending parity evidence.""" + return { + 'status': 'blocked', + 'method': 'Tajika Harsha/Panchavargiya Bala', + 'reason': 'panchavargiya_requires_unified_varga_core_and_golden_oracle_parity', + 'blocked_layers': ['Panchavargiya Bala', 'combined Tajika strength'], + 'available_planets': len(planet_lons or {}), + } - 该函数优先服务产品解释链:保留每颗星的分项分、等级和下一步提示。 - Panchavargiya 使用 Rasi、Hora、Drekkana、Navamsa、Dwadashamsa 五层分盘尊贵度 - 作为稳定代理;若分盘模块不可用,则使用本地经度推导,避免年度 API 断链。 - """ + # Legacy local Varga proxy retained below only for source history. normalized = { planet: float(planet_lons[planet]) % 360 for planet in CLASSICAL_PLANETS @@ -551,6 +554,19 @@ def calc_tajika_yogas(planet_lons: Dict[str, float], 'summary': str, # 总结 } """ + from tajika_kernel import calculate_tajika_interactions + if not all(isinstance(value, dict) for value in planet_lons.values()): + return { + 'status': 'blocked', + 'reason': 'legacy_tajika_input_lacks_planet_speeds', + 'yogas': [], 'ithasala': [], 'easarapha': [], 'nakta': [], 'yamaya': [], 'manahoo': [], 'graha_yuddha': [], + 'summary': 'Blocked: legacy Tajika input lacks planet speeds.', + 'nodes_excluded': True, + } + kernel = calculate_tajika_interactions(planet_lons) + return {**kernel, 'yogas': [], 'ithasala': [], 'easarapha': [], 'nakta': [], 'yamaya': [], 'manahoo': [], 'graha_yuddha': [], 'summary': kernel['boundary']} + + # Historical implementation below is unreachable pending deletion. if planet_lats is None: planet_lats = {} @@ -801,6 +817,14 @@ def calc_sahams(birth_dt: datetime, 'parakrama_saham': {...}, # 勇气点 } """ + return { + 'status': 'blocked', + 'reason': 'legacy_saham_entry_uses_house_based_daynight_proxy', + 'required_entry': 'calc_all_sahams(..., lat=..., lon=..., tz=...)', + 'blocked_layers': ['Sahams'], + } + + # Legacy approximation retained below only for source history. results = {} # 通用Saham计算公式(Tajika系统): @@ -844,7 +868,10 @@ def _is_daytime(birth_dt: datetime, sun_lon: float, asc_lon: float) -> bool: def calc_all_sahams(planet_lons: Dict[str, float], asc_lon: float, birth_dt: datetime, - chart_type: str = 'natal') -> Dict: + chart_type: str = 'natal', + lat: float | None = None, + lon: float | None = None, + tz: float | None = None) -> Dict: """ 计算所有主要Sahams(完整版)。 @@ -857,6 +884,14 @@ def calc_all_sahams(planet_lons: Dict[str, float], 返回: 完整Sahams字典 """ + if lat is None or lon is None or tz is None: + return { + 'status': 'blocked', + 'reason': 'saham_daynight_requires_wgs84_location_and_timezone', + 'boundary': 'No solar-house day/night proxy is permitted in production.', + } + from saham_daynight import determine_daytime + daynight = determine_daytime(birth_dt, lat=float(lat), lon=float(lon), tz=float(tz)) sun_lon = planet_lons.get('Sun', 0) moon_lon = planet_lons.get('Moon', 0) mars_lon = planet_lons.get('Mars', 0) @@ -867,9 +902,9 @@ def calc_all_sahams(planet_lons: Dict[str, float], def _saham(p1_lon, p2_lon): return (asc_lon + (p2_lon - p1_lon)) % 360 - is_day = _is_daytime(birth_dt, sun_lon, asc_lon) + is_day = daynight['is_daytime'] - results = {} + results = {'status': 'partial', 'daynight_evidence': daynight} computed_formula_sahams: Dict[str, float] = {} # 1. Punya Saham(福德点):Moon - Sun + Asc diff --git a/scripts/tajika_kernel.py b/scripts/tajika_kernel.py new file mode 100644 index 00000000..5f39bedf --- /dev/null +++ b/scripts/tajika_kernel.py @@ -0,0 +1,66 @@ +"""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, + "blocked_named_yogas": ["Nakta", "Yamaya", "Manahoo", "Ithasala chain"], + "boundary": "Only aspect/applying evidence is computed. Named Tajika yoga chains and verdicts remain blocked pending classic golden cases.", + } diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index a58e2205..a701f10f 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -951,13 +951,13 @@ def test_prashna_advanced_legacy_functions_exist() -> None: 'Rahu': 300, 'Ketu': 120, } - assert prashna.cast_prashna('2026-06-22 12:00', 28.6, 77.2)['ascendant'] + assert prashna.cast_prashna('2026-06-22 12:00', 28.6, 77.2)['status'] == 'blocked' assert prashna.calc_arudha(15.5, planet_lons)['arudha_house'] - assert prashna.calc_sphutas(planet_lons, 15.5)['trisphuta'] + assert prashna.calc_sphutas(planet_lons, 15.5)['status'] == 'blocked' assert prashna.calc_life_sphutas(15.5, 70, 10)['signal'] - assert prashna.calc_sahams(planet_lons, 15.5)['count'] >= 5 + assert prashna.calc_sahams(planet_lons, 15.5)['status'] == 'blocked' assert prashna.analyze_lost_item(planet_lons, 15.5)['summary'] - assert prashna.kunda_verify(15.5)['nakshatra'] + assert prashna.kunda_verify(15.5)['status'] == 'blocked' def test_dasha_system_rejects_unknown_key() -> None: 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..a498053d --- /dev/null +++ b/tests/test_prashna_entry_contract.py @@ -0,0 +1,60 @@ +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 "Gulika" 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_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.py b/tests/test_tajika.py index c9cd6597..930b0dfa 100644 --- a/tests/test_tajika.py +++ b/tests/test_tajika.py @@ -122,13 +122,9 @@ class TestTajikaStrengthLayers: result = calc_tajika_strength_layers(planet_lons, asc_lon=15.0, year_lord='Jupiter') + assert result['status'] == 'blocked' assert result['method'] == 'Tajika Harsha/Panchavargiya Bala' - assert result['available_planets'] == 7 - assert 'harsha_bala' in result - assert 'panchavargiya_bala' in result - assert 'combined_strength' in result - assert result['summary']['strongest_planets'] - assert result['summary']['weakest_planets'] + assert 'Panchavargiya Bala' in result['blocked_layers'] assert result['summary']['next_action'] for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'): @@ -141,51 +137,52 @@ class TestTajikaStrengthLayers: # ── Tajika Yogas Tests ───────────────────────────────────────────── class TestTajikaYogas: - def test_ithasala_detected(self): + def test_legacy_adapter_blocks_without_speeds(self): # Moon(15.0) and Mercury(14.5) in same sign, close degrees planet_lons = {'Moon': 15.0, 'Mercury': 14.5, 'Sun': 45.0, 'Mars': 90.0, 'Jupiter': 180.0, 'Venus': 210.0, 'Saturn': 300.0} result = calc_tajika_yogas(planet_lons) + assert result['status'] == 'blocked' + assert result['ithasala'] == [] assert len(result['ithasala']) >= 0 # May or may not detect based on rules - def test_graha_yuddha_detected(self): + def test_graha_yuddha_is_not_inferred_by_legacy_adapter(self): # Mercury and Venus very close planet_lons = {'Sun': 45.0, 'Moon': 120.0, 'Mars': 90.0, 'Mercury': 100.0, 'Venus': 100.5, 'Jupiter': 180.0, 'Saturn': 300.0} result = calc_tajika_yogas(planet_lons) - assert len(result['graha_yuddha']) >= 1 + assert result['graha_yuddha'] == [] - def test_nakta_yoga_sun_moon_same_sign(self): + def test_nakta_is_not_inferred_from_sun_moon_co_sign(self): planet_lons = {'Sun': 15.0, 'Moon': 18.0, 'Mars': 90.0, 'Mercury': 60.0, 'Jupiter': 180.0, 'Venus': 210.0, 'Saturn': 300.0} result = calc_tajika_yogas(planet_lons) - assert result['nakta'] is not None + assert result['nakta'] == [] def test_summary_present(self): planet_lons = {'Sun': 45.0, 'Moon': 120.0, 'Mars': 90.0, 'Mercury': 60.0, 'Jupiter': 180.0, 'Venus': 210.0, 'Saturn': 300.0} result = calc_tajika_yogas(planet_lons) assert 'summary' in result - assert 'Tajika' in result['summary'] + assert 'Blocked' in result['summary'] class TestDetectTajikaYogas: - def test_10_yoga_types_detected(self): + def test_legacy_detector_is_not_a_golden_case_oracle(self): # Use close-degree planets to force Ithasala detection planets = {'Sun': 14.0, 'Moon': 15.0, 'Mars': 90.0, 'Mercury': 14.8, 'Jupiter': 180.0, 'Venus': 210.0, 'Saturn': 300.0} yogas = detect_tajika_yogas(planets) types = set(y['type'] for y in yogas) - # At least Itasala or other types should be detected - assert len(yogas) >= 0 # May be 0 if no valid pair, that's OK + assert isinstance(yogas, list) - def test_itasala_type_present(self): + def test_legacy_detector_does_not_prove_itasala(self): # Moon(15) fast, Sun(14) slow → same sign, close planets = {'Sun': 14.0, 'Moon': 15.0, 'Mars': 90.0, 'Mercury': 60.0, 'Jupiter': 180.0, 'Venus': 210.0, 'Saturn': 300.0} yogas = detect_tajika_yogas(planets) types = set(y['type'] for y in yogas) - assert 'Itasala' in types or len(yogas) >= 0 + assert isinstance(types, set) def test_kuta_yoga_three_planets_same_sign(self): planets = {'Sun': 5.0, 'Moon': 8.0, 'Mars': 12.0, @@ -216,6 +213,10 @@ class TestVedhaDetection: # ── Sahams Tests ──────────────────────────────────────────────────── class TestSahams: + def test_sahams_block_without_location_context(self): + result = calc_all_sahams({'Sun': 45.0, 'Moon': 120.0}, 10.0, datetime(1990, 6, 15, 12, 0)) + assert result['status'] == 'blocked' + def test_tajika_module_exposes_saham_rules_reference_path(self): assert SAHAM_RULES_PATH.endswith('references/saham_rules.json') assert os.path.exists(SAHAM_RULES_PATH) @@ -226,7 +227,7 @@ class TestSahams: 'Saturn': 300.0, 'Rahu': 150.0, 'Ketu': 330.0} asc_lon = 10.0 birth_dt = datetime(1990, 6, 15, 10, 30) - result = calc_all_sahams(planet_lons, asc_lon, birth_dt) + result = calc_all_sahams(planet_lons, asc_lon, birth_dt, lat=39.9042, lon=116.4074, tz=8) assert 'punya_saham' in result assert 'karya_saham' in result assert 'vivah_saham' in result @@ -247,7 +248,7 @@ class TestSahams: planet_lons = {'Sun': sun, 'Moon': moon, 'Mars': 90.0, 'Mercury': 60.0, 'Jupiter': 180.0, 'Venus': 210.0, 'Saturn': 300.0, 'Rahu': 150.0, 'Ketu': 330.0} - result = calc_all_sahams(planet_lons, asc, datetime(1990, 6, 15, 12, 0)) + result = calc_all_sahams(planet_lons, asc, datetime(1990, 6, 15, 12, 0), lat=39.9042, lon=116.4074, tz=8) assert abs(result['punya_saham']['longitude'] - expected) < 0.01 def test_karma_saham_uses_reference_json_day_formula(self): @@ -257,7 +258,7 @@ class TestSahams: 'Mercury': 60.0, 'Jupiter': 180.0, 'Venus': 210.0, 'Saturn': 300.0, 'Rahu': 150.0, 'Ketu': 330.0} expected = (asc + (90.0 - 60.0)) % 360 - result = calc_all_sahams(planet_lons, asc, datetime(1990, 6, 15, 12, 0)) + result = calc_all_sahams(planet_lons, asc, datetime(1990, 6, 15, 12, 0), lat=39.9042, lon=116.4074, tz=8) assert abs(result['karma_saham']['longitude'] - expected) < 0.01 def test_is_faster_moon_vs_sun(self): diff --git a/tests/test_tajika_kernel.py b/tests/test_tajika_kernel.py new file mode 100644 index 00000000..e3ec6d16 --- /dev/null +++ b/tests/test_tajika_kernel.py @@ -0,0 +1,30 @@ +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" + 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"]