feat(report): ship full-mode longform appendix beside the five-chapter report
Independent Staging Quality Gate / validate (push) Failing after 9m41s
Independent Staging Quality Gate / publish (push) Has been skipped

Web export now calls the same full pack as the long skill report and caches an owner-only Markdown download. Appendix failure stays unavailable and does not change the main report status.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-05 11:10:44 +08:00
parent 18a5a48843
commit bab0718700
42 changed files with 6289 additions and 88 deletions
+986
View File
@@ -0,0 +1,986 @@
"""PyJHora annual replay boundary.
PyJHora/JHora is treated as an external reference engine. This wrapper imports
it only inside `replay_pyjhora_annual`, preserves raw return shapes, and never
vendors or normalizes it into the native engine as a hard dependency.
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import subprocess
import sys
from datetime import datetime, timedelta
from importlib.metadata import PackageNotFoundError, version as _distribution_version
from importlib import import_module as _import_module
from inspect import signature
from pathlib import Path
from typing import Any, Callable
ENGINE = "PyJHora/JHora"
REPO_ROOT = Path(__file__).resolve().parents[1]
_TAJAKA_YEAR_LORD_MODULE = "jhora.horoscope.transit.tajaka"
_YEAR_LORD_MODULES = (
"jhora.horoscope.dhasa.annual.mudda",
"jhora.horoscope.dhasa.annual.patyayini",
)
_YEAR_LORD_CALLABLES = (
"panchadhikari_year_lord",
"get_panchadhikari_year_lord",
"get_year_lord",
"year_lord",
)
_ANNUAL_SURFACE_KEYS = (
"annual_chart_snapshot",
"annual_chart_speed_snapshot",
"mudda_dasha",
"patyayini_dasha",
"sahams",
"solar_return_boundary",
"tajika_yogas",
)
_PYJHORA_SAHAM_CALLABLES = (
"apamrithyu_saham", "artha_saham", "asha_saham", "bandhana_saham", "bandhu_saham",
"bhratri_saham", "gaurava_saham", "jadya_saham", "jalapatna_saham", "jeeva_saham",
"kali_saham", "karma_saham", "karyasiddhi_saham", "laabha_saham", "maathri_saham",
"mahatmaya_saham", "mitra_saham", "mrithyu_saham", "paradara_saham", "paradesa_saham",
"pithri_saham", "preethi_saham", "punya_saham", "puthra_saham", "rajya_saham",
"roga_sagam_1", "roga_saham", "samartha_saham", "santapa_saham", "sastra_saham",
"sathru_saham", "sraddha_saham", "vanika_saham", "vidya_saham", "vivaha_saham",
"vyaapaara_saham", "yasas_saham",
)
def probe_pyjhora_year_lord(
profile: dict[str, Any],
*,
birth_julian_day: float,
place: Any,
age: int | float,
import_module: Callable[[str], Any] = _import_module,
distribution_version: Callable[[str], str] = _distribution_version,
isolated_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Probe PyJHora for an annual Tajaka/Panchadhikari Year Lord API.
This is deliberately an external-engine observation. PyJHora's annual
modules are not assumed to implement Panchadhikari simply because they
expose other Varshaphala calculations. The public Tajaka callable is
preferred when present; annual-dasha modules remain a legacy fallback.
"""
replay = {
"status": "blocked",
"reason": "pyjhora_panchadhikari_year_lord_api_missing",
"missing_api": "Panchadhikari/Year Lord callable",
"probed_modules": [_TAJAKA_YEAR_LORD_MODULE, *_YEAR_LORD_MODULES],
"available_annual_callables": [],
"raw": None,
"normalized_candidates": [],
}
result = {
"engine": ENGINE,
"input_profile_id": profile.get("profile_id"),
"pyjhora_version": _pyjhora_version(distribution_version),
"license_boundary": {
"mode": "external_reference_only",
"dependency_required": False,
},
"evidence_scope": "pyjhora_behavior_only",
"parity_status": "not_multiengine_parity",
"year_lord_replay": replay,
}
replay_runner = isolated_replay or _run_isolated_tajaka_year_lord
isolated = replay_runner(profile, birth_julian_day, place, age)
if isolated.get("reason") != "pyjhora_tajaka_api_missing":
replay.update(isolated)
replay["normalized_candidates"] = _normalize_tajaka_candidates(replay.get("raw_candidates"))
if isolated.get("pyjhora_version"):
result["pyjhora_version"] = isolated["pyjhora_version"]
return result
modules: list[Any] = []
for module_name in _YEAR_LORD_MODULES:
try:
modules.append(import_module(module_name))
except Exception as exc:
replay["status"] = "blocked"
replay["reason"] = f"pyjhora_annual_module_import_failed:{module_name}:{exc.__class__.__name__}"
replay["missing_api"] = module_name
return result
for module in modules:
replay["available_annual_callables"].extend(
sorted(name for name in _YEAR_LORD_CALLABLES if callable(getattr(module, name, None)))
)
for name in _YEAR_LORD_CALLABLES:
candidate = getattr(module, name, None)
if not callable(candidate):
continue
return _call_year_lord_candidate(result, replay, candidate, name, birth_julian_day, place, age)
return result
def _run_isolated_tajaka_year_lord(
profile: dict[str, Any],
birth_julian_day: float,
place: Any,
age: int | float,
) -> dict[str, Any]:
settings = _replay_settings(profile)
request = {
"birth_julian_day": birth_julian_day,
"place": _place_payload(place),
"age": age,
"settings": settings,
}
request_hash = _request_hash(request)
command = [
sys.executable,
"-c",
(
"import json, sys; "
"from scripts.annual_pyjhora_replay import _isolated_tajaka_worker; "
"print(json.dumps(_isolated_tajaka_worker(json.load(sys.stdin)), sort_keys=True))"
),
]
try:
completed = subprocess.run(
command,
input=json.dumps(request, sort_keys=True),
capture_output=True,
text=True,
timeout=30,
check=False,
cwd=str(REPO_ROOT),
)
except subprocess.TimeoutExpired:
return _isolated_blocked(
"pyjhora_isolated_replay_timeout", request_hash, settings
)
except Exception as exc:
return _isolated_blocked(
f"pyjhora_isolated_replay_start_failed:{exc.__class__.__name__}", request_hash, settings
)
if completed.returncode != 0:
return _isolated_blocked(
f"pyjhora_isolated_replay_failed:exit_{completed.returncode}", request_hash, settings
)
try:
replay = json.loads(completed.stdout)
except json.JSONDecodeError:
return _isolated_blocked("pyjhora_isolated_replay_invalid_json", request_hash, settings)
if not isinstance(replay, dict):
return _isolated_blocked("pyjhora_isolated_replay_invalid_payload", request_hash, settings)
replay["request_hash"] = request_hash
return replay
def _isolated_tajaka_worker(request: dict[str, Any]) -> dict[str, Any]:
"""Run in a child process so PyJHora global Ayanamsa state cannot leak."""
settings = dict(request.get("settings") or {})
node_mode = {
"requested": settings.get("node_mode"),
"status": "unsupported_by_replay_adapter",
}
ayanamsa = settings.get("ayanamsa")
if not isinstance(ayanamsa, str) or not ayanamsa.strip():
return {
"status": "blocked",
"reason": "pyjhora_ayanamsa_configuration_failed:missing_ayanamsa",
"settings": settings,
"node_mode": node_mode,
}
try:
# PyJHora can print import diagnostics; reserve stdout for the JSON reply.
with contextlib.redirect_stdout(sys.stderr):
from jhora import const
from jhora.panchanga import drik
from jhora.horoscope.transit import tajaka
drik.set_ayanamsa_mode(ayanamsa.upper())
effective_ayanamsa = const._DEFAULT_AYANAMSA_MODE
if str(effective_ayanamsa).upper() != ayanamsa.upper():
return {
"status": "blocked",
"reason": "pyjhora_ayanamsa_configuration_failed:effective_mode_mismatch",
"settings": settings,
"effective_ayanamsa": effective_ayanamsa,
"node_mode": node_mode,
}
except Exception as exc:
return {
"status": "blocked",
"reason": f"pyjhora_ayanamsa_configuration_failed:{exc.__class__.__name__}",
"settings": settings,
"node_mode": node_mode,
}
try:
place_data = dict(request["place"])
place = drik.Place(
place_data["name"],
float(place_data["latitude"]),
float(place_data["longitude"]),
float(place_data["timezone"]),
place_data.get("elevation"),
)
raw_candidates, candidate_trace_status = _tajaka_candidate_trace(
tajaka, float(request["birth_julian_day"]), place, request["age"]
)
raw = tajaka.lord_of_the_year(float(request["birth_julian_day"]), place, request["age"])
except Exception as exc:
return {
"status": "blocked",
"reason": f"pyjhora_tajaka_year_lord_failed:{exc.__class__.__name__}",
"callable": f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year",
"settings": settings,
"effective_ayanamsa": effective_ayanamsa,
"node_mode": node_mode,
"raw": None,
"raw_candidates": None,
}
return {
"status": "partial_verified",
"reason": "pyjhora_tajaka_year_lord_callable_observed",
"callable": f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year",
"pyjhora_version": _pyjhora_version(_distribution_version),
"settings": settings,
"effective_ayanamsa": effective_ayanamsa,
"node_mode": node_mode,
"year_lord": raw,
"raw": raw,
"raw_candidates": raw_candidates,
"candidate_trace_status": candidate_trace_status,
}
def _run_isolated_annual_replay(
profile: dict[str, Any],
birth_julian_day: float,
place: Any,
age: int | float,
) -> dict[str, Any]:
"""Call optional PyJHora annual surfaces with a child-process Place."""
settings = _replay_settings(profile)
request = {
"birth_julian_day": birth_julian_day,
"place": _place_payload(place),
"age": age,
"settings": settings,
}
request_hash = _request_hash(request)
command = [
sys.executable,
"-c",
(
"import json, sys; "
"from scripts.annual_pyjhora_replay import _isolated_annual_worker; "
"print(json.dumps(_isolated_annual_worker(json.load(sys.stdin)), sort_keys=True, default=str))"
),
]
try:
completed = subprocess.run(
command,
input=json.dumps(request, sort_keys=True),
capture_output=True,
text=True,
timeout=30,
check=False,
cwd=str(REPO_ROOT),
)
except subprocess.TimeoutExpired:
return _annual_replay_blocked("pyjhora_isolated_annual_replay_timeout", request_hash, settings)
except Exception as exc:
return _annual_replay_blocked(
f"pyjhora_isolated_annual_replay_start_failed:{exc.__class__.__name__}", request_hash, settings
)
if completed.returncode != 0:
return _annual_replay_blocked(
f"pyjhora_isolated_annual_replay_failed:exit_{completed.returncode}", request_hash, settings
)
try:
replay = json.loads(completed.stdout)
except json.JSONDecodeError:
return _annual_replay_blocked("pyjhora_isolated_annual_replay_invalid_json", request_hash, settings)
if not isinstance(replay, dict):
return _annual_replay_blocked("pyjhora_isolated_annual_replay_invalid_payload", request_hash, settings)
replay["request_hash"] = request_hash
return replay
def _isolated_annual_worker(request: dict[str, Any]) -> dict[str, Any]:
"""Construct ``drik.Place`` after setting Ayanamsa in the child process."""
settings = dict(request.get("settings") or {})
node_mode = {"requested": settings.get("node_mode"), "status": "unsupported_by_replay_adapter"}
ayanamsa = settings.get("ayanamsa")
if not isinstance(ayanamsa, str) or not ayanamsa.strip():
return _annual_worker_result(
{key: {"status": "blocked", "reason": "pyjhora_ayanamsa_configuration_failed:missing_ayanamsa"} for key in _ANNUAL_SURFACE_KEYS},
settings=settings,
node_mode=node_mode,
)
try:
with contextlib.redirect_stdout(sys.stderr):
from jhora import const
from jhora.panchanga import drik
from jhora.horoscope.dhasa.annual import mudda, patyayini
from jhora.horoscope.transit import saham
drik.set_ayanamsa_mode(ayanamsa.upper())
effective_ayanamsa = const._DEFAULT_AYANAMSA_MODE
if str(effective_ayanamsa).upper() != ayanamsa.upper():
return _annual_worker_result(
{key: {"status": "blocked", "reason": "pyjhora_ayanamsa_configuration_failed:effective_mode_mismatch"} for key in _ANNUAL_SURFACE_KEYS},
settings=settings,
node_mode=node_mode,
effective_ayanamsa=effective_ayanamsa,
)
place_data = dict(request["place"])
pyjhora_place = drik.Place(
place_data["name"], float(place_data["latitude"]), float(place_data["longitude"]),
float(place_data["timezone"]), place_data.get("elevation"),
)
except Exception as exc:
return _annual_worker_result(
{key: {"status": "blocked", "reason": f"pyjhora_annual_adapter_setup_failed:{exc.__class__.__name__}"} for key in _ANNUAL_SURFACE_KEYS},
settings=settings,
node_mode=node_mode,
)
jd = float(request["birth_julian_day"])
years = request["age"]
surfaces = {
"annual_chart_snapshot": _call_annual_chart_snapshot(jd, pyjhora_place, years),
"annual_chart_speed_snapshot": _call_annual_chart_speed_snapshot(jd, pyjhora_place, years),
"mudda_dasha": _call_mudda(mudda, jd, pyjhora_place, years),
"patyayini_dasha": _call_patyayini(patyayini, jd, pyjhora_place, years),
"sahams": _call_sahams(saham, jd, pyjhora_place, years),
"solar_return_boundary": _call_solar_return_boundary(saham, jd, pyjhora_place, years),
"tajika_yogas": _probe_tajika_yogas(jd, pyjhora_place, years),
}
return _annual_worker_result(
surfaces,
settings=settings,
node_mode=node_mode,
effective_ayanamsa=effective_ayanamsa,
)
def _annual_worker_result(
surfaces: dict[str, dict[str, Any]],
*,
settings: dict[str, Any],
node_mode: dict[str, Any],
effective_ayanamsa: Any = None,
) -> dict[str, Any]:
return {
**{key: surfaces.get(key, {"status": "blocked", "reason": "not_executed"}) for key in _ANNUAL_SURFACE_KEYS},
"settings": settings,
"effective_ayanamsa": effective_ayanamsa,
"node_mode": node_mode,
"evidence_scope": "pyjhora_behavior_only",
"parity_status": "not_multiengine_parity",
}
def _annual_replay_blocked(reason: str, request_hash: str, settings: dict[str, Any]) -> dict[str, Any]:
return _annual_worker_result(
{key: {"status": "blocked", "reason": reason} for key in _ANNUAL_SURFACE_KEYS},
settings=settings,
node_mode={"requested": settings.get("node_mode"), "status": "unsupported_by_replay_adapter"},
) | {"request_hash": request_hash}
def _place_payload(place: Any) -> dict[str, Any]:
return {
"name": getattr(place, "name", "External replay place"),
"latitude": getattr(place, "latitude"),
"longitude": getattr(place, "longitude"),
"timezone": getattr(place, "timezone"),
"elevation": getattr(place, "elevation", None),
}
def _request_hash(request: dict[str, Any]) -> str:
payload = json.dumps(request, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _isolated_blocked(reason: str, request_hash: str, settings: dict[str, Any]) -> dict[str, Any]:
return {
"status": "blocked",
"reason": reason,
"request_hash": request_hash,
"settings": settings,
"node_mode": {
"requested": settings.get("node_mode"),
"status": "unsupported_by_replay_adapter",
},
}
def _replay_settings(profile: dict[str, Any]) -> dict[str, Any]:
settings = profile.get("settings")
source = settings if isinstance(settings, dict) else profile
return {
key: source[key]
for key in ("ayanamsa", "node_mode")
if source.get(key) is not None
}
def _call_tajaka_year_lord(
result: dict[str, Any],
replay: dict[str, Any],
tajaka: Any,
*,
birth_julian_day: float,
place: Any,
age: int | float,
settings: dict[str, Any],
) -> dict[str, Any]:
callable_name = f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year"
try:
raw_candidates, candidate_trace_status = _tajaka_candidate_trace(
tajaka, birth_julian_day, place, age
)
raw = tajaka.lord_of_the_year(birth_julian_day, place, age)
except Exception as exc:
replay.update(
{
"status": "blocked",
"reason": f"pyjhora_tajaka_year_lord_failed:{exc.__class__.__name__}",
"missing_api": None,
"callable": callable_name,
"settings": settings,
"raw": None,
"raw_candidates": None,
}
)
return result
replay.update(
{
"status": "partial_verified",
"reason": "pyjhora_tajaka_year_lord_callable_observed",
"missing_api": None,
"callable": callable_name,
"settings": settings,
"year_lord": raw,
"raw": raw,
"raw_candidates": raw_candidates,
"candidate_trace_status": candidate_trace_status,
"normalized_candidates": _normalize_tajaka_candidates(raw_candidates),
}
)
return result
def _tajaka_candidate_trace(
tajaka: Any,
birth_julian_day: float,
place: Any,
age: int | float,
) -> tuple[Any, str]:
"""Collect the source module's candidate list without reimplementing its rule."""
candidate_function = getattr(tajaka, "_get_lord_candidates", None)
if not callable(candidate_function):
return None, "unavailable"
try:
# Lightweight injected test modules can expose a direct trace helper.
return candidate_function(), "observed"
except TypeError:
pass
try:
rasi_chart = tajaka.charts.divisional_chart(
birth_julian_day, place, divisional_chart_factor=1
)
natal_planet_to_house = tajaka.utils.get_planet_house_dictionary_from_planet_positions(rasi_chart)
natal_lagna_house = natal_planet_to_house[tajaka.const._ascendant_symbol]
annual_jd = birth_julian_day + float(age) * tajaka.year_value
annual_hour = tajaka.drik.jd_to_gregorian(annual_jd)[3]
sunrise = tajaka.utils.from_dms_str_to_dms(tajaka.drik.sunrise(annual_jd, place)[1])
sunset = tajaka.utils.from_dms_str_to_dms(tajaka.drik.sunset(annual_jd, place)[1])
sunrise_hour = sunrise[0] + sunrise[1] / 60 + sunrise[2] / 3600
sunset_hour = sunset[0] + sunset[1] / 60 + sunset[2] / 3600
night_time_birth = annual_hour > sunset_hour or annual_hour < sunrise_hour
annual_chart = tajaka.charts.divisional_chart(annual_jd, place, divisional_chart_factor=1)
return candidate_function(annual_chart, age, natal_lagna_house, night_time_birth), "observed"
except Exception as exc:
return None, f"blocked:{exc.__class__.__name__}"
def _normalize_tajaka_candidates(raw_candidates: Any) -> list[dict[str, Any]]:
if not isinstance(raw_candidates, (list, tuple)):
return []
return [
{"planet_index": candidate, "selection_state": "raw_tajaka_candidate"}
for candidate in raw_candidates
if isinstance(candidate, int)
]
def _pyjhora_version(distribution_version: Callable[[str], str]) -> str | None:
try:
return distribution_version("PyJHora")
except PackageNotFoundError:
return None
def _call_year_lord_candidate(
result: dict[str, Any],
replay: dict[str, Any],
candidate: Callable[..., Any],
callable_name: str,
birth_julian_day: float,
place: Any,
age: int | float,
) -> dict[str, Any]:
values = {
"jd": birth_julian_day,
"birth_julian_day": birth_julian_day,
"place": place,
"age": age,
"years": age,
}
try:
params = signature(candidate).parameters
kwargs = {name: values[name] for name in params if name in values}
required = [
name for name, parameter in params.items()
if parameter.default is parameter.empty
and parameter.kind in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY)
and name not in kwargs
]
if required:
replay.update(
{
"reason": "pyjhora_year_lord_callable_signature_unsupported",
"missing_api": ", ".join(required),
"callable": callable_name,
}
)
return result
raw = candidate(**kwargs)
except Exception as exc:
replay.update(
{
"reason": f"pyjhora_year_lord_callable_failed:{callable_name}:{exc.__class__.__name__}",
"missing_api": None,
"callable": callable_name,
}
)
return result
replay.update(
{
"status": "partial_verified",
"reason": "pyjhora_year_lord_callable_observed",
"missing_api": None,
"callable": callable_name,
"year_lord": raw.get("year_lord") if isinstance(raw, dict) else None,
"raw": raw,
"normalized_candidates": _normalize_year_lord_candidates(raw),
}
)
return result
def _normalize_year_lord_candidates(raw: Any) -> list[dict[str, Any]]:
if not isinstance(raw, dict):
return []
candidates = raw.get("candidates") or raw.get("candidate_planets")
if not isinstance(candidates, list):
return []
return [candidate for candidate in candidates if isinstance(candidate, dict)]
def replay_pyjhora_annual(
profile: dict[str, Any],
*,
birth_julian_day: float,
place: Any,
age: int | float,
import_module: Callable[[str], Any] = _import_module,
year_lord_probe: Callable[..., dict[str, Any]] | None = None,
isolated_annual_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Replay annual PyJHora methods for the same profile, if available."""
year_lord_observer = year_lord_probe or probe_pyjhora_year_lord
year_lord_result = year_lord_observer(
profile,
birth_julian_day=birth_julian_day,
place=place,
age=age,
)
year_lord_replay = year_lord_result.get("year_lord_replay") if isinstance(year_lord_result, dict) else None
blocked = _base(profile)
blocked["year_lord_replay"] = year_lord_replay if isinstance(year_lord_replay, dict) else {
"status": "blocked",
"reason": "pyjhora_year_lord_probe_invalid_payload",
}
blocked["evidence_scope"] = "pyjhora_behavior_only"
blocked["parity_status"] = "not_multiengine_parity"
if isolated_annual_replay is not None or (import_module is _import_module and year_lord_probe is None):
observer = isolated_annual_replay or _run_isolated_annual_replay
observed = observer(profile, birth_julian_day, place, age)
if not isinstance(observed, dict):
observed = _annual_replay_blocked(
"pyjhora_isolated_annual_replay_invalid_payload",
_request_hash({"birth_julian_day": birth_julian_day, "place": _place_payload(place), "age": age}),
_replay_settings(profile),
)
result = _base(profile)
for key in _ANNUAL_SURFACE_KEYS:
value = observed.get(key)
result[key] = value if isinstance(value, dict) else {"status": "blocked", "reason": f"{key}_missing_from_isolated_replay"}
result["year_lord_replay"] = blocked["year_lord_replay"]
for key in ("effective_ayanamsa", "node_mode", "request_hash"):
if key in observed:
result[key] = observed[key]
result["evidence_scope"] = "pyjhora_behavior_only"
result["parity_status"] = "not_multiengine_parity"
result["status"] = _aggregate_status(result)
return result
try:
mudda = import_module("jhora.horoscope.dhasa.annual.mudda")
patyayini = import_module("jhora.horoscope.dhasa.annual.patyayini")
saham = import_module("jhora.horoscope.transit.saham")
except Exception as exc:
reason = f"pyjhora_import_failed:{exc.__class__.__name__}:{exc}"
for key in _ANNUAL_SURFACE_KEYS:
blocked[key] = {"status": "blocked", "reason": reason}
blocked["status"] = "blocked"
return blocked
result = _base(profile)
result["annual_chart_snapshot"] = _call_annual_chart_snapshot(birth_julian_day, place, age)
result["mudda_dasha"] = _call_mudda(mudda, birth_julian_day, place, age)
result["patyayini_dasha"] = _call_patyayini(patyayini, birth_julian_day, place, age)
result["sahams"] = _call_sahams(saham, birth_julian_day, place, age)
result["solar_return_boundary"] = _call_solar_return_boundary(saham, birth_julian_day, place, age)
result["tajika_yogas"] = {"status": "blocked", "reason": "pyjhora_tajika_yogas_requires_isolated_replay"}
result["year_lord_replay"] = year_lord_replay if isinstance(year_lord_replay, dict) else {
"status": "blocked",
"reason": "pyjhora_year_lord_probe_invalid_payload",
}
result["evidence_scope"] = "pyjhora_behavior_only"
result["parity_status"] = "not_multiengine_parity"
result["status"] = _aggregate_status(result)
return result
def _base(profile: dict[str, Any]) -> dict[str, Any]:
return {
"engine": ENGINE,
"license_boundary": {
"mode": "external_reference_only",
"dependency_required": False,
"notes": "PyJHora/JHora remains an optional external oracle/replay boundary.",
},
"input_profile_id": profile.get("profile_id"),
"annual_chart_snapshot": {"status": "blocked", "reason": "not_executed"},
"annual_chart_speed_snapshot": {"status": "blocked", "reason": "not_executed"},
"mudda_dasha": {"status": "blocked", "reason": "not_executed"},
"patyayini_dasha": {"status": "blocked", "reason": "not_executed"},
"sahams": {"status": "blocked", "reason": "not_executed"},
"solar_return_boundary": {"status": "blocked", "reason": "not_executed"},
"tajika_yogas": {"status": "blocked", "reason": "not_executed"},
"year_lord_replay": {"status": "blocked", "reason": "not_executed"},
"evidence_scope": "pyjhora_behavior_only",
"parity_status": "not_multiengine_parity",
"status": "blocked",
}
def _call_mudda(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
payload: dict[str, Any] = {}
try:
if hasattr(module, "varsha_vimsottari_dasha_start_date"):
payload["varsha_vimsottari_start"] = module.varsha_vimsottari_dasha_start_date(
birth_julian_day, place, age
)
if hasattr(module, "mudda_dhasa_bhukthi"):
payload["periods"] = module.mudda_dhasa_bhukthi(birth_julian_day, place, age)
return {"status": "partial_verified", "raw": payload}
except Exception as exc:
return {"status": "blocked", "reason": f"pyjhora_mudda_failed:{exc}"}
def _call_patyayini(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
try:
jd_for_year = birth_julian_day + float(age) * 365.256364
return {"status": "partial_verified", "raw": {"periods": module.get_dhasa_bhukthi(jd_for_year, place)}}
except Exception as exc:
return {"status": "blocked", "reason": f"pyjhora_patyayini_failed:{exc}"}
def _call_sahams(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
"""Replay the current PyJHora per-Saham API against its annual chart.
Older PyJHora releases exposed an aggregate ``sahams`` function. Current
releases expose individual functions which all consume annual-chart planet
positions. Keep both paths external-reference-only and preserve the exact
source API used for audit.
"""
try:
individual = [name for name in _PYJHORA_SAHAM_CALLABLES if callable(getattr(module, name, None))]
if not individual:
if hasattr(module, "sahams"):
return {
"status": "partial_verified",
"callable": "jhora.horoscope.transit.saham.sahams",
"raw": module.sahams(birth_julian_day, place, age),
"reason": "legacy_pyjhora_aggregate_sahams_api",
}
return {"status": "blocked", "reason": "pyjhora_sahams_callable_missing"}
tajaka = _import_module("jhora.horoscope.transit.tajaka")
annual_chart = getattr(tajaka, "annual_chart", None)
if not callable(annual_chart):
return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"}
chart_payload = annual_chart(birth_julian_day, place, years=age)
if not isinstance(chart_payload, tuple) or len(chart_payload) < 2 or not isinstance(chart_payload[0], list):
return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"}
positions, return_marker = chart_payload[0], chart_payload[1]
annual_moment = _annual_return_datetime(return_marker)
if annual_moment is None:
return {"status": "blocked", "reason": "pyjhora_annual_chart_return_time_unavailable"}
try:
try:
from scripts.saham_daynight import determine_daytime
except ImportError: # pragma: no cover - direct script execution
from saham_daynight import determine_daytime
daynight = determine_daytime(
annual_moment,
lat=float(place.latitude),
lon=float(place.longitude),
tz=float(place.timezone),
)
except Exception as exc:
return {"status": "blocked", "reason": f"pyjhora_sahams_daynight_failed:{exc.__class__.__name__}"}
values: dict[str, Any] = {}
failures: dict[str, str] = {}
for name in individual:
candidate = getattr(module, name)
try:
parameters = signature(candidate).parameters
if "night_time_birth" in parameters:
values[name] = candidate(positions, night_time_birth=not bool(daynight["is_daytime"]))
else:
values[name] = candidate(positions)
except Exception as exc: # External API variations must remain visible.
failures[name] = exc.__class__.__name__
if not values:
return {
"status": "blocked",
"reason": "pyjhora_individual_sahams_all_failed",
"failed_callables": failures,
}
return {
"status": "partial_verified",
"callable": "jhora.horoscope.transit.tajaka.annual_chart",
"reason": "pyjhora_individual_sahams_from_annual_chart",
"daynight": daynight,
"raw": {
"annual_return": _annual_return_marker(return_marker),
"sahams": values,
"failed_callables": failures,
"available_callables": individual,
},
}
except Exception as exc:
return {"status": "blocked", "reason": f"pyjhora_sahams_failed:{exc}"}
def _call_annual_chart_snapshot(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
"""Capture PyJHora's annual chart without deriving a local interpretation.
This is an external-reference snapshot for profile diagnosis. It exists so
annual Tajika comparisons can distinguish a chart-input difference from a
candidate-rule difference. It is not a report authority or a Yoga result.
"""
try:
tajaka = _import_module("jhora.horoscope.transit.tajaka")
annual_chart = getattr(tajaka, "annual_chart", None)
if not callable(annual_chart):
return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"}
payload = annual_chart(birth_julian_day, place, years=age)
if not isinstance(payload, tuple) or len(payload) < 2 or not isinstance(payload[0], list):
return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"}
positions, return_marker = payload[0], payload[1]
return {
"status": "partial_verified",
"callable": "jhora.horoscope.transit.tajaka.annual_chart",
"reason": "pyjhora_annual_chart_snapshot_for_profile_comparison",
"raw": {
"annual_return": _annual_return_marker(return_marker),
"positions": positions,
},
"claim_boundary": "external_annual_chart_snapshot_is_not_tajika_yoga_or_report_authority",
}
except Exception as exc:
return {"status": "blocked", "reason": f"pyjhora_annual_chart_snapshot_failed:{exc.__class__.__name__}"}
def _signed_longitude_delta(previous: float, following: float) -> float:
return (float(following) - float(previous) + 180.0) % 360.0 - 180.0
def _planet_longitudes_from_positions(positions: Any) -> dict[int, float]:
values: dict[int, float] = {}
for row in positions if isinstance(positions, list) else []:
if not isinstance(row, (list, tuple)) or len(row) < 2 or not isinstance(row[0], int):
continue
planet, position = row[0], row[1]
if planet not in range(7) or not isinstance(position, (list, tuple)) or len(position) < 2:
continue
sign, degree = position[0], position[1]
if isinstance(sign, (int, float)) and isinstance(degree, (int, float)):
values[planet] = (float(sign) * 30.0 + float(degree)) % 360.0
return values
def _call_annual_chart_speed_snapshot(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
"""Observe PyJHora annual-chart motion by finite difference.
The upstream annual-chart snapshot exposes longitudes but not speeds. This
child-process-only probe samples one minute on either side of the external
return marker so a later diagnostic can distinguish chart coordinates from
applying/separating semantics. It is not a native speed source or Tajika
Yoga authority.
"""
try:
from jhora import utils
from jhora.panchanga import drik
tajaka = _import_module("jhora.horoscope.transit.tajaka")
annual_chart = getattr(tajaka, "annual_chart", None)
if not callable(annual_chart):
return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"}
payload = annual_chart(birth_julian_day, place, years=age)
if not isinstance(payload, tuple) or len(payload) < 2:
return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"}
annual_moment = _annual_return_datetime(payload[1])
if annual_moment is None:
return {"status": "blocked", "reason": "pyjhora_annual_chart_return_time_unavailable"}
offset_seconds = 60
before = annual_moment - timedelta(seconds=offset_seconds)
after = annual_moment + timedelta(seconds=offset_seconds)
before_jd = utils.julian_day_number(
(before.year, before.month, before.day), (before.hour, before.minute, before.second)
)
after_jd = utils.julian_day_number(
(after.year, after.month, after.day), (after.hour, after.minute, after.second)
)
before_positions = _planet_longitudes_from_positions(drik.dhasavarga(before_jd, place))
after_positions = _planet_longitudes_from_positions(drik.dhasavarga(after_jd, place))
if set(before_positions) != set(range(7)) or set(after_positions) != set(range(7)):
return {
"status": "blocked",
"reason": "pyjhora_annual_chart_speed_positions_incomplete",
"before_planets": sorted(before_positions),
"after_planets": sorted(after_positions),
}
interval_days = (2.0 * offset_seconds) / 86400.0
rates = {
planet: round(_signed_longitude_delta(before_positions[planet], after_positions[planet]) / interval_days, 8)
for planet in range(7)
}
return {
"status": "partial_verified",
"callable": "jhora.panchanga.drik.dhasavarga",
"reason": "pyjhora_annual_chart_finite_difference_speed_snapshot",
"raw": {
"annual_return": _annual_return_marker(payload[1]),
"sample_offset_seconds": offset_seconds,
"planet_rates_deg_per_day": rates,
},
"claim_boundary": "external_annual_speed_snapshot_is_comparison_only_not_tajika_yoga_or_report_authority",
}
except Exception as exc:
return {"status": "blocked", "reason": f"pyjhora_annual_chart_speed_snapshot_failed:{exc.__class__.__name__}"}
def _annual_return_marker(value: Any) -> dict[str, Any]:
if not isinstance(value, (list, tuple)) or len(value) < 2:
return {"raw": value}
date_value, time_value = value[0], value[1]
return {
"date": list(date_value) if isinstance(date_value, (list, tuple)) else date_value,
"time": str(time_value),
}
def _annual_return_datetime(value: Any) -> datetime | None:
marker = _annual_return_marker(value)
date_value = marker.get("date")
time_value = marker.get("time")
if not isinstance(date_value, list) or len(date_value) != 3 or not isinstance(time_value, str):
return None
try:
hour, minute, second = [int(part) for part in time_value.split(":")]
return datetime(int(date_value[0]), int(date_value[1]), int(date_value[2]), hour, minute, second)
except (TypeError, ValueError):
return None
def _call_solar_return_boundary(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
try:
if not hasattr(module, "solar_return_chart"):
return {"status": "blocked", "reason": "pyjhora_solar_return_chart_missing"}
return {
"status": "partial_verified",
"raw": module.solar_return_chart(birth_julian_day, place, age),
}
except Exception as exc:
return {"status": "blocked", "reason": f"pyjhora_solar_return_failed:{exc}"}
def _probe_tajika_yogas(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
"""Probe callable availability without recreating PyJHora yoga rules."""
try:
module = _import_module("jhora.horoscope.transit.tajaka_yoga")
except Exception as exc:
return {"status": "blocked", "reason": f"pyjhora_tajika_yogas_module_missing:{exc.__class__.__name__}"}
for name in ("tajaka_yoga", "tajika_yoga", "get_tajaka_yogas", "get_tajika_yogas"):
candidate = getattr(module, name, None)
if not callable(candidate):
continue
values = {"jd": birth_julian_day, "birth_julian_day": birth_julian_day, "place": place, "age": age, "years": age}
try:
params = signature(candidate).parameters
kwargs = {key: values[key] for key in params if key in values}
missing = [key for key, parameter in params.items() if parameter.default is parameter.empty and key not in kwargs]
if missing:
return {"status": "blocked", "reason": "pyjhora_tajika_yogas_callable_signature_unsupported", "callable": name, "missing": missing}
return {"status": "partial_verified", "callable": name, "raw": candidate(**kwargs)}
except Exception as exc:
return {"status": "blocked", "reason": f"pyjhora_tajika_yogas_callable_failed:{exc.__class__.__name__}", "callable": name}
return {"status": "blocked", "reason": "pyjhora_tajika_yogas_callable_missing"}
def _aggregate_status(result: dict[str, Any]) -> str:
statuses = {
result[key].get("status")
for key in (*_ANNUAL_SURFACE_KEYS, "year_lord_replay")
if isinstance(result.get(key), dict)
}
if statuses == {"partial_verified"}:
return "partial_verified"
if "partial_verified" in statuses:
return "partial_verified"
return "blocked"
+651
View File
@@ -0,0 +1,651 @@
"""Annual Varshaphala / Tajika report pack contract.
The pack normalizes existing annual producers into a stable, audit-friendly
shape. It does not adjudicate conflicting annual methods yet; that belongs to
the next conflict-gate layer.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import Any
try: # pragma: no cover - import path differs under CLI vs pytest
from calculation_profile_contract import build_calculation_profile
except ImportError: # pragma: no cover
from scripts.calculation_profile_contract import build_calculation_profile
try: # pragma: no cover - import path differs under CLI vs pytest
from tajika_named_yoga_authority import build_tajika_named_yoga_authority
except ImportError: # pragma: no cover
from scripts.tajika_named_yoga_authority import build_tajika_named_yoga_authority
SCHEMA = "jyotish.annual_tajika_pack.v1"
def build_annual_tajika_pack(
payload: dict[str, Any],
*,
solar_return_report: dict[str, Any] | None = None,
tajika_report: dict[str, Any] | None = None,
pyjhora_replay: dict[str, Any] | None = None,
vedastro_reference: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a JSON-safe annual pack for arbitrary birth payloads."""
profile = build_calculation_profile(payload)
args = _args_from_payload(payload)
solar_report = solar_return_report if solar_return_report is not None else _safe_solar_return(args)
tajika_report = tajika_report if tajika_report is not None else _safe_tajika(args)
solar_return = _field_from_report(solar_report, "solar_return", "Solar Return")
annual_chart = _annual_chart_from_solar_report(solar_report)
muntha = _field_with_conflict_gate("muntha", solar_report, tajika_report)
year_lord = _field_with_conflict_gate("year_lord", solar_report, tajika_report)
tajika_yogas = _tajika_yogas_field(solar_report)
sahams = _field_from_report(solar_report, "sahams", "Sahams")
mudda_dasha = _dasha_field("Mudda Dasha", solar_report, tajika_report, "mudda_dasha")
patyayini_dasha = _dasha_field("Patyayini Dasha", solar_report, tajika_report, "patyayini_dasha")
pack = {
"schema": SCHEMA,
"profile": {
**profile,
"target_year": int(payload["target_year"]),
},
"solar_return": solar_return,
"annual_chart": annual_chart,
"muntha": muntha,
"year_lord": year_lord,
"tajika_yogas": tajika_yogas,
"sahams": sahams,
"mudda_dasha": mudda_dasha,
"patyayini_dasha": patyayini_dasha,
"monthly_windows": _monthly_windows(mudda_dasha),
"external_engine_comparison": _external_engine_comparison(pyjhora_replay, vedastro_reference),
"report_sections": _report_sections(
profile,
muntha=muntha,
year_lord=year_lord,
tajika_yogas=tajika_yogas,
sahams=sahams,
),
"exports": {},
"audit": _audit(profile, solar_report, tajika_report, pyjhora_replay, vedastro_reference),
}
pack["exports"] = _exports(pack)
return pack
def _args_from_payload(payload: dict[str, Any]) -> SimpleNamespace:
birth = dict(payload.get("birth") or {})
settings = dict(payload.get("settings") or {})
year, month, day = [int(part) for part in str(birth["date"]).split("-")]
time_parts = [int(part) for part in str(birth.get("time") or "00:00:00").split(":")]
while len(time_parts) < 3:
time_parts.append(0)
age = payload.get("age")
if age is None and payload.get("target_year") is not None:
age = int(payload["target_year"]) - year
return SimpleNamespace(
year=year,
month=month,
day=day,
hour=time_parts[0],
minute=time_parts[1],
second=time_parts[2],
lat=float(birth["latitude"]),
lon=float(birth["longitude"]),
tz=_offset_to_hours(birth.get("utc_offset")),
target_year=int(payload["target_year"]),
age=age,
mode="all",
ayanamsa=settings.get("ayanamsa", "lahiri"),
node_mode=settings.get("node_mode", "mean"),
house_system=settings.get("house_system", "whole_sign"),
position_mode=settings.get("position_mode", "legacy"),
dasha_year_days=settings.get("dasha_year_days", 365.25),
solar_return_location_mode=settings.get("solar_return_location_mode", "birth_place"),
annual_year_policy=settings.get("annual_year_policy", "solar_return_exact"),
)
def _offset_to_hours(offset: Any) -> float:
if offset is None:
return 0.0
if isinstance(offset, (int, float)):
return float(offset)
text = str(offset).strip()
sign = -1 if text.startswith("-") else 1
text = text.lstrip("+-")
hours, minutes = [int(part) for part in text.split(":")[:2]]
return sign * (hours + minutes / 60)
def _safe_solar_return(args: SimpleNamespace) -> dict[str, Any]:
try:
from scripts.cmd_solar_return import cmd_solar_return
except ImportError: # pragma: no cover
from cmd_solar_return import cmd_solar_return
try:
result = cmd_solar_return(args)
return result if isinstance(result, dict) else {"error": "solar_return_non_dict"}
except Exception as exc: # pragma: no cover - defensive boundary
return {"error": str(exc)}
def _safe_tajika(args: SimpleNamespace) -> dict[str, Any]:
try:
from scripts.jyotish_engine import cmd_tajika
except ImportError: # pragma: no cover
from jyotish_engine import cmd_tajika
try:
result = cmd_tajika(args)
return result if isinstance(result, dict) else {"error": "tajika_non_dict"}
except Exception as exc: # pragma: no cover - defensive boundary
return {"error": str(exc)}
def _field_from_report(report: dict[str, Any], key: str, label: str) -> dict[str, Any]:
if not isinstance(report, dict) or report.get("error"):
return {"status": "blocked", "producer": label, "reason": report.get("error", "producer_failed")}
value = report.get(key)
if value is None:
return {"status": "blocked", "producer": label, "reason": f"{key}_missing"}
if isinstance(value, dict) and value.get("status") in {"blocked", "conflict", "not_applicable", "parameter_sensitive", "partial_verified", "verified"}:
normalized = {
"status": value.get("status"),
"producer": label,
"data": value,
}
if value.get("reason") is not None:
normalized["reason"] = value.get("reason")
return normalized
return {"status": "partial_verified", "producer": label, "data": value}
def _annual_chart_from_solar_report(report: dict[str, Any]) -> dict[str, Any]:
field = _field_from_report(report, "sr_chart_info", "Solar Return Annual Chart")
if field["status"] == "partial_verified":
field["data_contract"] = "normalized_from_sr_chart_info"
return field
def _tajika_yogas_field(solar_report: dict[str, Any]) -> dict[str, Any]:
if not isinstance(solar_report, dict) or solar_report.get("error"):
return _field_from_report(solar_report, "tajika_yogas", "Tajika Yogas")
authority = _build_governed_tajika_named_yoga_authority(solar_report)
if authority is None:
return _field_from_report(solar_report, "tajika_yogas", "Tajika Yogas")
return {
"status": "partial_verified" if authority.get("status") == "authority_ready" else authority.get("status", "blocked"),
"producer": "Tajika Named Yoga Authority",
"data": authority,
"data_contract": "governed_named_yoga_authority_surface",
}
def _build_governed_tajika_named_yoga_authority(solar_report: dict[str, Any]) -> dict[str, Any] | None:
planets = _extract_tajika_motion_planets(solar_report)
if not planets:
return None
authority = build_tajika_named_yoga_authority(planets)
if not isinstance(authority, dict):
return None
return authority
def _extract_tajika_motion_planets(solar_report: dict[str, Any]) -> dict[str, dict[str, float]]:
chart = solar_report.get("chart") if isinstance(solar_report.get("chart"), dict) else {}
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
extracted: dict[str, dict[str, float]] = {}
for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"):
payload = planets.get(name)
if not isinstance(payload, dict):
continue
longitude = payload.get("degree_raw", payload.get("degree"))
speed = payload.get("speed")
if longitude is None or speed is None:
continue
extracted[name] = {"longitude": float(longitude), "speed": float(speed)}
return extracted
def _first_available_field(label: str, *reports_and_key: Any) -> dict[str, Any]:
*reports, key = reports_and_key
producers = []
for report in reports:
field = _field_from_report(report, key, label)
producers.append(field)
if field["status"] == "partial_verified":
field["producers_checked"] = producers
return field
return {"status": "blocked", "producer": label, "reason": f"{key}_missing", "producers_checked": producers}
def _field_with_conflict_gate(key: str, solar_report: dict[str, Any], tajika_report: dict[str, Any]) -> dict[str, Any]:
values = []
for producer, report in (("solar_return", solar_report), ("tajika", tajika_report)):
if isinstance(report, dict) and report.get(key) is not None and not report.get("error"):
values.append({"producer": producer, "value": report[key]})
if len(values) >= 2 and values[0]["value"] != values[1]["value"]:
return {
"status": "conflict",
"field": key,
"values": values,
"reason": "same_profile_annual_producers_disagree",
}
if values:
return {
"status": "partial_verified",
"producer": values[0]["producer"],
"data": values[0]["value"],
"producers_checked": values,
}
return {
"status": "blocked",
"field": key,
"reason": f"{key}_missing",
"producers_checked": values,
}
def _dasha_field(label: str, solar_report: dict[str, Any], tajika_report: dict[str, Any], key: str) -> dict[str, Any]:
field = _first_available_field(label, solar_report, tajika_report, key)
data = field.get("data") if isinstance(field.get("data"), dict) else {}
periods = data.get("periods") or data.get("dasha_sequence") or []
field["periods"] = periods if isinstance(periods, list) else []
return field
def _monthly_windows(mudda_dasha: dict[str, Any]) -> list[dict[str, Any]]:
windows = []
for idx, period in enumerate(mudda_dasha.get("periods", []), start=1):
if not isinstance(period, dict):
continue
windows.append(
{
"index": idx,
"source": "mudda_dasha",
"lord": period.get("lord"),
"duration_months": period.get("months"),
"status": "partial_verified",
}
)
return windows
def _report_sections(
profile: dict[str, Any],
*,
muntha: dict[str, Any] | None = None,
year_lord: dict[str, Any] | None = None,
tajika_yogas: dict[str, Any] | None = None,
sahams: dict[str, Any] | None = None,
) -> dict[str, Any]:
blocked_fields = [
name
for name, field in (("muntha", muntha), ("year_lord", year_lord))
if isinstance(field, dict) and field.get("status") == "blocked"
]
conflict_fields = [
name
for name, field in (("muntha", muntha), ("year_lord", year_lord))
if isinstance(field, dict) and field.get("status") == "conflict"
]
all_flagged_fields = [
name
for name in (*blocked_fields, *conflict_fields)
if name
]
muntha_brief = _field_brief("Muntha", muntha)
year_lord_brief = _field_brief("Year Lord", year_lord)
tajika_brief = _field_brief("Tajika Yogas", tajika_yogas)
sahams_brief = _field_brief("Sahams", sahams)
quick_takeaways = [item for item in (muntha_brief, year_lord_brief) if item]
narrative_preview = [
"年度层已经有可读壳层,但仍需要带着冲突标签阅读。",
muntha_brief,
year_lord_brief,
]
if blocked_fields:
summary_status = "blocked"
summary_reason = "interpretive annual narrative waits for blocked field closure"
thematic_status = "blocked"
thematic_reason = "annual technique closure incomplete"
elif conflict_fields:
summary_status = "parameter_sensitive"
summary_reason = "interpretive annual narrative remains conflict-labeled until producer adjudication closes"
thematic_status = "parameter_sensitive"
thematic_reason = "annual technique conflict requires labeled reading"
else:
summary_status = "partial_verified"
summary_reason = "annual shell is readable but still awaits broader external parity closure"
thematic_status = "partial_verified"
thematic_reason = "annual technique shell available with current native evidence"
return {
"executive_summary": {
"status": summary_status,
"reason": summary_reason,
"blocked_fields": all_flagged_fields,
"summary_lines": narrative_preview,
"quick_takeaways": quick_takeaways,
},
"thematic_narrative": {
"status": thematic_status,
"reason": thematic_reason,
"highlights": [
"Solar Return / Tajika annual shell is available.",
"Muntha and Year Lord are visible, but Year Lord remains producer-disagree sensitive.",
"The report should read annual structure first, then the evidence appendix.",
],
},
"evidence_appendix": {
"status": "partial_verified",
"profile_id": profile["profile_id"],
"field_briefs": {
"muntha": muntha_brief,
"year_lord": year_lord_brief,
"tajika_yogas": tajika_brief,
"sahams": sahams_brief,
},
"must_not_claim": ["exact_annual_event_prediction"],
},
}
def _exports(pack: dict[str, Any]) -> dict[str, Any]:
exports = {
"json": {
"schema": pack["schema"],
"profile_id": pack["profile"]["profile_id"],
"field_statuses": {
key: value.get("status")
for key, value in pack.items()
if isinstance(value, dict) and "status" in value
},
},
"markdown": _markdown_summary(pack),
"ai_evidence_bundle": {
"schema": "jyotish.annual_tajika_pack.ai_evidence.v1",
"profile_id": pack["profile"]["profile_id"],
"allowed_claim_status": "blocked_until_conflict_gate",
"raw_field_paths": [
"solar_return",
"annual_chart",
"muntha",
"year_lord",
"tajika_yogas",
"sahams",
"mudda_dasha",
"patyayini_dasha",
],
},
}
try: # pragma: no cover - import path differs under CLI vs pytest
from report_pack_contract import normalize_report_pack_contract
except ImportError: # pragma: no cover
from scripts.report_pack_contract import normalize_report_pack_contract
exports["unified_report_pack_contract"] = normalize_report_pack_contract(
{**pack, "exports": exports},
pack_id="annual_tajika_pack",
)
return exports
def _markdown_summary(pack: dict[str, Any]) -> str:
rows = ["| field | status |", "| --- | --- |"]
for key in (
"solar_return",
"annual_chart",
"muntha",
"year_lord",
"tajika_yogas",
"sahams",
"mudda_dasha",
"patyayini_dasha",
"external_engine_comparison",
):
field = pack[key]
status = field.get('status')
if key == "year_lord":
values = field.get("values") or []
if values:
first = values[0].get("value") if isinstance(values[0], dict) else {}
if isinstance(first, dict):
status = f"{status}: {first.get('year_lord') or first.get('year_lord_sign')}"
rows.append(f"| {key} | {status} |")
summary = [
"",
"### Annual Reading Preview",
"",
f"- blocked fields: {', '.join((pack['report_sections']['executive_summary'].get('blocked_fields') or [])) or 'none'}",
]
return "\n".join(rows + summary)
def _external_engine_comparison(
pyjhora_replay: dict[str, Any] | None,
vedastro_reference: dict[str, Any] | None,
) -> dict[str, Any]:
if not pyjhora_replay and not vedastro_reference:
return {
"status": "blocked",
"reason": "external annual replay not integrated in annual_tajika_pack.v1",
"engines": [],
}
comparison: dict[str, Any] = {"status": "blocked", "engines": []}
statuses = []
if pyjhora_replay:
comparison["engines"].append("PyJHora/JHora")
pyjhora = dict(pyjhora_replay)
patyayini = pyjhora.get("patyayini_dasha")
if isinstance(patyayini, dict):
patyayini = dict(patyayini)
patyayini["normalized_rows"] = _normalize_patyayini_replay_rows(patyayini)
pyjhora["patyayini_dasha"] = patyayini
comparison["pyjhora"] = pyjhora
statuses.append(pyjhora_replay.get("status", "blocked"))
year_lord_replay = pyjhora_replay.get("year_lord_replay")
if isinstance(year_lord_replay, dict):
raw_artifact_path = year_lord_replay.get("raw_artifact_path")
comparison["evidence_scope"] = "pyjhora_behavior_only"
comparison["parity_status"] = "not_multiengine_parity"
comparison["raw_evidence_paths"] = [raw_artifact_path] if raw_artifact_path else []
comparison["raw_evidence_status"] = (
"archived_artifact" if raw_artifact_path else "runtime_observation_not_archived"
)
comparison["year_lord_replay"] = {
key: year_lord_replay[key]
for key in (
"status",
"reason",
"callable",
"pyjhora_version",
"effective_ayanamsa",
"request_hash",
"node_mode",
)
if key in year_lord_replay
}
if vedastro_reference:
comparison["engines"].append("VedAstro official")
comparison["vedastro"] = _sanitize_vedastro_reference(vedastro_reference)
statuses.append(comparison["vedastro"].get("status", "blocked"))
comparison["status"] = "partial_verified" if any(str(s).startswith("partial_verified") for s in statuses) else "blocked"
return comparison
def _normalize_patyayini_replay_rows(field: dict[str, Any]) -> list[dict[str, Any]]:
"""Expose PyJHora tuple shape without inferring a local Patyayini contract."""
raw = field.get("raw") if isinstance(field.get("raw"), dict) else {}
periods = raw.get("periods") if isinstance(raw.get("periods"), list) else []
rows = []
for order, period in enumerate(periods, start=1):
if not isinstance(period, (list, tuple)) or len(period) != 3:
continue
codes, boundary, duration = period
if not isinstance(codes, (list, tuple)) or len(codes) != 2:
continue
if not isinstance(boundary, (list, tuple)) or len(boundary) != 4:
continue
try:
year, month, day = (int(value) for value in boundary[:3])
hour_decimal = float(boundary[3])
boundary_display = (datetime(year, month, day) + timedelta(hours=hour_decimal)).strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError, OverflowError):
continue
rows.append({
"order": order,
"main_code": codes[0],
"sub_code": codes[1],
"boundary_components": {
"year": year,
"month": month,
"day": day,
"hour_decimal": hour_decimal,
},
"boundary_display": boundary_display,
"duration_raw": duration,
"timezone_semantics": "not_returned_by_pyjhora_tuple",
"boundary_semantics": "unresolved_external_tuple_boundary",
"evidence_status": "pyjhora_behavior_only / not_multiengine_parity",
})
return rows
def _sanitize_vedastro_reference(reference: dict[str, Any]) -> dict[str, Any]:
allowed = {
"engine",
"status",
"chart_core",
"dasha_all",
"varshaphala_status",
"supported_annual_methods",
"secret_redaction",
}
sanitized = {key: value for key, value in reference.items() if key in allowed}
sanitized.setdefault("engine", "VedAstro official")
sanitized.setdefault("status", "blocked")
sanitized.setdefault("varshaphala_status", "blocked")
sanitized.setdefault("supported_annual_methods", [])
sanitized["secret_redaction"] = {"secret_material_included": False}
return sanitized
def _field_brief(label: str, field: dict[str, Any] | None) -> str:
if not isinstance(field, dict):
return f"{label}: unavailable"
status = field.get("status", "blocked")
if status == "conflict":
values = field.get("values") or []
render_values = []
for item in values[:2]:
if not isinstance(item, dict):
continue
value = item.get("value")
if isinstance(value, dict):
render_values.append(
", ".join(
str(part)
for part in (
value.get("year_lord"),
value.get("year_lord_sign"),
value.get("muntha_sign"),
value.get("muntha_lord"),
)
if part
)
)
if render_values:
return f"{label}: conflict between { ' vs '.join(render_values) }"
if status == "partial_verified":
data = field.get("data") if isinstance(field.get("data"), dict) else {}
if label == "Muntha":
return f"{label}: {data.get('muntha_sign') or data.get('muntha_sign_idx')} / {data.get('muntha_lord') or 'unknown'}"
if label == "Year Lord":
return f"{label}: {data.get('year_lord') or 'unknown'}"
if label == "Tajika Yogas":
authority_status = data.get("status")
if authority_status == "authority_ready":
summary = data.get("summary") if isinstance(data.get("summary"), dict) else {}
row_count = summary.get("row_count")
supported = data.get("supported_named_yogas") if isinstance(data.get("supported_named_yogas"), list) else []
supported_text = "/".join(str(item) for item in supported) if supported else "governed named yogas"
return (
f"Tajika Yogas: governed authority surface visible for {supported_text}"
+ (f" ({row_count} rows)" if row_count is not None else "")
)
return "Tajika Yogas: annual candidate structures visible"
if label == "Sahams":
return "Sahams: annual sensitive points visible"
return f"{label}: {status}"
def _audit(
profile: dict[str, Any],
solar_report: dict[str, Any],
tajika_report: dict[str, Any],
pyjhora_replay: dict[str, Any] | None = None,
vedastro_reference: dict[str, Any] | None = None,
) -> dict[str, Any]:
muntha_status = _field_with_conflict_gate("muntha", solar_report, tajika_report).get("status", "blocked")
year_lord_status = _field_with_conflict_gate("year_lord", solar_report, tajika_report).get("status", "blocked")
audit = {
"profile_id": profile["profile_id"],
"technique_audit": [
{"technique": "Calculation Profile", "status": "verified", "profile_id": profile["profile_id"]},
{"technique": "Solar Return", "status": _producer_status(solar_report)},
{"technique": "Annual Chart", "status": "partial_verified" if solar_report.get("sr_chart_info") else "blocked"},
{"technique": "Muntha", "status": muntha_status},
{"technique": "Year Lord", "status": year_lord_status},
{"technique": "Tajika Yogas", "status": _tajika_yogas_field(solar_report).get("status", "blocked")},
{"technique": "Sahams", "status": "partial_verified" if solar_report.get("sahams") else "blocked"},
{"technique": "Mudda Dasha", "status": "partial_verified" if (solar_report.get("mudda_dasha") or tajika_report.get("mudda_dasha")) else "blocked"},
{"technique": "Patyayini Dasha", "status": "blocked", "reason": "native producer not integrated"},
{"technique": "External Engine Comparison", "status": "blocked", "reason": "pending Task 5/6"},
],
}
if pyjhora_replay:
year_lord_replay = pyjhora_replay.get("year_lord_replay")
audit["technique_audit"].append(
{
"technique": "PyJHora Annual Replay",
"status": pyjhora_replay.get("status", "blocked"),
"license_boundary": pyjhora_replay.get("license_boundary"),
**(
{
"evidence_scope": "pyjhora_behavior_only",
"parity_status": "not_multiengine_parity",
"raw_artifact_path": year_lord_replay.get("raw_artifact_path"),
"raw_evidence_status": (
"archived_artifact"
if year_lord_replay.get("raw_artifact_path")
else "runtime_observation_not_archived"
),
"year_lord_replay_status": year_lord_replay.get("status", "blocked"),
"node_mode": year_lord_replay.get("node_mode"),
}
if isinstance(year_lord_replay, dict)
else {}
),
}
)
if vedastro_reference:
vedastro = _sanitize_vedastro_reference(vedastro_reference)
audit["technique_audit"].append(
{
"technique": "VedAstro Annual Boundary",
"status": vedastro.get("status", "blocked"),
"varshaphala_status": vedastro.get("varshaphala_status", "blocked"),
}
)
return audit
def _producer_status(report: dict[str, Any]) -> str:
return "blocked" if report.get("error") else "partial_verified"
+486
View File
@@ -0,0 +1,486 @@
#!/usr/bin/env python3
"""Shared canonical calculation-profile contract for the Jyotish runtime.
Single source of truth for calculation-profile identity. Both
``scripts/domain_calculation_service.py`` (CLI/REST/MCP canonical chart) and the
CLI wrappers in ``scripts/jyotish_engine.py`` delegate here, so one
(birth input, effective settings, observed ephemeris) tuple yields one profile.
Contract rules
--------------
1. Deterministic: identical normalized input gives identical ``input_hash`` /
``profile_id`` / ``profile_hash`` across runs, processes and machines.
2. Privacy (AGENTS 6.6): the profile never stores birth data. Birth
date/time/place/coordinates fold into the irreversible ``input_hash``; only
calculation settings, ``timezone``, ``engine`` and ``algorithm`` remain.
3. One hash algorithm: canonical JSON ``ensure_ascii=True, sort_keys=True,
separators=(",",":"), default=str`` — identical to
``domain_calculation_service._canonical_hash``.
4. Observed-only ephemeris: ``engine`` carries the five observed fields
(source/flags_verified/flags/provider/policy); ``ephemeris_path`` is never
part of the profile or any hash, so profiles are stable across machine
paths. No provider is hard-coded (ERR-042/ERR-121).
5. ``profile_id == profile_hash`` (self hash); ``input_hash`` covers the
normalized input contract only (no engine observation).
6. Structured errors: unparseable input raises ``CalculationProfileError`` (a
``ValueError``); IANA timezone names are retained as-is.
Stdlib-only so it imports identically under direct-script execution and as
``scripts.calculation_profile_contract``.
"""
from __future__ import annotations
import hashlib
import json
import math
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
SCHEMA = "jyotish.calculation_profile.v1"
PROFILE_VERSION = "1.0"
DEFAULT_ALGORITHM = "sidereal_natal_chart"
DEFAULTS = {
"ayanamsa": "lahiri",
"node_mode": "mean",
"position_mode": "legacy",
"house_system": "whole_sign",
"dasha_year_days": 365.25,
"solar_return_location_mode": "birth_place",
"annual_year_policy": "solar_return_exact",
"coordinate_precision": "coordinates",
}
# single truth source: every setting the profile exposes
SETTINGS_KEYS = (
"ayanamsa",
"node_mode",
"position_mode",
"house_system",
"dasha_year_days",
"solar_return_location_mode",
"annual_year_policy",
)
EPHEMERIS_FIELDS = (
"ephemeris_source",
"ephemeris_flags_verified",
"ephemeris_flags",
"ephemeris_provider",
"ephemeris_policy",
)
POSITION_MODES = ("legacy", "mean", "apparent")
NODE_MODES = ("mean", "true")
class CalculationProfileError(ValueError):
"""Raised for inputs that cannot be normalized without data loss."""
# ---------------------------------------------------------------------------
# canonical hashing (unified with domain_calculation_service)
# ---------------------------------------------------------------------------
def _canonical_hash(value: Any) -> str:
encoded = json.dumps(
value,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _as_float(value: Any, label: str) -> float | None:
if value is None or value == "":
return None
try:
result = float(value)
except (TypeError, ValueError):
raise CalculationProfileError(f"{label} must be numeric, got {value!r}") from None
if not math.isfinite(result):
raise CalculationProfileError(f"{label} must be finite, got {value!r}")
return result
def _as_int(value: Any, label: str) -> int:
if isinstance(value, bool):
raise CalculationProfileError(f"{label} must be an integer, got {value!r}")
try:
result = int(value)
except (TypeError, ValueError):
raise CalculationProfileError(f"{label} must be an integer, got {value!r}") from None
if isinstance(value, float) and not value.is_integer():
raise CalculationProfileError(f"{label} must be an integer, got {value!r}")
return result
# ---------------------------------------------------------------------------
# input normalization (root and nested birth containers)
# ---------------------------------------------------------------------------
def _root_or_nested(payload: dict[str, Any], key: str) -> Any:
"""Read a key from the payload root, then ``birth``, then ``location``."""
for container in (payload, payload.get("birth"), payload.get("location")):
if isinstance(container, dict) and container.get(key) is not None:
return container[key]
return None
def _first_not_none(*values: Any) -> Any:
return next((value for value in values if value is not None), None)
def _normalize_date(payload: dict[str, Any]) -> str | None:
value = _root_or_nested(payload, "date")
if value is not None:
parts = [part for part in str(value).strip().split("-") if part != ""]
if len(parts) != 3:
raise CalculationProfileError(f"invalid date {value!r}, expected YYYY-MM-DD")
year, month, day = (_as_int(part, f"date part {part!r}") for part in parts)
if not (1 <= year <= 9999 and 1 <= month <= 12 and 1 <= day <= 31):
raise CalculationProfileError(f"invalid date {value!r}, parts out of range")
return f"{year:04d}-{month:02d}-{day:02d}"
year = _root_or_nested(payload, "year")
month = _root_or_nested(payload, "month")
day = _root_or_nested(payload, "day")
if year is None and month is None and day is None:
return None
if year is None or month is None or day is None:
raise CalculationProfileError("incomplete birth date: year/month/day must be given together")
return _normalize_date({"date": f"{_as_int(year, 'year')}-{_as_int(month, 'month')}-{_as_int(day, 'day')}"})
def _normalize_time(payload: dict[str, Any]) -> list[float] | None:
value = _root_or_nested(payload, "time")
if value is not None:
parts = [part for part in str(value).strip().split(":") if part != ""]
if len(parts) not in (2, 3):
raise CalculationProfileError(f"invalid time {value!r}, expected HH:MM[:SS]")
hour = _as_int(parts[0], f"time hour {parts[0]!r}")
minute = _as_int(parts[1], f"time minute {parts[1]!r}")
second = _as_float(parts[2], f"time second {parts[2]!r}") if len(parts) == 3 else 0.0
if not (0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= second < 60):
raise CalculationProfileError(f"invalid time {value!r}, parts out of range")
return [float(hour), float(minute), float(second)]
hour = _root_or_nested(payload, "hour")
minute = _root_or_nested(payload, "minute")
second = _root_or_nested(payload, "second")
if hour is None and minute is None and second is None:
return None
if hour is None or minute is None:
raise CalculationProfileError("incomplete birth time: hour/minute must be given together")
return _normalize_time({"time": f"{_as_int(hour, 'hour')}:{_as_int(minute, 'minute')}:{_as_float(second, 'second') if second is not None else 0.0}"})
def _format_utc_offset(value: Any) -> str:
"""Normalize a numeric UTC offset to ``+HH:MM`` (or ``-HH:MM``)."""
if isinstance(value, str):
text = value.strip()
if not text:
raise CalculationProfileError("empty UTC offset")
if ":" in text:
sign = -1 if text.startswith("-") else 1
parts = text.lstrip("+-").split(":")
hours = _as_float(parts[0], f"offset hour {parts[0]!r}")
minutes = _as_float(parts[1], f"offset minute {parts[1]!r}") if len(parts) > 1 else 0.0
total = sign * (hours + minutes / 60.0)
else:
total = _as_float(text, f"UTC offset {text!r}")
elif isinstance(value, bool):
raise CalculationProfileError(f"invalid UTC offset {value!r}")
else:
total = _as_float(value, "UTC offset")
if total is None or not -14 <= total <= 14:
raise CalculationProfileError(f"invalid UTC offset {value!r}, must be within -14..+14")
total_minutes = int(round(total * 60.0))
sign = "+" if total_minutes >= 0 else "-"
total_minutes = abs(total_minutes)
return f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}"
def _normalize_timezone(payload: dict[str, Any]) -> tuple[str | None, str | None]:
"""Return ``(utc_offset, timezone_name)``.
A numeric ``tz``/``utc_offset`` becomes canonical ``+HH:MM``. A non-numeric
``tz`` that looks like an IANA zone (contains ``/``) is retained as the
timezone name; an explicit ``timezone`` field wins as the name.
"""
raw_offset = _first_not_none(_root_or_nested(payload, "tz"), _root_or_nested(payload, "utc_offset"))
name = None
for key in ("timezone", "timezone_name", "tz_name"):
candidate = _root_or_nested(payload, key)
if isinstance(candidate, str) and candidate.strip():
name = candidate.strip()
break
if raw_offset is None or raw_offset == "":
return None, name
try:
return _format_utc_offset(raw_offset), name
except CalculationProfileError:
if isinstance(raw_offset, str) and "/" in raw_offset:
return None, raw_offset.strip()
raise
def _normalize_birth(payload: dict[str, Any]) -> dict[str, Any]:
offset, tz_name = _normalize_timezone(payload)
return {
"date": _normalize_date(payload),
"time": _normalize_time(payload),
"utc_offset": offset,
"timezone_name": tz_name,
"latitude": _as_float(
_first_not_none(_root_or_nested(payload, "latitude"), _root_or_nested(payload, "lat")),
"latitude",
),
"longitude": _as_float(
_first_not_none(_root_or_nested(payload, "longitude"), _root_or_nested(payload, "lon")),
"longitude",
),
"place": _root_or_nested(payload, "place"),
"coordinate_precision": _root_or_nested(payload, "coordinate_precision")
or DEFAULTS["coordinate_precision"],
}
def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]:
settings_block = payload.get("settings")
settings_block = settings_block if isinstance(settings_block, dict) else {}
def _camel(key: str) -> str:
head, _, tail = key.partition("_")
return head + tail[:1].upper() + tail[1:]
def _setting(key: str) -> Any:
if key in settings_block and settings_block[key] is not None:
return settings_block[key]
return _first_not_none(_root_or_nested(payload, key), _root_or_nested(payload, _camel(key)))
ayanamsa = str(_setting("ayanamsa") or DEFAULTS["ayanamsa"]).strip().lower()
if not ayanamsa:
raise CalculationProfileError("ayanamsa must be a non-empty string")
node_mode = str(_setting("node_mode") or DEFAULTS["node_mode"]).strip().lower()
if node_mode not in NODE_MODES:
raise CalculationProfileError(f"node_mode must be one of {NODE_MODES}, got {node_mode!r}")
position_mode = str(_setting("position_mode") or DEFAULTS["position_mode"]).strip().lower()
if position_mode not in POSITION_MODES:
raise CalculationProfileError(f"position_mode must be one of {POSITION_MODES}, got {position_mode!r}")
house_system = str(_setting("house_system") or DEFAULTS["house_system"]).strip()
if not house_system:
raise CalculationProfileError("house_system must be a non-empty string")
dasha_year_days_value = _setting("dasha_year_days")
if dasha_year_days_value is None:
dasha_year_days_value = DEFAULTS["dasha_year_days"]
dasha_year_days = _as_float(dasha_year_days_value, "dasha_year_days")
if dasha_year_days <= 0:
raise CalculationProfileError(f"dasha_year_days must be positive, got {dasha_year_days!r}")
solar_return_location_mode = str(
_setting("solar_return_location_mode") or DEFAULTS["solar_return_location_mode"]
).strip() or DEFAULTS["solar_return_location_mode"]
annual_year_policy = str(
_setting("annual_year_policy") or DEFAULTS["annual_year_policy"]
).strip() or DEFAULTS["annual_year_policy"]
return {
"ayanamsa": ayanamsa,
"node_mode": node_mode,
"position_mode": position_mode,
"house_system": house_system,
"dasha_year_days": dasha_year_days,
"solar_return_location_mode": solar_return_location_mode,
"annual_year_policy": annual_year_policy,
}
def _effective_ephemeris(payload: dict[str, Any]) -> dict[str, Any]:
"""Observed ephemeris provenance only; never fabricate a provider.
``ephemeris_path`` is excluded: absolute machine paths must not
participate in profile identity or hashing.
"""
observed: dict[str, Any] = {}
for container in (payload.get("engine"), payload.get("ephemeris"), payload.get("meta")):
if not isinstance(container, dict):
continue
for field in EPHEMERIS_FIELDS:
if field in container and container[field] is not None:
observed[field] = container[field]
if observed:
observed.setdefault("ephemeris_policy", "observed_provider_recorded")
return observed
return {
"ephemeris_provider": "not_observed",
"ephemeris_source": None,
"ephemeris_flags": None,
"ephemeris_flags_verified": False,
"ephemeris_policy": "no_hardcoded_provider_observed_provider_required",
}
# ---------------------------------------------------------------------------
# public builders
# ---------------------------------------------------------------------------
def build_calculation_profile(payload: dict[str, Any]) -> dict[str, Any]:
"""Build the canonical profile for any payload (dict or Namespace-shaped).
Birth fields may be nested under ``birth``/``location`` or at the root
(``year``/``month``/``day``/``hour``/``minute``/``second``/``lat``/``lon``/
``tz``). They are only hashed into ``input_hash``; the returned profile
carries no birth data. Settings are computed once (single truth) and
mirrored flat and under ``effective_settings``.
"""
birth = _normalize_birth(payload)
settings = _normalize_settings(payload)
offset = birth["utc_offset"]
timezone = {
"name": birth["timezone_name"] or (f"UTC{offset}" if offset is not None else None),
"utc_offset": offset,
}
algorithm = str(payload.get("algorithm") or DEFAULT_ALGORITHM).strip() or DEFAULT_ALGORITHM
input_hash = _canonical_hash({"birth": birth, "settings": settings})
profile: dict[str, Any] = {
"schema": SCHEMA,
"profile_version": PROFILE_VERSION,
"algorithm": algorithm,
"effective_settings": {**settings, "timezone_offset": offset},
"timezone": timezone,
"engine": _effective_ephemeris(payload),
"coordinate_precision": birth["coordinate_precision"],
**{key: settings[key] for key in SETTINGS_KEYS},
"input_hash": input_hash,
"profile_id": None,
"profile_hash": None,
}
profile_hash = _canonical_hash(profile)
profile["profile_id"] = profile_hash
profile["profile_hash"] = profile_hash
return profile
def _payload_from_args(args: Any) -> dict[str, Any]:
"""Convert an argparse Namespace (or a plain dict) into a root-style payload."""
if isinstance(args, dict):
return dict(args)
if not all(hasattr(args, name) for name in ("year", "month", "day", "hour", "minute")):
raise TypeError(
"attach_calculation_profile expects an argparse Namespace or dict, "
f"got {type(args).__name__}"
)
def _attr(name: str) -> Any:
return getattr(args, name, None)
return {
"year": _attr("year"),
"month": _attr("month"),
"day": _attr("day"),
"hour": _attr("hour"),
"minute": _attr("minute"),
"second": _attr("second"),
"lat": _attr("lat"),
"lon": _attr("lon"),
"tz": _attr("tz"),
"place": _attr("place") or _attr("location_name"),
"timezone": _attr("timezone") or _attr("timezone_name"),
"settings": {
key: _first_not_none(_attr(key), DEFAULTS[key]) for key in SETTINGS_KEYS
},
}
def _observed_ephemeris_from_result(result: dict[str, Any]) -> dict[str, Any] | None:
"""Lift observed ephemeris provenance from a computed result.
Only the five contract fields are carried; ``ephemeris_path`` (an absolute
machine path) is deliberately excluded from profile identity.
"""
meta = result.get("meta")
if isinstance(meta, dict) and meta.get("ephemeris_provider"):
return {key: value for key, value in meta.items() if key in EPHEMERIS_FIELDS}
contract = result.get("calculation_contract")
if isinstance(contract, dict):
effective = contract.get("effective")
if isinstance(effective, dict) and effective.get("ephemeris_provider"):
return {key: value for key, value in effective.items() if key in EPHEMERIS_FIELDS}
return None
def attach_calculation_profile(result: dict[str, Any], args: Any) -> dict[str, Any]:
"""Attach the canonical profile to a result in place.
Profile metadata and deterministic result-binding metadata are written;
every pre-existing business key keeps its value. A canonical profile already
present (e.g. from ``domain_calculation_service.compute_chart``) is kept
unchanged; otherwise the profile is built from ``args`` plus any observed
ephemeris provenance in ``result``.
"""
existing = result.get("calculation_profile") if isinstance(result, dict) else None
if (
isinstance(existing, dict)
and isinstance(existing.get("profile_id"), str)
and isinstance(existing.get("profile_hash"), str)
):
result["calculation_profile_id"] = existing["profile_id"]
return result
payload = _payload_from_args(args)
observed = _observed_ephemeris_from_result(result)
if observed:
payload["engine"] = observed
profile = build_calculation_profile(payload)
result["calculation_profile"] = profile
result["calculation_profile_id"] = profile["profile_id"]
return bind_result_to_profile(result, profile)
def bind_result_to_profile(result: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]:
"""Bind one concrete calculation result to its normalized input profile."""
if not isinstance(result, dict) or not isinstance(profile, dict):
raise TypeError("result and calculation profile must be dictionaries")
input_hash = profile.get("input_hash")
if not isinstance(input_hash, str) or len(input_hash) != 64:
raise ValueError("calculation profile is missing a valid input_hash")
result_payload = {
key: value
for key, value in result.items()
if key not in {"result_hash", "result_binding", "calculation_profile", "calculation_profile_id"}
}
encoded = json.dumps(
canonicalize_result_payload({"input_hash": input_hash, "result": result_payload}),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
result_hash = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
result["result_hash"] = result_hash
result["result_binding"] = {"input_hash": input_hash, "result_hash": result_hash}
return result
def canonicalize_result_payload(value: Any) -> Any:
"""Convert producer mapping keys to deterministic JSON keys before hashing."""
if isinstance(value, dict):
normalized: dict[str, Any] = {}
for key, child in value.items():
normalized_key = key if isinstance(key, str) else f"__{type(key).__name__}__:{key}"
normalized[normalized_key] = canonicalize_result_payload(child)
return normalized
if isinstance(value, (list, tuple)):
return [canonicalize_result_payload(item) for item in value]
return value
if __name__ == "__main__":
import sys
sample = {
"birth": {
"date": "1990-01-01",
"time": "12:00:00",
"utc_offset": "+08:00",
"latitude": 39.9,
"longitude": 116.4,
},
"settings": {"ayanamsa": "lahiri", "node_mode": "mean"},
}
print(json.dumps(build_calculation_profile(sample), ensure_ascii=False, indent=2, default=str))
sys.exit(0)
+507
View File
@@ -0,0 +1,507 @@
#!/usr/bin/env python3
"""Read-only quality checks for PL9-grade full personal report packets."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any
SCHEMA_VERSION = "jyotish.full_report_quality_gate.v1"
REPORT_SCHEMA = "pl9_style_professional_export_v1"
REQUIRED_PACK_SECTIONS = (
"base",
"strength",
"dasha",
"annual",
"transit",
"d1_d60_ledger",
"professional_support",
"audit_appendix",
)
RESTRICTED_MATERIAL_IDS = (
"jaimini_special_points",
"tajika_named_yoga",
"annual_sahams",
"kranti",
"alternate_ashtottari",
)
def _as_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _present(value: Any) -> bool:
return bool(value) if isinstance(value, (dict, list, tuple, set, str)) else value is not None
def _status(value: Any) -> str:
item = _as_dict(value)
return str(item.get("status") or item.get("execution_status") or "available")
def _marker_present(markdown: str | None, markers: tuple[str, ...]) -> bool | None:
if markdown is None:
return None
return any(marker in markdown for marker in markers)
def _check(name: str, status: str, detail: str) -> dict[str, str]:
return {"name": name, "status": status, "detail": detail}
def _coverage_entry(
material_id: str,
tier: str,
source_reference: str,
upstream_value: Any,
markdown: str | None,
markers: tuple[str, ...],
) -> tuple[dict[str, Any], str | None]:
upstream_present = _present(upstream_value)
rendered = _marker_present(markdown, markers)
status = _status(upstream_value) if upstream_present else "not_available"
entry = {
"material_id": material_id,
"admission_tier": tier,
"report_role": "professional_support",
"source_reference": source_reference,
"upstream_present": upstream_present,
"status": status,
"source_status": status,
"surface_location": (
"professional_support_cross_reference"
if material_id in {
"special_lagnas",
"patyayini_annual_support",
"narayana_alignment",
}
else "thematic_or_operator_appendix"
),
"rendered": rendered,
"limitation_reference": None if status not in {"blocked", "partial", "parameter_sensitive", "conflict"} else status,
}
if upstream_present and rendered is False:
return entry, f"required_support_not_rendered:{material_id}"
return entry, None
def _patyayini_normalized_rows_present(packet: dict[str, Any]) -> bool | None:
annual_section = _as_dict(_as_dict(_as_dict(packet.get("full_report_pack")).get("sections")).get("annual"))
annual_data = _as_dict(packet.get("worksheets"))
timing = _as_dict(annual_data.get("timing_and_predictive_systems"))
annual_pack = _as_dict(timing.get("annual_tajika_pack"))
candidates = (
_as_dict(_as_dict(annual_pack.get("external_engine_comparison")).get("pyjhora")).get("patyayini_dasha"),
_as_dict(_as_dict(annual_section.get("external_engine_comparison")).get("pyjhora")).get("patyayini_dasha"),
)
saw_patyayini = False
for candidate in candidates:
if candidate:
saw_patyayini = True
rows = candidate.get("normalized_rows")
if isinstance(rows, list) and rows:
return True
if saw_patyayini:
return False
return None
def _promotion_status(blocking_reasons: list[str], review_reasons: list[str], warning_reasons: list[str]) -> str:
if blocking_reasons:
return "blocked"
if review_reasons:
return "review_required"
if warning_reasons:
return "passed_with_limitations"
return "passed"
def evaluate_full_report(packet: dict[str, Any], rendered_markdown: str | None = None) -> dict[str, Any]:
"""Evaluate an assembled report without mutating calculation or report content."""
packet = _as_dict(packet)
checks: list[dict[str, str]] = []
blocking_reasons: list[str] = []
review_reasons: list[str] = []
warning_reasons: list[str] = []
source_schema = str(packet.get("schema") or "")
if source_schema != REPORT_SCHEMA:
blocking_reasons.append("report_schema_invalid")
checks.append(_check("report_schema", "blocked", f"expected {REPORT_SCHEMA}, got {source_schema or 'missing'}"))
else:
checks.append(_check("report_schema", "passed", source_schema))
profile = _as_dict(packet.get("calculation_profile"))
for key, value in (
("calculation_profile_id", packet.get("calculation_profile_id") or profile.get("profile_id")),
("result_hash", packet.get("result_hash")),
("ayanamsa", profile.get("ayanamsa") or _as_dict(profile.get("effective_settings")).get("ayanamsa")),
("node_mode", profile.get("node_mode") or _as_dict(profile.get("effective_settings")).get("node_mode")),
):
if value in (None, ""):
blocking_reasons.append(f"provenance_missing:{key}")
checks.append(_check(f"provenance:{key}", "blocked", "missing"))
else:
checks.append(_check(f"provenance:{key}", "passed", "present"))
for key, value in (
("input_hash", profile.get("input_hash")),
("calculation_source", profile.get("algorithm")),
("profile_version", profile.get("profile_version")),
("engine_metadata", profile.get("engine")),
):
if not _present(value):
blocking_reasons.append(f"provenance_missing:{key}")
checks.append(_check(f"provenance:{key}", "blocked", "missing"))
else:
checks.append(_check(f"provenance:{key}", "passed", "present"))
binding = _as_dict(packet.get("result_binding"))
expected_input_hash = profile.get("input_hash")
expected_result_hash = packet.get("result_hash")
if binding.get("input_hash") != expected_input_hash or binding.get("result_hash") != expected_result_hash:
blocking_reasons.append("provenance_lineage_binding_invalid")
checks.append(_check("provenance:result_binding", "blocked", "does not match profile/result hashes"))
else:
checks.append(_check("provenance:result_binding", "passed", "input/result hashes match"))
chart_identity = _as_dict(packet.get("chart_identity"))
for key in (
"chart_profile_id",
"birth_data_status",
"rectification_status",
"approval_status",
):
value = chart_identity.get(key)
if value in (None, ""):
blocking_reasons.append(f"chart_identity_missing:{key}")
checks.append(_check(f"chart_identity:{key}", "blocked", "missing"))
else:
checks.append(_check(f"chart_identity:{key}", "passed", str(value)))
full_report_pack = _as_dict(packet.get("full_report_pack"))
sections = _as_dict(full_report_pack.get("sections"))
if not full_report_pack:
blocking_reasons.append("full_report_pack_missing")
checks.append(_check("full_report_pack", "blocked", "missing"))
else:
checks.append(_check("full_report_pack", "passed", str(full_report_pack.get("schema") or "present")))
for key in REQUIRED_PACK_SECTIONS:
section = _as_dict(sections.get(key))
if not section:
blocking_reasons.append(f"required_pack_section_missing:{key}")
checks.append(_check(f"pack_section:{key}", "blocked", "missing"))
else:
section_status = _status(section)
checks.append(_check(f"pack_section:{key}", "passed" if section_status in {"verified", "available"} else "warning", section_status))
if section_status in {"blocked", "partial", "partial_verified", "parameter_sensitive", "conflict"}:
warning_reasons.append(f"pack_section_limited:{key}:{section_status}")
d1_d60_ledger_section = _as_dict(sections.get("d1_d60_ledger"))
d1_d60_ledger = _as_dict(d1_d60_ledger_section.get("d1_to_d60"))
d1_d60_summary = _as_dict(d1_d60_ledger_section.get("summary"))
if d1_d60_ledger_section:
if len(d1_d60_ledger) != 60:
blocking_reasons.append("d1_d60_ledger_incomplete")
checks.append(_check("d1_d60_ledger:row_count", "blocked", f"expected 60, got {len(d1_d60_ledger)}"))
else:
checks.append(_check("d1_d60_ledger:row_count", "passed", "60 rows"))
expected_summary = {
"formal_traditional_division_count": 20,
"research_generic_dn_division_count": 40,
}
if {key: d1_d60_summary.get(key) for key in expected_summary} != expected_summary:
review_reasons.append("d1_d60_ledger_classification_invalid")
checks.append(_check("d1_d60_ledger:classification", "review_required", str(d1_d60_summary)))
else:
checks.append(_check("d1_d60_ledger:classification", "passed", "20 formal + 40 research"))
audit_appendix = _as_dict(sections.get("audit_appendix"))
if not audit_appendix:
blocking_reasons.append("audit_appendix_missing")
elif _status(audit_appendix) in {"blocked", "partial", "partial_verified", "parameter_sensitive", "conflict"}:
warning_reasons.append(f"audit_appendix_limited:{_status(audit_appendix)}")
markdown_checks = {
"chart_identity": ("基础资料表", "Birth Particulars"),
"d1": ("D1 — Rashi Chart", "D1 / Base Chart"),
"varga": ("Vargas I", "Varga Analysis"),
"d1_d60_ledger": ("D1D60 完整原始分盘账本", "D1-D60 Complete Raw Divisional Ledger"),
"timing": ("大运与时间主线", "Dasha Analysis"),
"kp": ("KP 三年流月支持", "KP and Transit Timing Layer"),
"annual": ("年度重点", "Annual / Tajika / Yearly Focus"),
"professional_support": ("专业支持专题:第二证据轴",),
"restricted_materials": ("未闭环专业层:可见但不入判断",),
"limitations": ("Blocked / Audit Appendix", "Conflict and Limitation"),
"audit_appendix": ("Audit Appendix", "Operator Appendix"),
}
if rendered_markdown is None:
warning_reasons.append("rendered_markdown_not_supplied")
checks.append(_check("reader_surface", "not_evaluated", "rendered Markdown was not supplied"))
else:
for name, markers in markdown_checks.items():
if _marker_present(rendered_markdown, markers):
checks.append(_check(f"reader_section:{name}", "passed", "rendered"))
else:
review_reasons.append(f"reader_section_missing:{name}")
checks.append(_check(f"reader_section:{name}", "review_required", "not rendered"))
worksheets = _as_dict(packet.get("worksheets"))
divisional = _as_dict(worksheets.get("divisional_and_special_charts"))
strengths = _as_dict(worksheets.get("strengths_and_scores"))
timing = _as_dict(worksheets.get("timing_and_predictive_systems"))
annual = _as_dict(timing.get("annual_tajika_pack"))
dasha_master = _as_dict(timing.get("dasha_master_pack"))
dasha_families = _as_dict(dasha_master.get("families"))
narayana = timing.get("narayana_dasha") or dasha_families.get("narayana")
patyayini = annual.get("patyayini_dasha") or annual.get("patyayini")
auxiliary_dasha_support = {
key: value
for key, value in dasha_families.items()
if key in {"yogini", "ashtottari", "kala_chakra"} and _present(value)
}
advanced = _as_dict(worksheets.get("advanced_systems"))
kp_monthly_report = _as_dict(advanced.get("kp_monthly_report"))
annual_audit = _as_dict(annual.get("audit"))
annual_sections = _as_dict(annual.get("report_sections"))
if not annual_audit or not annual_sections:
review_reasons.append("annual_evidence_status_missing")
checks.append(_check("annual_evidence_status", "review_required", "annual audit or report sections missing"))
else:
checks.append(_check("annual_evidence_status", "passed", "audit and report sections present"))
if rendered_markdown is not None and "年度专题证据状态" not in rendered_markdown:
review_reasons.append("annual_evidence_status_not_rendered")
checks.append(_check("annual_evidence_status:rendered", "review_required", "not rendered"))
if not kp_monthly_report:
review_reasons.append("kp_evidence_status_missing")
checks.append(_check("kp_evidence_status", "review_required", "monthly report missing"))
else:
kp_maturity = _as_dict(kp_monthly_report.get("maturity_profile"))
kp_restrictions = kp_monthly_report.get("must_not_claim")
if not kp_maturity.get("claim_status") or not isinstance(kp_restrictions, list) or not kp_restrictions:
review_reasons.append("kp_evidence_status_missing")
checks.append(_check("kp_evidence_status", "review_required", "maturity profile or must_not_claim missing"))
else:
checks.append(_check("kp_evidence_status", "passed", str(kp_maturity.get("claim_status"))))
if rendered_markdown is not None and "KP 使用边界" not in rendered_markdown:
review_reasons.append("kp_evidence_status_not_rendered")
checks.append(_check("kp_evidence_status:rendered", "review_required", "not rendered"))
if rendered_markdown is None:
checks.append(_check("three_year_kp_monthly", "not_evaluated", "rendered Markdown was not supplied"))
else:
rendered_years = sorted(
{int(year) for year in re.findall(r"^###\s+(\d{4})\s+KP 月度支持\s*$", rendered_markdown, re.MULTILINE)}
)
consecutive = len(rendered_years) >= 3 and all(
year == rendered_years[0] + offset for offset, year in enumerate(rendered_years)
)
if not consecutive:
review_reasons.append("three_year_kp_monthly_incomplete")
checks.append(_check("three_year_kp_monthly", "review_required", str(rendered_years)))
else:
checks.append(_check("three_year_kp_monthly", "passed", f"{rendered_years[0]}-{rendered_years[-1]}"))
source_years = sorted(
{
int(row["year"])
for row in kp_monthly_report.get("yearly_highlights", [])
if isinstance(row, dict) and isinstance(row.get("year"), int)
}
)
if source_years and source_years != rendered_years:
review_reasons.append("three_year_kp_monthly_source_render_mismatch")
checks.append(_check("three_year_kp_monthly:source_alignment", "review_required", f"source={source_years}; rendered={rendered_years}"))
elif source_years:
checks.append(_check("three_year_kp_monthly:source_alignment", "passed", str(source_years)))
natal_special_factor_support = {
"sahams": _as_dict(advanced.get("sahams")),
"avasthas": _as_dict(advanced.get("avasthas")),
"upagrahas": _as_dict(divisional.get("upagrahas")),
}
natal_special_factor_support = {
key: value for key, value in natal_special_factor_support.items() if _present(value)
}
special_lagnas = _as_dict(divisional.get("special_lagnas"))
required_special_lagnas = {
"Bhava_Lagna": "Bhava Lagna",
"Hora_Lagna": "Hora Lagna",
"Ghati_Lagna": "Ghati Lagna",
"ViGhati_Lagna": "ViGhati Lagna",
"Sree_Lagna": "Sree Lagna",
"Indu_Lagna": "Indu Lagna",
}
normalized_special_lagnas = {
str(key).strip().lower().replace(" ", "_"): _as_dict(value)
for key, value in special_lagnas.items()
}
missing_special_lagnas: list[str] = []
for key, display_name in required_special_lagnas.items():
row = normalized_special_lagnas.get(key.lower(), {})
explicit_status = _status(row)
has_position = any(row.get(field) not in (None, "") for field in ("sign", "degree", "longitude"))
explicitly_blocked = explicit_status in {"blocked", "not_applicable"}
if not has_position and not explicitly_blocked:
missing_special_lagnas.append(key)
checks.append(_check(f"special_lagna:{key}", "review_required", "missing value or blocked status"))
else:
checks.append(_check(f"special_lagna:{key}", "passed", explicit_status if explicitly_blocked else "position present"))
if rendered_markdown is not None and (has_position or explicitly_blocked) and display_name not in rendered_markdown:
review_reasons.append(f"special_lagna_not_rendered:{key}")
checks.append(_check(f"special_lagna:{key}:rendered", "review_required", "not rendered"))
if len(missing_special_lagnas) >= 2:
blocking_reasons.append("special_lagnas_incomplete")
elif missing_special_lagnas:
review_reasons.extend(f"special_lagna_missing:{key}" for key in missing_special_lagnas)
wealth_vargas = _as_dict(divisional.get("varga_full"))
for key, label in (("D2_Hora", "D2"), ("D11_Rudramsa", "D11")):
chart = _as_dict(wealth_vargas.get(key))
ascendant = _as_dict(chart.get("Ascendant") or chart.get("ascendant"))
has_ascendant = any(ascendant.get(field) not in (None, "") for field in ("sign", "degree_in_sign", "longitude"))
if not has_ascendant:
blocking_reasons.append(f"wealth_varga_structure_missing:{label}")
checks.append(_check(f"wealth_varga:{label}", "blocked", "missing ascendant structure"))
else:
checks.append(_check(f"wealth_varga:{label}", "passed", "ascendant structure present"))
coverage_inputs = (
(
"wealth_d2_chart",
"required_support",
"worksheets.divisional_and_special_charts.varga_full.D2_Hora",
_as_dict(_as_dict(divisional.get("varga_full")).get("D2_Hora")),
("D2Hora 财富分盘)",),
),
(
"wealth_d11_gains_chart",
"required_support",
"worksheets.divisional_and_special_charts.varga_full.D11_Rudramsa",
_as_dict(_as_dict(divisional.get("varga_full")).get("D11_Rudramsa")),
("D11Rudramsa 收益分盘)",),
),
(
"special_lagnas",
"required_support",
"worksheets.divisional_and_special_charts.special_lagnas",
divisional.get("special_lagnas"),
("Hora Lagna", "Bhava Lagna", "Standard Lagnas", "Special Lagnas"),
),
(
"patyayini_annual_support",
"required_support",
"worksheets.timing_and_predictive_systems.annual_tajika_pack.patyayini_dasha",
patyayini,
("Patyayini Dasha", "Patyayini"),
),
(
"narayana_alignment",
"required_support",
"worksheets.timing_and_predictive_systems.narayana_dasha",
narayana,
("Narayana Rashi Dasha", "Narayana 参考对齐", "Narayana"),
),
(
"functional_benefic_malefic",
"required_support",
"worksheets.strengths_and_scores.functional_benefic_malefic",
strengths.get("functional_benefic_malefic"),
("功能性吉凶", "Functional Benefic/Malefic"),
),
(
"auxiliary_dasha_support",
"required_support",
"worksheets.timing_and_predictive_systems.dasha_master_pack.families",
auxiliary_dasha_support,
("辅助大运族:Yogini / Ashtottari / Kala Chakra",),
),
(
"natal_special_factor_support",
"required_support",
"worksheets.advanced_systems.sahams/avasthas and worksheets.divisional_and_special_charts.upagrahas",
natal_special_factor_support,
("本命补充因子:Sahams / Avasthas / Upagrahas",),
),
)
professional_coverage_manifest: list[dict[str, Any]] = []
for material_id, tier, source_reference, upstream_value, markers in coverage_inputs:
entry, review_reason = _coverage_entry(
material_id,
tier,
source_reference,
upstream_value,
rendered_markdown,
markers,
)
professional_coverage_manifest.append(entry)
if review_reason:
review_reasons.append(review_reason)
patyayini_rows_present = _patyayini_normalized_rows_present(packet)
if patyayini_rows_present is True:
checks.append(_check("patyayini:normalized_rows", "passed", "present"))
elif patyayini_rows_present is False:
review_reasons.append("patyayini_normalized_rows_missing")
checks.append(_check("patyayini:normalized_rows", "review_required", "missing or empty"))
overrides = _as_dict(packet.get("professional_coverage_overrides"))
for material_id in RESTRICTED_MATERIAL_IDS:
override = _as_dict(overrides.get(material_id))
override_status = str(override.get("status") or "restricted_or_unclosed")
professional_coverage_manifest.append(
{
"material_id": material_id,
"admission_tier": "restricted_or_unclosed",
"report_role": "restricted_research_material",
"source_reference": "professional_coverage_overrides",
"upstream_present": bool(override),
"status": override_status,
"source_status": override_status,
"surface_location": "blocked_or_research_appendix",
"rendered": None,
"limitation_reference": "must_not_generate_result_claim",
}
)
if override_status in {"confirmed", "verified", "approved", "promoted"}:
blocking_reasons.append(f"restricted_material_promoted:{material_id}")
checks.append(_check(f"restricted:{material_id}", "blocked", override_status))
status = _promotion_status(blocking_reasons, review_reasons, warning_reasons)
return {
"schema_version": SCHEMA_VERSION,
"status": status,
"checks": checks,
"blocking_reasons": blocking_reasons,
"review_reasons": review_reasons,
"warning_reasons": warning_reasons,
"professional_coverage_manifest": professional_coverage_manifest,
"audit_reference": {
"source_schema": source_schema or None,
"calculation_profile_id": packet.get("calculation_profile_id") or profile.get("profile_id"),
"result_hash": packet.get("result_hash"),
"read_only": True,
},
}
def main() -> int:
parser = argparse.ArgumentParser(description="Evaluate an assembled PL9 full-report packet without modifying it")
parser.add_argument("--packet", required=True, help="PL9 export JSON packet")
parser.add_argument("--markdown", help="Optional rendered Markdown to validate reader-facing coverage")
args = parser.parse_args()
packet = json.loads(Path(args.packet).read_text(encoding="utf-8"))
markdown = Path(args.markdown).read_text(encoding="utf-8") if args.markdown else None
print(json.dumps(evaluate_full_report(packet, markdown), ensure_ascii=False, indent=2, sort_keys=True))
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+37 -4
View File
@@ -4750,6 +4750,21 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
lat = self._get_float(body, 'lat', 0, -90, 90)
lon = self._get_float(body, 'lon', 0, -180, 180)
tz = self._parse_timezone(body, lat, lon, year, month, day, hour, minute, second)
today = body.get('today') or body.get('current_date')
if not isinstance(today, str) or not today.strip():
today = datetime.now().strftime('%Y-%m-%d')
else:
today = today.strip()[:10]
raw_target = body.get('target_year')
if raw_target in (None, ''):
target_year = int(today[:4])
else:
target_year = self._get_int(body, 'target_year', int(today[:4]), 1800, 2400)
raw_age = body.get('age')
if raw_age in (None, ''):
age = target_year - year
else:
age = self._get_int(body, 'age', target_year - year, 0, 120)
return {
'year': year,
'month': month,
@@ -4762,8 +4777,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'tz': tz,
'ayanamsa': _request_ayanamsa(body),
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
'today': body.get('today') or body.get('current_date'),
'today': today,
'transit_date': body.get('transit_date') or body.get('reference_date'),
'target_year': target_year,
'age': age,
'birth_time_accuracy': body.get('birth_time_accuracy', 'confirmed'),
'candidate_range': body.get('candidate_range'),
'representative_time': body.get('representative_time'),
@@ -6567,6 +6584,22 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
if not isinstance(node_mode, str) or node_mode not in {'mean', 'true'}:
node_mode = 'mean'
today = body.get('today') or body.get('current_date')
if not isinstance(today, str) or not today.strip():
today = datetime.now().strftime('%Y-%m-%d')
else:
today = today.strip()[:10]
raw_target = body.get('target_year')
if raw_target in (None, ''):
target_year = int(today[:4])
else:
target_year = self._get_int(body, 'target_year', int(today[:4]), 1800, 2400)
raw_age = body.get('age')
if raw_age in (None, ''):
age = target_year - year
else:
age = self._get_int(body, 'age', target_year - year, 0, 120)
args = type('Args', (), {
'year': year,
'month': month,
@@ -6579,10 +6612,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'tz': tz,
'node_mode': node_mode,
'ayanamsa': _request_ayanamsa(body),
'age': body.get('age'),
'today': body.get('today') or body.get('current_date'),
'age': age,
'today': today,
'transit_date': body.get('transit_date'),
'target_year': body.get('target_year'),
'target_year': target_year,
'birth_time_accuracy': body.get('birth_time_accuracy', 'confirmed'),
'candidate_range': body.get('candidate_range'),
'representative_time': body.get('representative_time'),
+217 -19
View File
@@ -76,10 +76,6 @@ def build_report_theme_catalog(report):
return []
def attach_calculation_profile(payload, args=None):
return payload
def _try_attr_import(modname, attr):
for name in (modname, f"scripts.{modname}"):
try:
@@ -89,10 +85,6 @@ def _try_attr_import(modname, attr):
return None
def build_calculation_profile(args=None):
return {"status": "blocked", "reason": "calculation_profile_contract_absent"}
from ayanamsa_utils import (
AYANAMSA_DISPLAY_NAMES,
AYANAMSA_MODES,
@@ -110,6 +102,27 @@ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.dirname(SCRIPT_DIR)
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
if SCRIPT_DIR not in sys.path:
sys.path.insert(0, SCRIPT_DIR)
try:
from calculation_profile_contract import (
attach_calculation_profile,
build_calculation_profile,
)
except ImportError: # pragma: no cover - package import
try:
from scripts.calculation_profile_contract import (
attach_calculation_profile,
build_calculation_profile,
)
except ImportError:
def attach_calculation_profile(payload, args=None):
return payload
def build_calculation_profile(args=None):
return {"status": "blocked", "reason": "calculation_profile_contract_absent"}
HOME_DIR = os.path.expanduser('~')
CLAW_DIR = os.path.join(HOME_DIR, 'WorkBuddy', 'Claw')
DB_PATH = os.path.join(CLAW_DIR, 'vedic_astrology_validation.db')
@@ -2384,7 +2397,12 @@ def _attach_report_governance_contracts(packet: dict, args) -> dict:
if birth_time_sensitivity.get('status') == 'candidate_window_only':
packet['birth_time_sensitivity'] = birth_time_sensitivity
else:
packet.pop('birth_time_sensitivity', None)
packet['birth_time_sensitivity'] = {
'schema': 'jyotish.report_birth_time_sensitivity.v1',
'status': 'not_rectified',
'accuracy': birth_time_sensitivity.get('accuracy') or _birth_time_accuracy(args),
'reason': 'no_candidate_window',
}
packet['timing_boundary_attribution'] = _build_timing_boundary_attribution(packet)
packet['module_execution_audit'] = _build_module_execution_audit(packet)
ai_pack = ((packet.get('raw_full_reading') or {}).get('ai_prompt_pack') or {})
@@ -2520,6 +2538,34 @@ def _attach_personal_report_producer(packet: dict) -> dict:
return packet
def _native_dasha_family_status(module) -> dict:
if not isinstance(module, dict) or not module:
return {'execution_status': 'blocked', 'reason': 'native_module_absent'}
if module.get('error'):
return {'execution_status': 'blocked', 'reason': str(module.get('error'))}
status = str(module.get('status') or '').strip().lower()
if status in {'blocked', 'error'}:
return {
'execution_status': 'blocked',
'reason': module.get('reason') or module.get('status') or 'blocked',
}
return {
'execution_status': 'executed',
'status': module.get('status') or 'executed',
}
def _native_dasha_master_families(modules: dict) -> dict:
modules = modules if isinstance(modules, dict) else {}
return {
'vimshottari': _native_dasha_family_status(modules.get('dasha')),
'narayana': _native_dasha_family_status(modules.get('narayana_dasha')),
'yogini': _native_dasha_family_status(modules.get('yogini_dasha')),
'ashtottari': _native_dasha_family_status(modules.get('ashtottari_dasha')),
'kala_chakra': _native_dasha_family_status(modules.get('kalachakra_dasha')),
}
def _build_pl9_full_dasha_section(packet: dict, modules: dict) -> dict:
try:
from professional_parity_closure import build_dasha_master_pack
@@ -2563,7 +2609,12 @@ def _build_pl9_full_dasha_section(packet: dict, modules: dict) -> dict:
functional_classification,
)
except Exception as exc:
return {'schema': 'dasha_master_report_pack_v1', 'status': 'blocked', 'reason': str(exc)}
return {
'schema': 'dasha_master_report_pack_v1',
'status': 'blocked',
'reason': str(exc),
'families': _native_dasha_master_families(modules),
}
def _attach_profile_id_to_dasha_section(dasha: dict, profile: dict) -> dict:
@@ -2832,6 +2883,87 @@ def _render_shared_report_identity_header(packet: dict) -> list[str]:
]
def _status_cell(value) -> str:
text = str(value if value not in (None, '') else 'blocked').strip()
return text.replace('|', '/')
def _render_finished_reading_navigation(packet: dict) -> list[str]:
"""Deterministic reading-nav / QA layer. Numbers come from this packet only."""
quality = packet.get('report_quality_gate') if isinstance(packet.get('report_quality_gate'), dict) else {}
worksheets = packet.get('worksheets') if isinstance(packet.get('worksheets'), dict) else {}
timing = worksheets.get('timing_and_predictive_systems') if isinstance(worksheets.get('timing_and_predictive_systems'), dict) else {}
advanced = worksheets.get('advanced_systems') if isinstance(worksheets.get('advanced_systems'), dict) else {}
strengths = worksheets.get('strengths_and_scores') if isinstance(worksheets.get('strengths_and_scores'), dict) else {}
divisional = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {}
annual = timing.get('annual_tajika_pack') if isinstance(timing.get('annual_tajika_pack'), dict) else {}
annual_series = timing.get('annual_tajika_series') if isinstance(timing.get('annual_tajika_series'), dict) else {}
kp_monthly = advanced.get('kp_monthly_report') if isinstance(advanced.get('kp_monthly_report'), dict) else {}
sensitivity = packet.get('birth_time_sensitivity') if isinstance(packet.get('birth_time_sensitivity'), dict) else {}
functional = strengths.get('functional_benefic_malefic') if isinstance(strengths.get('functional_benefic_malefic'), dict) else {}
checks = quality.get('checks') if isinstance(quality.get('checks'), list) else []
manifest = quality.get('professional_coverage_manifest') if isinstance(quality.get('professional_coverage_manifest'), list) else []
year_keys = list((annual_series.get('years') or {}).keys()) if isinstance(annual_series.get('years'), dict) else []
months = kp_monthly.get('months') if isinstance(kp_monthly.get('months'), list) else []
coverage_rows = [
('三年年度展开', 'present' if len(year_keys) >= 3 else ('degraded' if year_keys else 'missing'), _status_cell(annual.get('status') or annual_series.get('status'))),
('KP 三年流月支持', 'present' if len(months) >= 36 else ('degraded' if months else 'missing'), _status_cell(kp_monthly.get('status') or kp_monthly.get('reason'))),
('功能性吉凶 / Yogakaraka', 'present' if functional else 'missing', _status_cell(functional.get('status') if functional else 'blocked')),
('Upagraha 表', 'present' if divisional.get('upagrahas') else 'missing', _status_cell('partial_verified' if divisional.get('upagrahas') else 'blocked')),
('校时敏感层', 'present' if sensitivity.get('status') == 'candidate_window_only' else 'degraded', _status_cell(sensitivity.get('status') or 'not_rectified')),
]
if manifest:
coverage_rows = [
(
str(item.get('material_id') or item.get('source_reference') or 'item'),
'present' if item.get('rendered') or item.get('upstream_present') else 'missing',
_status_cell(item.get('status') or item.get('source_status')),
)
for item in manifest
if isinstance(item, dict)
]
check_passed = sum(1 for item in checks if isinstance(item, dict) and item.get('status') == 'passed')
check_total = len(checks) if checks else 0
quality_status = _status_cell(quality.get('status') or quality.get('reason') or 'not_evaluated')
lines = [
'## 成品阅读导航',
'',
'### 先读什么',
'',
'1. 本命主轴与功能性吉凶,确认计算口径。',
'2. 大运与时间主线,以及三年年度重点。',
'3. 事业 / 财运 / 关系专题中的 KP 小表与月度支持。',
'4. 校时敏感层:有候选窗才读矩阵,没有则视为未做校时。',
'5. 质量验收矩阵与对照覆盖表,只用来核对装配,不改写正文标签。',
'',
'### 结论等级规则',
'',
'- `executed` / `partial_verified`:可阅读,但仍受参数与证据边界约束。',
'- `parameter_sensitive`:可交叉核对,不得升级为精确事件。',
'- `blocked` / `missing_in_local`:保持原标签,不得涂成可读结论。',
'',
'### 专题判读协议',
'',
'- 事业先看 2/6/10/11 宫 KP 结构与 D10,再看三年月度支持。',
'- 财运先看 2/5/9/11 宫与 D2/D11,再看年度 Sahams 坐标。',
'- 关系先看 2/7/11 宫与 D9 / UL,再看年内换挡点。',
'',
'### 质量验收矩阵',
'',
f'- 本次质量门状态:`{quality_status}`',
f'- 检查项:{check_passed}/{check_total} passed' if check_total else '- 检查项:本次未返回逐项检查,保持 not_evaluated。',
'',
'### 对照覆盖表',
'',
'| 段落 | 装配 | 状态标签 |',
'|------|------|----------|',
]
for name, coverage, status in coverage_rows:
lines.append(f'| {_status_cell(name)} | {coverage} | {status} |')
lines.append('')
return lines
def render_pl9_markdown(packet: dict) -> str:
"""Render the full PL9 Markdown report."""
birth = packet.get('birth_info', {}) if isinstance(packet, dict) else {}
@@ -3158,6 +3290,7 @@ def render_pl9_markdown(packet: dict) -> str:
lines = ['# 个人印度占星报告', '']
lines.extend(_render_shared_report_identity_header(packet))
lines.extend(_reader_engine_boundary_notice_markdown())
lines.extend(_render_finished_reading_navigation(packet))
if isinstance(producer, dict) and producer:
main_body = producer.get('main_body') if isinstance(producer.get('main_body'), dict) else {}
@@ -3577,8 +3710,19 @@ def render_pl9_markdown(packet: dict) -> str:
annual_pack = timing_sheet.get('annual_tajika_pack') if isinstance(timing_sheet.get('annual_tajika_pack'), dict) else {}
annual_sections = annual_pack.get('sections') if isinstance(annual_pack.get('sections'), dict) else {}
year_lord = annual_sections.get('year_lord') if isinstance(annual_sections.get('year_lord'), dict) else {}
if not year_lord:
year_lord = annual_pack.get('year_lord') if isinstance(annual_pack.get('year_lord'), dict) else {}
rows: list[tuple[str, str, str, str]] = []
if not families:
families = _native_dasha_master_families({
'dasha': timing_sheet.get('dasha') or modules.get('dasha'),
'narayana_dasha': timing_sheet.get('narayana_dasha') or modules.get('narayana_dasha'),
'yogini_dasha': modules.get('yogini_dasha'),
'ashtottari_dasha': modules.get('ashtottari_dasha'),
'kalachakra_dasha': modules.get('kalachakra_dasha'),
})
def _family_status_row(label: str, key: str, confidence_when_executed: str, blocked_text: str) -> None:
family = families.get(key) if isinstance(families.get(key), dict) else {}
if not family:
@@ -8590,6 +8734,8 @@ def render_pl9_markdown(packet: dict) -> str:
lines.extend(yoga_section)
professional_support = full_report_pack.get('sections', {}).get('professional_support') if isinstance(full_report_pack.get('sections'), dict) else {}
if not isinstance(professional_support, dict):
professional_support = {}
professional_topics = professional_support.get('topics') if isinstance(professional_support.get('topics'), dict) else {}
if professional_support:
special_topic = professional_topics.get('special_lagnas') if isinstance(professional_topics.get('special_lagnas'), dict) else {}
@@ -9307,12 +9453,20 @@ def render_pl9_markdown(packet: dict) -> str:
lines.append(f"- 边界:{_md_cell(blind_policy.get('boundary'))}")
if event_replay.get('family_d12_binding'):
lines.append(f"- D12/父母家庭绑定:{_md_cell(event_replay.get('family_d12_binding'))}")
if birth_time_sensitivity.get('status') == 'candidate_window_only':
if birth_time_sensitivity.get('status') == 'candidate_window_only' and isinstance(birth_time_sensitivity.get('report_projection'), dict):
try:
from flexible_birth_time_report_section import render_flexible_birth_time_report_section
except ImportError: # pragma: no cover - package import
from scripts.flexible_birth_time_report_section import render_flexible_birth_time_report_section
lines.extend(['', render_flexible_birth_time_report_section(birth_time_sensitivity['report_projection']).rstrip()])
else:
lines.extend([
'',
'### 出生时间敏感度',
'',
'未做校时。当前没有可用的候选窗,因此不输出分钟级敏感性矩阵或校时分钟排行。',
'',
])
lines.extend(['', '## 校时证据领域合同', ''])
lines.append(_md_cell(rectification_evidence_contract.get('claim_boundary', 'rectification_evidence_contract_missing')))
@@ -12925,6 +13079,7 @@ def cmd_kp(args):
chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(kp_args)
if chart is None:
_apply_ayanamsa(requested_report_ayanamsa)
return {"error": "swisseph未安装"}
asc_sign = chart.get("ascendant", {}).get("sign", "Aries")
@@ -12999,7 +13154,9 @@ def cmd_kp(args):
'The main report calculation profile is not overwritten. External same-input KP replay and timing parity remain unclosed.'
),
}
return _build_response_envelope('kp', attach_calculation_profile(result, kp_args), args=kp_args, execution_status='executed')
envelope = _build_response_envelope('kp', attach_calculation_profile(result, kp_args), args=kp_args, execution_status='executed')
_apply_ayanamsa(requested_report_ayanamsa)
return envelope
# ============================================================================
@@ -14575,6 +14732,19 @@ def cmd_full_reading(args):
report['chart'] = chart
report['modules']['chart'] = chart
# KP is a distinct cusp/sub-lord system. Retain its raw output so the
# unified evidence archive can expose it without treating it as Parashari.
# cmd_kp temporarily switches Swiss Ephemeris to KP ayanamsa; restore the
# report profile immediately so later full-reading producers stay on Raman.
try:
report['modules']['kp'] = cmd_kp(args)
except Exception as e:
report['modules']['kp'] = {"status": "blocked", "reason": f"KP producer failed: {e}"}
report['errors'].append(f"kp: {e}")
finally:
_apply_ayanamsa(_current_ayanamsa_name(args))
planets = chart.get('planets', {})
report['modules']['planetary_friendship'] = _build_planetary_friendship_snapshot(planets)
ascendant = chart.get('ascendant', {})
@@ -14584,6 +14754,19 @@ def cmd_full_reading(args):
planet_degs = {pn: pd.get('degree_in_sign_raw', pd.get('degree_in_sign', pd['degree'] % 30)) for pn, pd in planets.items() if isinstance(pd, dict) and 'degree' in pd}
houses = _build_whole_sign_houses(asc_idx, planets)
report['modules']['house_map'] = houses
try:
report['modules'].update(_build_natal_foundation_modules(
args,
planet_lons,
asc_lon=asc_deg,
jd=jd,
))
except Exception as e:
report['errors'].append(f"natal-foundation: {e}")
try:
report['modules']['varga_research_high'] = _build_research_high_varga(planet_lons, asc_deg)
except Exception as e:
report['errors'].append(f"varga-research-high: {e}")
planet_sign_indices = {}
for pn, pd in planets.items():
if isinstance(pd, dict) and 'sign' in pd:
@@ -14593,7 +14776,7 @@ def cmd_full_reading(args):
'core_chart_and_setup',
stage_started,
enabled=profile_stages,
details={'modules': ['chart', 'house_map']},
details={'modules': ['chart', 'house_map', 'kp', 'upagrahas', 'functional_benefic_malefic']},
)
# ── Step 1.5: Special Lagnas 特殊上升点 (v4.4.0) ──
@@ -16340,6 +16523,7 @@ def build_pl9_style_export_packet(full_reading: dict, include_raw: bool = False)
dasha_master_pack = {
'schema': 'dasha_master_report_pack_v1',
'audit': {'status': 'blocked', 'reason': f'Dasha master pack assembly failed: {exc}'},
'families': _native_dasha_master_families(modules),
}
dasha_interpretation_pack = {
'schema': 'pl9.dasha_interpretation_pack.v1',
@@ -16979,11 +17163,17 @@ def build_professional_report_reference_packet(
'reason': 'full_report_quality_gate_absent',
}
else:
public_quality_input = sanitize_professional_report_reference(final_packet)
final_packet['report_quality_gate'] = evaluate_full_report(
public_quality_input,
render_pl9_markdown(public_quality_input),
)
try:
public_quality_input = sanitize_professional_report_reference(final_packet)
final_packet['report_quality_gate'] = evaluate_full_report(
public_quality_input,
render_pl9_markdown(public_quality_input),
)
except Exception:
final_packet['report_quality_gate'] = {
'status': 'blocked',
'reason': 'full_report_quality_gate_failed',
}
build_shared_full_report_authority = _try_attr_import(
'shared_full_report_authority', 'build_shared_full_report_authority'
)
@@ -16995,7 +17185,15 @@ def build_professional_report_reference_packet(
'read_only': True,
}
else:
final_packet['shared_full_report_authority'] = build_shared_full_report_authority(final_packet)
try:
final_packet['shared_full_report_authority'] = build_shared_full_report_authority(final_packet)
except Exception:
final_packet['shared_full_report_authority'] = {
'schema_version': 'jyotish.shared_full_report_authority.v1',
'status': 'blocked',
'reason': 'shared_full_report_authority_failed',
'read_only': True,
}
return sanitize_professional_report_reference(final_packet)
def cmd_pl9_export(args):
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Contract helpers for the KP three-year monthly report packet."""
from __future__ import annotations
try: # pragma: no cover - import path differs under CLI vs pytest
from kp_system import build_kp_western_support_surface, kp_maturity_profile
except ImportError: # pragma: no cover
from scripts.kp_system import build_kp_western_support_surface, kp_maturity_profile
def build_kp_monthly_report_contract(
*, start_month: str, month_count: int, timezone_offset: float
) -> dict:
maturity_profile = kp_maturity_profile()
return {
"schema": "jyotish.kp_monthly_report.v1",
"profile": {
"ayanamsa": "kp",
"house_system": "placidus",
"node_mode": "mean",
"status": "parameter_sensitive",
},
"maturity_profile": {
"claim_status": maturity_profile.get("claim_status"),
"truth_matrix_allowed": maturity_profile.get("truth_matrix_allowed") is True,
},
"window": {
"start_month": start_month,
"month_count": month_count,
"timezone_offset": timezone_offset,
"anchor_policy": "local_month_start_noon",
},
"supporting_systems": {
"western_kp_support": build_kp_western_support_surface(
maturity_profile=maturity_profile,
)
},
"must_not_claim": [
"exact_event_timing",
"specific_event_prediction",
],
}
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""Assembler for the KP three-year monthly report packet."""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
try: # pragma: no cover - import path differs under CLI vs pytest
from domain_calculation_service import compute_chart
from kp_monthly_report_contract import build_kp_monthly_report_contract
from kp_monthly_theme_support import build_kp_monthly_theme_support
from kp_monthly_transits import build_monthly_transit_snapshot
from kp_monthly_vimshottari import build_monthly_vimshottari_snapshot
from kp_system import build_kp_western_support_surface, kp_maturity_profile
except ImportError: # pragma: no cover
from scripts.domain_calculation_service import compute_chart
from scripts.kp_monthly_report_contract import build_kp_monthly_report_contract
from scripts.kp_monthly_theme_support import build_kp_monthly_theme_support
from scripts.kp_monthly_transits import build_monthly_transit_snapshot
from scripts.kp_monthly_vimshottari import build_monthly_vimshottari_snapshot
from scripts.kp_system import build_kp_western_support_surface, kp_maturity_profile
_HUMAN_LABELS = {
"Jupiter": "木星",
"Saturn": "土星",
"Rahu": "北交点",
"Ketu": "南交点",
"Aries": "白羊座",
"Taurus": "金牛座",
"Gemini": "双子座",
"Cancer": "巨蟹座",
"Leo": "狮子座",
"Virgo": "处女座",
"Libra": "天秤座",
"Scorpio": "天蝎座",
"Sagittarius": "射手座",
"Capricorn": "摩羯座",
"Aquarius": "水瓶座",
"Pisces": "双鱼座",
}
def _add_months(start_year: int, start_month: int, offset: int) -> tuple[int, int]:
month_index = (start_month - 1) + offset
return start_year + (month_index // 12), (month_index % 12) + 1
def _label(value: str | None) -> str:
if not value:
return "-"
return _HUMAN_LABELS.get(value, value)
def _build_slow_planet_signals(
current_transits: dict,
next_transits: dict | None,
) -> list[str]:
signals = []
current_planets = current_transits.get("planets") if isinstance(current_transits.get("planets"), dict) else {}
next_planets = next_transits.get("planets") if isinstance(next_transits, dict) and isinstance(next_transits.get("planets"), dict) else {}
for planet in ("Jupiter", "Saturn", "Rahu", "Ketu"):
current = current_planets.get(planet) if isinstance(current_planets.get(planet), dict) else {}
nxt = next_planets.get(planet) if isinstance(next_planets.get(planet), dict) else {}
current_sign = current.get("sign")
next_sign = nxt.get("sign")
if current_sign and next_sign and current_sign != next_sign:
signals.append(f"{_label(planet)}换座:{_label(current_sign)}{_label(next_sign)}")
elif current.get("retrograde"):
signals.append(f"{_label(planet)}逆行强调")
return signals
def _highlight_reason(row: dict) -> str:
signals = row.get("slow_planet_signals") if isinstance(row.get("slow_planet_signals"), list) else []
if signals:
return "".join(str(item) for item in signals[:2])
levels = row.get("vimshottari_five_levels") if isinstance(row.get("vimshottari_five_levels"), dict) else {}
level_rows = levels.get("levels") if isinstance(levels.get("levels"), dict) else {}
md = level_rows.get("mahadasha") if isinstance(level_rows.get("mahadasha"), dict) else {}
ad = level_rows.get("antardasha") if isinstance(level_rows.get("antardasha"), dict) else {}
return f"主运/次运焦点:{_label(md.get('lord'))} / {_label(ad.get('lord'))}"
def _build_yearly_highlights(month_rows: list[dict]) -> list[dict]:
years: dict[int, list[dict]] = {}
for row in month_rows:
month = str(row.get("month") or "")
if len(month) < 4:
continue
year = int(month[:4])
years.setdefault(year, []).append(row)
yearly = []
for year in sorted(years):
ranked = sorted(
years[year],
key=lambda item: (
len(item.get("slow_planet_signals") or []),
1 if any("换座" in str(signal) for signal in (item.get("slow_planet_signals") or [])) else 0,
),
reverse=True,
)
picks = []
for row in ranked:
picks.append({"month": row.get("month"), "reason": _highlight_reason(row), "status": "parameter_sensitive"})
if len(picks) == 4:
break
yearly.append({"year": year, "months": picks, "status": "parameter_sensitive"})
return yearly
def _resolve_cmd_kp():
try: # pragma: no cover - script execution path
from __main__ import cmd_kp as resolved
return resolved
except ImportError:
pass
scripts_dir = Path(__file__).resolve().parent
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
try: # pragma: no cover - pytest/module import path
from jyotish_engine import cmd_kp as resolved
return resolved
except ImportError: # pragma: no cover
from scripts.jyotish_engine import cmd_kp as resolved
return resolved
def build_kp_monthly_report_packet(
*,
birth_payload: dict,
start_month: str,
month_count: int,
western_support: dict | None = None,
) -> dict:
start_year, start_month_number = [int(part) for part in start_month.split("-", 1)]
natal_chart = compute_chart({**birth_payload, "ayanamsa": "lahiri", "node_mode": "mean"})
moon_lon = natal_chart.get("planets", {}).get("Moon", {}).get("degree_raw")
kp_args = SimpleNamespace(
year=birth_payload["year"],
month=birth_payload["month"],
day=birth_payload["day"],
hour=birth_payload["hour"],
minute=birth_payload["minute"],
second=birth_payload.get("second", 0),
lat=birth_payload["lat"],
lon=birth_payload["lon"],
tz=birth_payload["tz"],
node_mode="mean",
ayanamsa="lahiri",
)
natal_kp = _resolve_cmd_kp()(kp_args)
packet = build_kp_monthly_report_contract(
start_month=start_month,
month_count=month_count,
timezone_offset=float(birth_payload["tz"]),
)
maturity_profile = kp_maturity_profile()
western_support_surface = build_kp_western_support_surface(
western_support,
maturity_profile=maturity_profile,
)
packet["supporting_systems"]["western_kp_support"] = western_support_surface
packet["western_support"] = western_support_surface
packet["months"] = []
month_rows = []
for offset in range(month_count):
year, month = _add_months(start_year, start_month_number, offset)
anchor_dt = datetime(year, month, 1, 12, 0, 0)
monthly_levels = build_monthly_vimshottari_snapshot(
birth_dt=datetime(
birth_payload["year"],
birth_payload["month"],
birth_payload["day"],
birth_payload["hour"],
birth_payload["minute"],
birth_payload.get("second", 0),
),
moon_lon=moon_lon,
anchor_dt=anchor_dt,
)
monthly_transits = build_monthly_transit_snapshot(
year=year,
month=month,
lat=float(birth_payload["lat"]),
lon=float(birth_payload["lon"]),
tz=float(birth_payload["tz"]),
)
theme_support = build_kp_monthly_theme_support(
natal_kp=natal_kp,
monthly_levels=monthly_levels.get("levels") or {},
monthly_transits=monthly_transits,
)
month_rows.append(
{
"month": f"{year:04d}-{month:02d}",
"anchor_local": monthly_transits["anchor_local"],
"vimshottari_five_levels": monthly_levels,
"monthly_transits": monthly_transits,
"theme_support": theme_support,
"status": "parameter_sensitive",
"must_not_claim": packet["must_not_claim"],
}
)
for index, row in enumerate(month_rows):
next_transits = None
if index + 1 < len(month_rows):
next_transits = month_rows[index + 1].get("monthly_transits")
row["slow_planet_signals"] = _build_slow_planet_signals(
row.get("monthly_transits") or {},
next_transits,
)
packet["months"] = month_rows
packet["yearly_highlights"] = _build_yearly_highlights(month_rows)
packet["natal_kp"] = natal_kp
return packet
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Theme-level KP monthly support aggregation."""
from __future__ import annotations
_DOMAIN_HOUSES = {
"career": (2, 6, 10, 11),
"wealth": (2, 5, 9, 11),
"relationship": (2, 7, 11),
}
_DOMAIN_LABELS = {
"career": "事业",
"wealth": "财务",
"relationship": "关系",
}
_HOUSE_LABELS = {
2: "收入与资源",
5: "机会与投入",
6: "职责与压力",
7: "合作与伴侣",
9: "运气与扩张",
10: "事业与位置",
11: "回报与支持",
}
_DOMAIN_TEMPLATES = {
"career": "这段时间更适合把重心放在职责变化、位置调整和回报兑现的节奏里观察。",
"wealth": "这段时间更适合把注意力放在现金流、机会选择和回报兑现的节奏里观察。",
"relationship": "这段时间更适合把重心放在合作默契、关系回应和现实支持的变化里观察。",
}
_PLANET_CN = {
"Sun": "太阳",
"Moon": "月亮",
"Mars": "火星",
"Mercury": "水星",
"Jupiter": "木星",
"Venus": "金星",
"Saturn": "土星",
"Rahu": "北交点",
"Ketu": "南交点",
}
_SIGN_CN = {
"Aries": "白羊座",
"Taurus": "金牛座",
"Gemini": "双子座",
"Cancer": "巨蟹座",
"Leo": "狮子座",
"Virgo": "处女座",
"Libra": "天秤座",
"Scorpio": "天蝎座",
"Sagittarius": "射手座",
"Capricorn": "摩羯座",
"Aquarius": "水瓶座",
"Pisces": "双鱼座",
}
def _collect_active_houses(houses: dict, house_numbers: tuple[int, ...]) -> list[int]:
active: list[int] = []
for house in house_numbers:
row = houses.get(str(house)) if isinstance(houses.get(str(house)), dict) else {}
significators = row.get("significators") if isinstance(row.get("significators"), dict) else {}
if significators.get("A") not in (None, "", [], (), set()):
active.append(house)
return active
def _house_signal_text(active_houses: list[int], fallback_houses: tuple[int, ...]) -> str:
if not active_houses:
return "相关宫位线索暂时偏弱"
labels = [f"{house}{_HOUSE_LABELS.get(house, '')}" for house in active_houses]
return "".join(labels)
def _summary_text(domain: str, active_houses: list[int], fallback_houses: tuple[int, ...], md_lord: str, saturn_sign: str) -> str:
label = _DOMAIN_LABELS.get(domain, domain)
focus = _house_signal_text(active_houses, fallback_houses)
template = _DOMAIN_TEMPLATES.get(domain, "这段时间更适合先看结构支持,再看外部触发。")
md_label = _PLANET_CN.get(md_lord, md_lord)
saturn_label = _SIGN_CN.get(saturn_sign, saturn_sign)
if active_houses:
return (
f"{label}线本月先看 {focus} 的本命承诺;"
f"当前主运星为{md_label},土星行运落在{saturn_label}{template}"
)
fallback_text = "".join(
f"{house}{_HOUSE_LABELS.get(house, '')}" for house in fallback_houses
)
return (
f"{label}线本月更适合先按 {fallback_text} 这组标准宫位阅读,再等待更强触发出现;"
f"当前主运星为{md_label},土星行运落在{saturn_label}{template}"
)
def _brief_summary_text(domain: str, active_houses: list[int], fallback_houses: tuple[int, ...], md_lord: str) -> str:
label = _DOMAIN_LABELS.get(domain, domain)
md_label = _PLANET_CN.get(md_lord, md_lord)
focus_houses = active_houses or list(fallback_houses[:2])
focus_text = "".join(f"{house}" for house in focus_houses)
return f"本命焦点:{focus_text};月运主轴:{label}线跟随{md_label}主运推进。"
def build_kp_monthly_theme_support(
*, natal_kp: dict, monthly_levels: dict, monthly_transits: dict
) -> dict:
houses = natal_kp.get("houses") if isinstance(natal_kp.get("houses"), dict) else {}
ruling = natal_kp.get("ruling_planets") if isinstance(natal_kp.get("ruling_planets"), dict) else {}
md_lord = ((monthly_levels.get("mahadasha") or {}) if isinstance(monthly_levels, dict) else {}).get("lord") or "-"
saturn_sign = ((((monthly_transits.get("planets") or {}) if isinstance(monthly_transits, dict) else {}).get("Saturn") or {}) if isinstance((monthly_transits.get("planets") or {}).get("Saturn"), dict) else {}).get("sign") or "-"
out = {}
for domain, house_numbers in _DOMAIN_HOUSES.items():
active_houses = _collect_active_houses(houses, house_numbers)
out[domain] = {
"houses": list(house_numbers),
"active_houses": active_houses,
"promise_code": " / ".join(str(h) for h in active_houses) if active_houses else "-",
"summary": _summary_text(domain, active_houses, house_numbers, md_lord, saturn_sign),
"brief_summary": _brief_summary_text(domain, active_houses, house_numbers, md_lord),
"ruling_planet_snapshot": ruling.get("day_lord") or "-",
"status": "parameter_sensitive",
"must_not_claim": ["exact_event_timing", "specific_event_prediction"],
}
return out
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Monthly transit snapshots for the KP three-year report."""
from __future__ import annotations
try: # pragma: no cover - import path differs under CLI vs pytest
from domain_calculation_service import compute_chart
except ImportError: # pragma: no cover
from scripts.domain_calculation_service import compute_chart
def build_monthly_transit_snapshot(
*, year: int, month: int, lat: float, lon: float, tz: float
) -> dict:
chart = compute_chart(
{
"year": year,
"month": month,
"day": 1,
"hour": 12,
"minute": 0,
"second": 0,
"lat": lat,
"lon": lon,
"tz": tz,
"ayanamsa": "kp",
"node_mode": "mean",
"position_mode": "apparent",
}
)
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
snapshot = {}
for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"):
row = planets.get(name) if isinstance(planets.get(name), dict) else {}
snapshot[name] = {
"sign": row.get("sign"),
"longitude": row.get("degree_raw", row.get("degree")),
"retrograde": bool(row.get("retrograde")),
"speed": row.get("speed"),
}
return {
"month": f"{year:04d}-{month:02d}",
"anchor_local": f"{year:04d}-{month:02d}-01T12:00:00",
"planets": snapshot,
"status": "parameter_sensitive",
"source": "local_kp_month_anchor_chart",
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Monthly five-level Vimshottari snapshots for the KP monthly report."""
from __future__ import annotations
from datetime import datetime
try: # pragma: no cover - import path differs under CLI vs pytest
from dasha_calculator_enhanced import calculate_five_level_dasha
from domain_calculation_service import compute_vimshottari_timeline
except ImportError: # pragma: no cover
from scripts.dasha_calculator_enhanced import calculate_five_level_dasha
from scripts.domain_calculation_service import compute_vimshottari_timeline
def build_monthly_vimshottari_snapshot(
*, birth_dt: datetime, moon_lon: float, anchor_dt: datetime
) -> dict:
timeline = compute_vimshottari_timeline(
birth_dt=birth_dt,
moon_lon=moon_lon,
current_date=anchor_dt,
)
current = timeline.get("current_dasha") or {}
start_text = current.get("start")
if start_text:
current_start = datetime.strptime(start_text, "%Y-%m-%d")
elapsed_years = max((anchor_dt - current_start).days / 365.25, 0.0)
else:
elapsed_years = 0.0
md_lord = current.get("lord") or timeline.get("birth_balance", {}).get("lord")
levels = calculate_five_level_dasha(md_lord, elapsed_years)
return {
"anchor_date": anchor_dt.strftime("%Y-%m-%d"),
"timeline": {
"current_dasha": current,
"birth_balance": timeline.get("birth_balance"),
},
"levels": {
"mahadasha": levels.get("mahadasha"),
"antardasha": levels.get("bhukti"),
"pratyantardasha": levels.get("pratyantar"),
"sookshma": levels.get("sookshma"),
"prana": levels.get("prana"),
},
"status": "parameter_sensitive",
"must_not_claim": ["exact_event_timing", "specific_event_prediction"],
}
+23 -9
View File
@@ -7,6 +7,7 @@ calculation, and delegates packet assembly/rendering to ``jyotish_engine``.
from __future__ import annotations
from datetime import datetime
from importlib import import_module
from types import SimpleNamespace
from typing import Any
@@ -57,19 +58,32 @@ def _load_engine():
def _export_args(birth: dict[str, Any]) -> SimpleNamespace:
normalized_birth = {
today = birth.get("today") or datetime.now().strftime("%Y-%m-%d")
raw_target = birth.get("target_year")
if raw_target in (None, ""):
target_year = int(str(today)[:4])
else:
target_year = int(raw_target)
raw_age = birth.get("age")
if raw_age in (None, ""):
try:
age = int(target_year) - int(birth["year"])
except (TypeError, ValueError, KeyError):
age = None
else:
age = int(raw_age)
payload = {
**birth,
"hour": int(birth["hour"]),
"minute": int(birth["minute"]),
"second": int(birth.get("second", 0)),
"second": int(birth.get("second", 0) or 0),
"today": today,
"target_year": target_year,
"age": age,
"visual_chart_observations": birth.get("visual_chart_observations"),
"startrack_language_bridge": bool(birth.get("startrack_language_bridge")),
}
return SimpleNamespace(
**normalized_birth,
age=None,
target_year=None,
visual_chart_observations=None,
startrack_language_bridge=False,
)
return SimpleNamespace(**payload)
def build_professional_report_reference(handler, body: dict[str, Any], *, engine=None) -> dict[str, Any]:
+405
View File
@@ -0,0 +1,405 @@
"""Build the governed reference object for a PL9-grade full personal report.
The authority is intentionally a compact, read-only reference envelope. It
does not duplicate raw worksheets, render Markdown, alter quality-gate results,
or publish a report. Consumers use its stable identity and references to locate
the already assembled PL9 export packet through an approved transport layer.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
AUTHORITY_SCHEMA_VERSION = "jyotish.shared_full_report_authority.v1"
REPORT_SCHEMA_VERSION = "expert_report_output.v2"
DEFAULT_REPORT_VERSION = "pl9_personal_long_report.v2"
PL9_EXPORT_SCHEMA = "pl9_style_professional_export_v1"
QUALITY_GATE_SCHEMA = "jyotish.full_report_quality_gate.v1"
def build_shared_full_report_authority(
packet: dict[str, Any],
*,
expert_judgment_output: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Create a compact authority envelope without mutating an export packet.
A quality-gate result is mandatory, but it is not a publication approval.
Every non-blocked authority remains ``review_required`` until a future
governed review/promotion flow authorizes a consumer surface.
"""
source = _require_mapping(packet, "packet")
_require_value(source.get("schema"), "packet.schema")
if source.get("schema") != PL9_EXPORT_SCHEMA:
raise ValueError(f"packet.schema must be {PL9_EXPORT_SCHEMA}")
profile = _require_mapping(source.get("calculation_profile"), "calculation_profile")
profile_id = _require_value(
source.get("calculation_profile_id") or profile.get("profile_id"),
"calculation_profile_id",
)
result_hash = _require_value(source.get("result_hash"), "result_hash")
full_report_pack = _require_mapping(source.get("full_report_pack"), "full_report_pack")
full_report_pack_schema = _require_value(full_report_pack.get("schema"), "full_report_pack.schema")
quality_gate = _require_mapping(source.get("report_quality_gate"), "report_quality_gate")
if quality_gate.get("schema_version") != QUALITY_GATE_SCHEMA:
raise ValueError(f"report_quality_gate.schema_version must be {QUALITY_GATE_SCHEMA}")
report_version = str(source.get("report_version") or DEFAULT_REPORT_VERSION)
chart_identity = _build_chart_identity(source, profile, profile_id)
identity_seed = {
"report_schema_version": REPORT_SCHEMA_VERSION,
"report_version": report_version,
"calculation_profile_id": profile_id,
"result_hash": result_hash,
"full_report_pack_schema": full_report_pack_schema,
# The same calculation payload can be reached from different chart
# identity states. Keep those authorities distinct rather than letting
# an unreviewed and an approved chart share one report reference.
"chart_profile_id": chart_identity["chart_profile_id"],
"rectification_status": chart_identity["rectification_status"],
"approval_status": chart_identity["approval_status"],
}
identity_digest = _digest(identity_seed)
report_id = f"full-report://{profile_id}/{identity_digest[:24]}"
quality_gate_reference = _build_quality_gate_reference(quality_gate)
status, publication_status = _derive_status(quality_gate_reference["status"])
sections = _require_mapping(full_report_pack.get("sections"), "full_report_pack.sections")
report_pack_manifest = _as_mapping(source.get("report_pack_manifest"))
pack_ids = [
str(pack.get("id"))
for pack in report_pack_manifest.get("packs", [])
if isinstance(pack, dict) and pack.get("id")
]
judgment_lineage = _build_judgment_lineage(expert_judgment_output)
limitations = _build_limitations(source, quality_gate_reference, chart_identity, judgment_lineage)
return {
"schema_version": AUTHORITY_SCHEMA_VERSION,
"authority_id": f"shared-full-report://{identity_digest[:24]}",
"report_id": report_id,
"report_version": report_version,
"report_contract_version": REPORT_SCHEMA_VERSION,
"status": status,
"publication_status": publication_status,
"read_only": True,
"report_metadata": {
"generated_at": source.get("generated_at"),
"generated_at_status": "recorded" if source.get("generated_at") else "not_recorded",
},
"chart_identity": chart_identity,
"lineage": {
"source_schema": source.get("schema"),
"calculation_profile_id": profile_id,
"result_hash": result_hash,
"full_report_pack_schema": full_report_pack_schema,
"report_pack_ids": pack_ids,
"source_reference": "pl9_export_packet",
},
"quality_gate_reference": quality_gate_reference,
"content_reference": {
"full_report_pack_schema": full_report_pack_schema,
"section_keys": list(sections.keys()),
"professional_support_reference": _build_professional_support_reference(
sections.get("professional_support")
),
"professional_coverage_reference": _build_professional_coverage_reference(
quality_gate.get("professional_coverage_manifest")
),
"full_report_body_authority": _build_full_report_body_authority(
sections=sections,
professional_support=sections.get("professional_support"),
professional_coverage_manifest=quality_gate.get("professional_coverage_manifest"),
),
"available_render_formats": ["markdown", "reader-markdown", "pdf"],
"contains_raw_calculation": False,
"contains_shadow_artifact": False,
},
"surface_slices": _build_surface_slices(report_id),
"limitations": limitations,
"judgment_lineage": judgment_lineage,
"publication_boundary": (
"This reference does not publish, promote, render, or replace any "
"existing report consumer. It remains review-only until a governed "
"promotion path explicitly authorizes a surface."
),
}
def _require_mapping(value: Any, name: str) -> dict[str, Any]:
if not isinstance(value, dict) or not value:
raise ValueError(f"{name} is required")
return value
def _as_mapping(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _require_value(value: Any, name: str) -> str:
if value in (None, ""):
raise ValueError(f"{name} is required")
return str(value)
def _digest(value: dict[str, Any]) -> str:
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _build_quality_gate_reference(quality_gate: dict[str, Any]) -> dict[str, Any]:
status = str(quality_gate.get("status") or "review_required")
reference_payload = {
"schema_version": quality_gate.get("schema_version"),
"status": status,
"blocking_reasons": list(quality_gate.get("blocking_reasons") or []),
"review_reasons": list(quality_gate.get("review_reasons") or []),
"warning_reasons": list(quality_gate.get("warning_reasons") or []),
"audit_reference": _as_mapping(quality_gate.get("audit_reference")),
}
return {
**reference_payload,
"quality_gate_reference_id": f"report-quality-gate://{_digest(reference_payload)[:24]}",
}
def _derive_status(quality_gate_status: str) -> tuple[str, str]:
if quality_gate_status == "blocked":
return "blocked", "blocked"
return "review_required", "pending_review"
def _build_chart_identity(
packet: dict[str, Any],
profile: dict[str, Any],
profile_id: str,
) -> dict[str, Any]:
existing = _as_mapping(packet.get("chart_identity"))
return {
"chart_profile_id": existing.get("chart_profile_id") or profile_id,
"birth_data_status": existing.get("birth_data_status") or packet.get("birth_data_status") or "user_provided",
"rectification_status": existing.get("rectification_status") or packet.get("rectification_status") or "not_reviewed",
"approval_status": existing.get("approval_status") or packet.get("approval_status") or "not_approved",
"calculation_profile_reference": profile.get("profile_id") or profile_id,
}
def _build_limitations(
packet: dict[str, Any],
quality_gate_reference: dict[str, Any],
chart_identity: dict[str, Any],
judgment_lineage: dict[str, Any],
) -> list[dict[str, str]]:
limitations = [
{
"type": "publication_review_required",
"detail": "A quality-gate result is advisory and cannot publish a report automatically.",
},
]
if judgment_lineage.get("status") != "attached":
limitations.append(
{
"type": "judgment_lineage_not_attached",
"detail": "The current PL9 packet is not yet linked to a production ExpertJudgmentOutput reference.",
}
)
if quality_gate_reference.get("status") != "passed":
limitations.append(
{
"type": "quality_gate_not_clean_pass",
"detail": f"Quality gate status is {quality_gate_reference.get('status')}.",
}
)
if chart_identity.get("approval_status") != "approved":
limitations.append(
{
"type": "chart_identity_not_approved",
"detail": f"Chart approval status is {chart_identity.get('approval_status')}.",
}
)
if not packet.get("generated_at"):
limitations.append(
{
"type": "generation_timestamp_not_recorded",
"detail": "The source export did not supply a generated_at timestamp.",
}
)
return limitations
def _build_judgment_lineage(expert_judgment_output: dict[str, Any] | None) -> dict[str, Any]:
output = expert_judgment_output if isinstance(expert_judgment_output, dict) else {}
if not output:
return {
"status": "not_attached",
"boundary": (
"Current PL9 export is a governed report candidate. A future "
"ExpertJudgmentOutput reference is required before it can claim "
"judgment-backed production authority."
),
}
return {
"status": "attached",
"schema_version": output.get("schema_version"),
"judgment_id": output.get("judgment_id"),
"judgment_status": output.get("status"),
"promotion_state": ((output.get("review_metadata") or {}).get("promotion_state") if isinstance(output.get("review_metadata"), dict) else None) or "unknown",
"review_status": ((output.get("review_metadata") or {}).get("review_status") if isinstance(output.get("review_metadata"), dict) else None) or "unknown",
"audit_reference": _as_mapping(output.get("audit_reference")),
"boundary": (
"A governed ExpertJudgmentOutput reference is attached. Publication and promotion still require "
"their own review boundary."
),
}
def _build_surface_slices(report_id: str) -> dict[str, dict[str, Any]]:
return {
"simple_view": {
"authority_mode": "reference_only",
"report_reference": report_id,
"allowed_sections": ["Executive Summary", "Chart Identity", "Conflict and Limitation"],
},
"deep_analysis_view": {
"authority_mode": "reference_only",
"report_reference": report_id,
"allowed_sections": [
"Core Promise Analysis",
"Strength Analysis",
"Domain Analysis",
"Varga Analysis",
"Dasha Analysis",
"KP and Transit Timing Layer",
"Annual / Tajika / Yearly Focus",
"Three-Year Ephemeris and Predictive Outlook",
"Conflict and Limitation",
"Evidence Summary",
],
},
"expert_workspace": {
"authority_mode": "reference_only",
"report_reference": report_id,
"allowed_sections": ["all_governed_sections", "Operator Appendix / Audit Appendix"],
},
}
def _build_professional_support_reference(section: Any) -> dict[str, Any]:
topic_section = section if isinstance(section, dict) else {}
topics = topic_section.get("topics") if isinstance(topic_section.get("topics"), dict) else {}
topic_statuses = {
str(key): str(value.get("status") or "blocked")
for key, value in topics.items()
if isinstance(value, dict)
}
restricted = topics.get("restricted_research_materials") if isinstance(topics.get("restricted_research_materials"), dict) else {}
materials = restricted.get("materials") if isinstance(restricted.get("materials"), dict) else {}
restricted_material_visibility = sorted(
key for key, value in materials.items()
if isinstance(value, dict) and value.get("status") not in (None, "", "not_available")
)
body_authority_topics = sorted(
key
for key, value in topics.items()
if key != "restricted_research_materials"
and isinstance(value, dict)
and value.get("status") not in (None, "", "blocked", "not_available")
)
appendix_only_topics = sorted(
key
for key, value in topics.items()
if isinstance(value, dict)
and (
key == "restricted_research_materials"
or value.get("status") in (None, "", "blocked", "not_available")
)
)
return {
"status": topic_section.get("status") or "blocked",
"topic_statuses": topic_statuses,
"body_authority_topics": body_authority_topics,
"appendix_only_topics": appendix_only_topics,
"restricted_material_visibility": restricted_material_visibility,
}
def _build_professional_coverage_reference(manifest: Any) -> list[dict[str, str]]:
rows = manifest if isinstance(manifest, list) else []
normalized: list[dict[str, str]] = []
for row in rows:
if not isinstance(row, dict):
continue
material_id = row.get("material_id")
status = row.get("status")
source_reference = row.get("source_reference")
if material_id in (None, ""):
continue
normalized.append(
{
"material_id": str(material_id),
"status": str(status or "unknown"),
"source_reference": str(source_reference or ""),
}
)
return normalized
def _build_full_report_body_authority(
*,
sections: dict[str, Any],
professional_support: Any,
professional_coverage_manifest: Any,
) -> dict[str, Any]:
section_statuses = {
str(key): str(value.get("status") or "blocked")
for key, value in sections.items()
if isinstance(value, dict)
}
professional_support_reference = _build_professional_support_reference(professional_support)
coverage_rows = _build_professional_coverage_reference(professional_coverage_manifest)
body_sections = [
key
for key, status in section_statuses.items()
if key not in {"audit_appendix", "professional_support"}
and status not in {"blocked", "not_available"}
]
restricted_topics = list(professional_support_reference.get("restricted_material_visibility") or [])
body_authority_topics = list(professional_support_reference.get("body_authority_topics") or [])
appendix_only_topics = list(professional_support_reference.get("appendix_only_topics") or [])
topic_promotion_state = {
topic: (
"body_authority"
if topic in body_authority_topics
else "restricted_registry"
if topic in restricted_topics
else "appendix_only"
)
for topic in sorted(set(body_authority_topics + restricted_topics + appendix_only_topics))
}
return {
"status": "authority_topics_promoted" if body_sections or body_authority_topics or coverage_rows else "blocked",
"body_sections": body_sections,
"body_authority_topics": body_authority_topics,
"restricted_professional_topics": restricted_topics,
"appendix_only_topics": appendix_only_topics,
"topic_promotion_state": topic_promotion_state,
"coverage_manifest_material_ids": [row["material_id"] for row in coverage_rows],
"coverage_manifest_authority_materials": [
{
"material_id": row["material_id"],
"status": row["status"],
"source_reference": row["source_reference"],
"promotion_state": "body_authority_reference" if row["status"] not in {"blocked", "not_available"} else "appendix_only",
}
for row in coverage_rows
],
"boundary": (
"This layer tracks which high-value support and coverage materials have an explicit body-authority "
"or restricted-professional registration inside the long-report contract. It does not auto-promote "
"parameter-sensitive or blocked material into judgment authority."
),
}
+91
View File
@@ -0,0 +1,91 @@
"""Governed authority builder for auditable Tajika named yogas.
This module promotes only the two named yogas whose full rule chain already
shares one vocabulary with the seven-planet Tajika kernel: Ithasala and
Easarapha. It does not infer broader Tajika event verdicts or unsupported
named-yoga chains.
"""
from __future__ import annotations
from typing import Any
try:
from tajika_named_yoga_governed_draft import build_tajika_named_yoga_governed_draft
except ImportError: # pragma: no cover - package import
from scripts.tajika_named_yoga_governed_draft import build_tajika_named_yoga_governed_draft
SCHEMA_VERSION = "jyotish.tajika_named_yoga_authority.v1"
SUPPORTED_NAMED_YOGAS = ("Ithasala", "Easarapha")
def build_tajika_named_yoga_authority(planets: dict[str, dict[str, Any]]) -> dict[str, Any]:
"""Promote governed Tajika draft rows into a narrow authority layer."""
draft = build_tajika_named_yoga_governed_draft(planets)
if draft.get("status") == "blocked":
return {
"schema_version": SCHEMA_VERSION,
"status": "blocked",
"authority_ready": False,
"supported_named_yogas": list(SUPPORTED_NAMED_YOGAS),
"rows": [],
"reason": draft.get("reason") or "tajika_named_yoga_draft_blocked",
"missing": list(draft.get("missing") or []),
"claim_boundary": "named_yoga_authority_requires_complete_governed_draft_inputs",
"limitations": [
"kernel_input_missing",
"no_event_verdict",
"unsupported_named_yogas_remain_blocked",
],
}
rows = []
for item in draft.get("rows") or []:
if not isinstance(item, dict):
continue
named_yoga = item.get("named_yoga")
if named_yoga not in SUPPORTED_NAMED_YOGAS:
continue
rows.append(
{
**item,
"review_status": "governed_authority_ready",
"resolution_state": "authority_promoted_from_shared_motion_contract",
"authority_status": "supported",
"claim_boundary": "named_yoga_present_absent_only_no_event_verdict",
}
)
return {
"schema_version": SCHEMA_VERSION,
"status": "authority_ready",
"authority_ready": True,
"supported_named_yogas": list(SUPPORTED_NAMED_YOGAS),
"blocked_named_yogas": ["Nakta", "Yamaya", "Manahoo", "Kamboola"],
"kernel_status": draft.get("kernel_status"),
"kernel_boundary": draft.get("kernel_boundary"),
"rows": rows,
"summary": {
"row_count": len(rows),
"named_yoga_counts": _count(rows, "named_yoga"),
"motion_counts": _count(rows, "motion"),
},
"claim_boundary": "governed_named_yoga_authority_limited_to_present_absent_rows_for_ithasala_easarapha",
"limitations": [
"no_event_verdict",
"unsupported_named_yogas_remain_blocked",
"annual_product_surface_not_promoted_here",
],
}
def _count(rows: list[dict[str, Any]], key: str) -> dict[str, int]:
counts: dict[str, int] = {}
for row in rows:
value = row.get(key)
if value in (None, ""):
continue
value = str(value)
counts[value] = counts.get(value, 0) + 1
return counts
+105
View File
@@ -0,0 +1,105 @@
"""Governed named-yoga draft built from shared Tajika motion evidence.
This module does not promote named yogas into authority. It packages kernel
candidate relations into a review-ready draft so the remaining closure can be
resolved under one governed vocabulary.
"""
from __future__ import annotations
from typing import Any
try:
from tajika_kernel import calculate_tajika_interactions
except ImportError: # pragma: no cover - package import
from scripts.tajika_kernel import calculate_tajika_interactions
SCHEMA_VERSION = "jyotish.tajika_named_yoga_governed_draft.v1"
_CANDIDATE_TO_NAMED = {
"Ithasala_candidate": "Ithasala",
"Easarapha_candidate": "Easarapha",
}
def build_tajika_named_yoga_governed_draft(planets: dict[str, dict[str, Any]]) -> dict[str, Any]:
"""Package Tajika kernel candidate relations as a review-only draft."""
if not isinstance(planets, dict):
raise ValueError("planets_required")
kernel = calculate_tajika_interactions(planets)
if kernel.get("status") == "blocked":
return {
"schema_version": SCHEMA_VERSION,
"status": "blocked",
"authority_ready": False,
"claim_boundary": "governed_named_yoga_draft_requires_complete_seven_planet_kernel_inputs",
"reason": kernel.get("reason") or "tajika_kernel_blocked",
"missing": list(kernel.get("missing") or []),
"rows": [],
"summary": {
"row_count": 0,
"named_yoga_counts": {},
"motion_counts": {},
},
"limitations": [
"kernel_input_missing",
"no_named_yoga_authority",
"draft_does_not_promote_verdict",
],
}
rows: list[dict[str, Any]] = []
for item in kernel.get("candidate_yogas") or []:
if not isinstance(item, dict):
continue
named_yoga = _CANDIDATE_TO_NAMED.get(str(item.get("name") or ""))
if not named_yoga:
continue
rows.append(
{
"named_yoga": named_yoga,
"candidate_name": item.get("name"),
"planets": list(item.get("planets") or []),
"aspect": item.get("aspect"),
"motion": item.get("motion"),
"average_deeptamsa": item.get("average_deeptamsa"),
"residual": item.get("residual"),
"rule_source": item.get("rule_source"),
"review_status": "governed_seed_resolution_review_required",
"resolution_state": "governed_seed_resolution_draft",
"claim_boundary": "candidate_relation_only_no_named_yoga_authority",
}
)
return {
"schema_version": SCHEMA_VERSION,
"status": "draft_only",
"authority_ready": False,
"claim_boundary": "governed_named_yoga_draft_only_no_named_yoga_verdict_or_promotion",
"kernel_status": kernel.get("status"),
"kernel_boundary": kernel.get("boundary"),
"rows": rows,
"summary": {
"row_count": len(rows),
"named_yoga_counts": _count(rows, "named_yoga"),
"motion_counts": _count(rows, "motion"),
},
"limitations": [
"candidate_relations_only",
"no_named_yoga_authority",
"no_final_judgment",
"governed_seed_review_required_before_promotion",
],
}
def _count(rows: list[dict[str, Any]], key: str) -> dict[str, int]:
counts: dict[str, int] = {}
for row in rows:
value = row.get(key)
if value in (None, ""):
continue
value = str(value)
counts[value] = counts.get(value, 0) + 1
return counts