harden prashna and tajika calculation boundaries

This commit is contained in:
732642856
2026-07-12 17:46:19 +08:00
parent 50c936406a
commit defa9fff52
17 changed files with 535 additions and 52 deletions
+1
View File
@@ -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
+13 -1
View File
@@ -614,10 +614,22 @@
<label>具体问题</label>
<input type="text" id="prashna-question" maxlength="120" placeholder="例如:这个工作机会是否值得争取?">
</div>
<div class="form-group prashna-question">
<label>提问时刻(当地)</label>
<input type="datetime-local" id="prashna-timestamp">
</div>
<div class="form-group prashna-question">
<label>提问地点纬度 / 经度 / 时区</label>
<div class="form-row">
<input type="number" step="0.0001" id="prashna-lat" placeholder="纬度">
<input type="number" step="0.0001" id="prashna-lon" placeholder="经度">
<input type="number" step="0.5" id="prashna-timezone" placeholder="UTC 时区,例如 8">
</div>
</div>
<button class="btn-primary prashna-run" id="btn-run-prashna">问事分析</button>
</div>
<div id="prashna-result" class="prashna-result">
<p>Prashna 使用提问当下时刻与当前星盘数据判断具体问题,适合一次只问一个清晰问题</p>
<p>Prashna 使用提问当下时刻与地点由后端排盘;不使用本命盘替代问事盘</p>
</div>
<div id="prashna-case-workspace" class="prashna-case-workspace"></div>
</div>
+19 -4
View File
@@ -7177,6 +7177,10 @@ function renderPrashnaTab(chartData) {
const question = $('prashna-question');
const result = $('prashna-result');
const runBtn = $('btn-run-prashna');
const timestamp = $('prashna-timestamp');
const lat = $('prashna-lat');
const lon = $('prashna-lon');
const timezone = $('prashna-timezone');
if (!category || !question || !result || !runBtn) return;
const workflow = chartData?._consultationWorkflow;
renderPrashnaCaseWorkspace();
@@ -7191,6 +7195,14 @@ function renderPrashnaTab(chartData) {
runBtn.addEventListener('click', async () => {
const questionText = question.value.trim();
const questionType = category.value || 'general';
if (!questionText) {
result.innerHTML = '<p class="prashna-error">请填写一个明确问题。</p>';
return;
}
if (!timestamp?.value || !lat?.value || !lon?.value || !timezone?.value) {
result.innerHTML = '<p class="prashna-error">请填写提问时刻、纬度、经度与 UTC 时区。</p>';
return;
}
if (questionText.length > 120) {
result.innerHTML = '<p class="prashna-error">问题请控制在 120 字以内。</p>';
return;
@@ -7198,11 +7210,14 @@ function renderPrashnaTab(chartData) {
result.innerHTML = '<p>正在铸造 Prashna 问事盘...</p>';
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);
+29 -12
View File
@@ -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"
+21
View File
@@ -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')
+33 -1
View File
@@ -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新增)
+32 -1
View File
@@ -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:
+76
View File
@@ -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.",
}
+41
View File
@@ -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.",
}
+44 -9
View File
@@ -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
+66
View File
@@ -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.",
}
+4 -4
View File
@@ -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:
+32
View File
@@ -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"})
+60
View File
@@ -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
+13
View File
@@ -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"
+21 -20
View File
@@ -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):
+30
View File
@@ -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"]