fix(chart): skip unused synastry overview and doomed report subprocesses

Synastry charts now skip the unused external overview and run together. Reports skip PyJHora subprocesses when that library is missing, and a full heavy-compute slot logs which paths hold it. Splitting quotas stays a simulation.

Gate, growth, and report pytest passed, as did the frontend route tests, tsc, lint, and the A/B script.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-15 08:02:39 +08:00
co-authored by Grok (agent loop) Cursor
parent 9274491681
commit 336286e428
19 changed files with 1126 additions and 37 deletions
+49
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import contextlib
import hashlib
import importlib.util
import json
import subprocess
import sys
@@ -22,6 +23,34 @@ from typing import Any, Callable
ENGINE = "PyJHora/JHora"
REPO_ROOT = Path(__file__).resolve().parents[1]
_jhora_spec_cache: bool | None = None
def reset_jhora_import_cache() -> None:
"""Tests reset the process-local "is PyJHora installed?" answer."""
global _jhora_spec_cache
_jhora_spec_cache = None
def _jhora_importable() -> bool:
global _jhora_spec_cache
if _jhora_spec_cache is None:
_jhora_spec_cache = importlib.util.find_spec("jhora") is not None
return _jhora_spec_cache
def _no_jhora_tajaka_replay(settings: dict[str, Any]) -> dict[str, Any]:
"""Same dict the child returns when ``import jhora`` raises ModuleNotFoundError."""
copied = dict(settings)
return {
"status": "blocked",
"reason": "pyjhora_ayanamsa_configuration_failed:ModuleNotFoundError",
"settings": copied,
"node_mode": {
"requested": copied.get("node_mode"),
"status": "unsupported_by_replay_adapter",
},
}
_TAJAKA_YEAR_LORD_MODULE = "jhora.horoscope.transit.tajaka"
_YEAR_LORD_MODULES = (
"jhora.horoscope.dhasa.annual.mudda",
@@ -139,6 +168,10 @@ def _run_isolated_tajaka_year_lord(
"settings": settings,
}
request_hash = _request_hash(request)
if not _jhora_importable():
replay = _no_jhora_tajaka_replay(settings)
replay["request_hash"] = request_hash
return replay
command = [
sys.executable,
"-c",
@@ -276,6 +309,10 @@ def _run_isolated_annual_replay(
"settings": settings,
}
request_hash = _request_hash(request)
if not _jhora_importable():
replay = _no_jhora_annual_replay(settings)
replay["request_hash"] = request_hash
return replay
command = [
sys.executable,
"-c",
@@ -391,6 +428,18 @@ def _annual_worker_result(
}
def _no_jhora_annual_replay(settings: dict[str, Any]) -> dict[str, Any]:
"""Same dict the annual child returns when ``import jhora`` raises ModuleNotFoundError."""
copied = dict(settings)
node_mode = {"requested": copied.get("node_mode"), "status": "unsupported_by_replay_adapter"}
reason = "pyjhora_annual_adapter_setup_failed:ModuleNotFoundError"
return _annual_worker_result(
{key: {"status": "blocked", "reason": reason} for key in _ANNUAL_SURFACE_KEYS},
settings=copied,
node_mode=node_mode,
)
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},
+99 -10
View File
@@ -8,13 +8,17 @@ must not call this gate.
from __future__ import annotations
import logging
import os
import threading
import time
DEFAULT_CONCURRENCY = 2
DEFAULT_RETRY_AFTER_SECONDS = 2
ENV_CONCURRENCY = "JYOTISH_HEAVY_COMPUTE_CONCURRENCY"
ENV_RETRY_AFTER = "JYOTISH_HEAVY_COMPUTE_RETRY_AFTER_SECONDS"
ENV_GATE_CHART_OVERVIEW = "JYOTISH_GATE_CHART_OVERVIEW"
_LOG = logging.getLogger("jyotish.heavy_compute")
HEAVY_COMPUTE_PATHS = frozenset(
{
@@ -46,6 +50,26 @@ HEAVY_COMPUTE_PATHS = frozenset(
_state_lock = threading.Lock()
_semaphore: threading.BoundedSemaphore | None = None
_retry_after_seconds = DEFAULT_RETRY_AFTER_SECONDS
_limit = DEFAULT_CONCURRENCY
_holders: list[dict] = []
_holder_seq = 0
class _HeldSlot:
"""One acquired heavy-compute permit, paired with the path that holds it."""
def __init__(self, semaphore: threading.BoundedSemaphore, holder_id: int) -> None:
self._semaphore = semaphore
self._holder_id = holder_id
self._released = False
def release(self) -> None:
with _state_lock:
if self._released:
return
self._released = True
_holders[:] = [item for item in _holders if item["id"] != self._holder_id]
self._semaphore.release()
class HeavyComputeBusy(RuntimeError):
@@ -80,7 +104,7 @@ def reset_heavy_compute_gate(
retry_after_seconds: int | None = None,
) -> None:
"""Rebuild the process-wide semaphore. Tests must call this after env changes."""
global _semaphore, _retry_after_seconds
global _semaphore, _retry_after_seconds, _limit, _holders
resolved_limit = (
limit
if limit is not None
@@ -93,31 +117,96 @@ def reset_heavy_compute_gate(
)
with _state_lock:
_retry_after_seconds = resolved_retry
_limit = resolved_limit
_holders = []
_semaphore = threading.BoundedSemaphore(resolved_limit)
def _ensure_locked() -> tuple[threading.BoundedSemaphore, int]:
global _semaphore, _retry_after_seconds
global _semaphore, _retry_after_seconds, _limit
if _semaphore is None:
limit = _parse_positive_int(os.environ.get(ENV_CONCURRENCY), DEFAULT_CONCURRENCY)
_limit = _parse_positive_int(os.environ.get(ENV_CONCURRENCY), DEFAULT_CONCURRENCY)
_retry_after_seconds = _parse_positive_int(
os.environ.get(ENV_RETRY_AFTER), DEFAULT_RETRY_AFTER_SECONDS
)
_semaphore = threading.BoundedSemaphore(limit)
_semaphore = threading.BoundedSemaphore(_limit)
return _semaphore, _retry_after_seconds
def acquire_heavy_compute_slot(path: str) -> threading.BoundedSemaphore | None:
def _holder_view(now: float) -> list[dict]:
return [
{
"path": item["path"],
"held_seconds": round(max(now - item["started"], 0.0), 3),
}
for item in _holders
]
def _acquire_recorded(path: str) -> _HeldSlot:
"""Take one permit and remember which path holds it. Fail fast when full."""
global _holder_seq
with _state_lock:
semaphore, retry_after = _ensure_locked()
if not semaphore.acquire(blocking=False):
snapshot = _holder_view(time.monotonic())
failed = True
slot = None
else:
_holder_seq += 1
holder_id = _holder_seq
_holders.append({"id": holder_id, "path": path, "started": time.monotonic()})
failed = False
snapshot = []
slot = _HeldSlot(semaphore, holder_id)
if failed:
_LOG.warning(
"heavy_compute_busy rejected_path=%s holders=%s",
path,
snapshot,
)
raise HeavyComputeBusy(retry_after)
assert slot is not None
return slot
def acquire_heavy_compute_slot(path: str) -> _HeldSlot | None:
"""Acquire a slot for a gated path. Light paths return None. Fail-fast on saturation."""
if not is_heavy_compute_path(path):
return None
return _acquire_recorded(path)
def _chart_overview_gate_enabled() -> bool:
return os.environ.get(ENV_GATE_CHART_OVERVIEW, "").strip() == "1"
def acquire_heavy_compute_slot_for_request(path: str, body: dict | None) -> _HeldSlot | None:
"""Path gate, plus the chart-overview switch and the warm-consultation exception.
``/api/chart`` stays out of ``HEAVY_COMPUTE_PATHS``. It takes a slot only when
``JYOTISH_GATE_CHART_OVERVIEW=1``, the body does not skip the VedAstro overview,
and the body is not an async enqueue. Default is off.
"""
payload = body if isinstance(body, dict) else {}
if path == "/api/consultation_workflow" and payload.get("consult_card_warm_only") is True:
return None
if path == "/api/chart" and _chart_overview_gate_enabled():
if payload.get("async") or payload.get("enqueue"):
return None
if payload.get("skip_vedastro_main_entry_overview"):
return None
return _acquire_recorded(path)
return acquire_heavy_compute_slot(path)
def heavy_compute_status() -> dict:
"""Process-local counts only. No request body and no user identity."""
with _state_lock:
semaphore, retry_after = _ensure_locked()
if not semaphore.acquire(blocking=False):
raise HeavyComputeBusy(retry_after)
return semaphore
_ensure_locked()
return {"limit": _limit, "in_use": len(_holders)}
def release_heavy_compute_slot(slot: threading.BoundedSemaphore | None) -> None:
def release_heavy_compute_slot(slot: _HeldSlot | None) -> None:
if slot is not None:
slot.release()
+6 -10
View File
@@ -122,13 +122,13 @@ except ModuleNotFoundError: # pragma: no cover - script execution path
try:
from scripts.api_heavy_compute_gate import (
HeavyComputeBusy,
acquire_heavy_compute_slot,
acquire_heavy_compute_slot_for_request,
release_heavy_compute_slot,
)
except ModuleNotFoundError: # pragma: no cover - script execution path
from api_heavy_compute_gate import (
HeavyComputeBusy,
acquire_heavy_compute_slot,
acquire_heavy_compute_slot_for_request,
release_heavy_compute_slot,
)
@@ -3345,14 +3345,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
try:
self._enforce_request_security(require_json=True)
body = self._read_json_body()
# Chart-save warm builds today's packet on the same route. It must
# not take a heavy-compute slot: the cap is 2, and a question asked
# right after save would otherwise come back 429.
warm_only = (
path == '/api/consultation_workflow'
and body.get('consult_card_warm_only') is True
)
compute_slot = None if warm_only else acquire_heavy_compute_slot(path)
# Warm chart-save must not take a slot (BUG-1302). Chart overview
# gating is off unless JYOTISH_GATE_CHART_OVERVIEW=1. Both live in
# the gate module so this file only keeps the call.
compute_slot = acquire_heavy_compute_slot_for_request(path, body)
if path == '/api/location/resolve':
city = str(body.get('city') or '').strip()
city_aliases = {'beijing': '北京', 'shanghai': '上海', 'guangzhou': '广州', 'shenzhen': '深圳'}
+26 -2
View File
@@ -10762,7 +10762,25 @@ def _planet_snapshot(planets, planet_name):
}
_oracle_progress_cache: dict = {}
def reset_oracle_progress_snapshot_cache():
"""Tests clear the static-file validation cache."""
_oracle_progress_cache.clear()
def _oracle_progress_cache_key(oracle_file):
try:
stat = oracle_file.stat()
except OSError:
return None
mtime = getattr(stat, 'st_mtime_ns', stat.st_mtime)
return (str(oracle_file.resolve()), mtime, stat.st_size)
def _oracle_progress_snapshot():
cache_key = None
try:
import json
import subprocess
@@ -10772,6 +10790,9 @@ def _oracle_progress_snapshot():
root = Path(__file__).resolve().parents[1]
oracle_file = root / 'references' / 'oracle' / 'dasha_shadbala_oracle_cases.json'
cache_key = _oracle_progress_cache_key(oracle_file)
if cache_key is not None and cache_key in _oracle_progress_cache:
return _oracle_progress_cache[cache_key]
with tempfile.TemporaryDirectory() as temp_dir:
queue_path = Path(temp_dir) / 'oracle-queue.json'
queue = subprocess.run(
@@ -10797,7 +10818,7 @@ def _oracle_progress_snapshot():
raise RuntimeError(validation.stderr.strip() or validation.stdout.strip())
report = json.loads(validation.stdout)
summary = report.get('summary', {})
return {
result = {
'scope': 'external_oracle_evidence_validation',
'collection_queue': 'external_oracle_collection_queue',
'total_packets': summary.get('total_packets', 0),
@@ -10809,7 +10830,7 @@ def _oracle_progress_snapshot():
'boundary': report.get('boundary', 'Dasha/Shadbala absolute values are not externally calibrated until enough packets pass validation.'),
}
except Exception as exc:
return {
result = {
'scope': 'external_oracle_evidence_validation',
'collection_queue': 'external_oracle_collection_queue',
'total_packets': 0,
@@ -10820,6 +10841,9 @@ def _oracle_progress_snapshot():
'promotion_rule': 'external_verified requires source_artifact, filled target values, and non-local-engine external evidence.',
'boundary': f'Oracle progress unavailable: {exc}',
}
if cache_key is not None:
_oracle_progress_cache[cache_key] = result
return result
# Combustion orbs of the full reading (Step 5 shadbala block). The Moon, Rahu
+272
View File
@@ -0,0 +1,272 @@
"""A/B for issue #64. A is this branch with the PyJHora short-circuit forced off
(the staging control flow: always spawn). B is the short-circuit on.
Chart skip/concurrency is measured on the real chart call. The gate schemes
O2/O3/O4 are simulated only and are not implemented.
Volatile fields dropped before the report byte compare:
generated_at, created_at, timestamp, elapsed_seconds, elapsed_ms, duration_ms, request_id.
"""
from __future__ import annotations
import json
import os
import random
import statistics
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts"))
os.environ.setdefault("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
os.environ.setdefault("VEDASTRO_ENABLE_NETWORK", "0")
os.environ.setdefault("JYOTISH_GATE_CHART_OVERVIEW", "0")
import annual_pyjhora_replay as replay # noqa: E402
import tajika as tajika_top # noqa: E402
from scripts.jyotish_api_server import JyotishAPIHandler # noqa: E402
from scripts.jyotish_engine import reset_oracle_progress_snapshot_cache # noqa: E402
from scripts.professional_report_reference import build_professional_report_reference # noqa: E402
from scripts.tajika import reset_external_pyjhora_python_cache # noqa: E402
BIRTH = {
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"second": 0,
"lat": 28.6139,
"lon": 77.2090,
"tz": 5.5,
"ayanamsa": "lahiri",
"node_mode": "true",
}
PARTNER = {**BIRTH, "year": 1992, "day": 2}
VOLATILE = {"generated_at", "created_at", "timestamp", "elapsed_seconds", "elapsed_ms", "duration_ms", "request_id"}
class _Counter:
def __init__(self) -> None:
self.n = 0
self._real = subprocess.run
self._lock = threading.Lock()
def install(self) -> None:
def _run(*args, **kwargs):
with self._lock:
self.n += 1
return self._real(*args, **kwargs)
subprocess.run = _run # type: ignore[assignment]
for module in list(sys.modules.values()):
bound = getattr(module, "subprocess", None)
if getattr(bound, "run", None) is self._real:
bound.run = _run # type: ignore[union-attr]
def reset(self) -> None:
with self._lock:
self.n = 0
def _median(values: list[float]) -> float:
return statistics.median(values) if values else 0.0
def _strip(value):
if isinstance(value, dict):
return {key: _strip(item) for key, item in value.items() if key not in VOLATILE}
if isinstance(value, list):
return [_strip(item) for item in value]
return value
def _canon(value) -> str:
return json.dumps(_strip(value), sort_keys=True, ensure_ascii=True, default=str)
def _chart_summary(chart: dict) -> dict:
planets = chart.get("planets") if isinstance(chart, dict) else None
moon = planets.get("Moon") if isinstance(planets, dict) else None
lon = None
if isinstance(moon, dict):
lon = moon.get("lon", moon.get("longitude"))
return {
"ascendant": chart.get("ascendant") if isinstance(chart, dict) else None,
"planets": planets,
"moon_longitude": lon,
}
def _one_chart(body: dict) -> tuple[float, dict]:
started = time.perf_counter()
chart = object.__new__(JyotishAPIHandler)._compute_chart_sync(body)
return time.perf_counter() - started, chart
def _measure_charts(counter: _Counter) -> dict:
serial_times = []
concurrent_times = []
serial_subs = []
concurrent_subs = []
same = True
for _ in range(3):
counter.reset()
started = time.perf_counter()
_, self_a = _one_chart(BIRTH)
_, partner_a = _one_chart(PARTNER)
serial_times.append(time.perf_counter() - started)
serial_subs.append(counter.n)
counter.reset()
started = time.perf_counter()
with ThreadPoolExecutor(max_workers=2) as pool:
self_b = pool.submit(_one_chart, {**BIRTH, "skip_vedastro_main_entry_overview": True})
partner_b = pool.submit(_one_chart, {**PARTNER, "skip_vedastro_main_entry_overview": True})
_, self_chart = self_b.result()
_, partner_chart = partner_b.result()
concurrent_times.append(time.perf_counter() - started)
concurrent_subs.append(counter.n)
same = same and _canon(_chart_summary(self_a)) == _canon(_chart_summary(self_chart))
same = same and _canon(_chart_summary(partner_a)) == _canon(_chart_summary(partner_chart))
return {
"serial_seconds_median": round(_median(serial_times), 4),
"concurrent_skip_seconds_median": round(_median(concurrent_times), 4),
"serial_subprocesses_median": _median(serial_subs),
"concurrent_skip_subprocesses_median": _median(concurrent_subs),
"summary_bytes_match": same,
}
def _measure_report(counter: _Counter, *, spawn: bool) -> dict:
times = []
subs = []
payload = None
body = {**BIRTH, "format": "markdown", "edition": "full_data", "languages": ["zh", "en"], "include_fact_tables": True}
for _ in range(3):
replay.reset_jhora_import_cache()
if spawn:
replay._jhora_spec_cache = True
reset_oracle_progress_snapshot_cache()
reset_external_pyjhora_python_cache()
tajika_top.reset_external_pyjhora_python_cache()
counter.reset()
started = time.perf_counter()
payload = build_professional_report_reference(
object.__new__(JyotishAPIHandler),
body,
)
times.append(time.perf_counter() - started)
subs.append(counter.n)
return {
"seconds_median": round(_median(times), 4),
"subprocesses_median": _median(subs),
"payload": payload,
}
def _simulate() -> list[dict]:
"""One fixed minute. O2 adds a second pool and is the only scheme that touches BUG-717."""
rng = random.Random(64)
arrivals = [("report", index * 30.0, 13.0) for index in range(2)]
arrivals += [("consult", rng.random() * 60.0, 0.2) for _ in range(20)]
arrivals += [("score", rng.random() * 60.0, 8.0) for _ in range(6)]
arrivals.sort(key=lambda item: item[1])
def run(mode: str) -> dict:
heavy: list[tuple[float, str]] = []
light: list[float] = []
rejected = {"consult": 0, "report": 0, "score": 0}
accepted = {"consult": 0, "report": 0, "score": 0}
peak = 0
def occupy(at: float) -> tuple[int, int]:
running = [item for item in heavy if item[0] > at]
lights = [item for item in light if item > at]
return len(running), len(lights)
for kind, arrival, duration in arrivals:
heavy_n, light_n = occupy(arrival)
heavy = [item for item in heavy if item[0] > arrival]
light = [item for item in light if item > arrival]
placed = False
stamp = arrival
if mode == "O2" and kind == "consult":
if light_n < 2:
light.append(arrival + duration)
placed = True
elif mode == "O3" and kind == "consult":
start = arrival
while start <= arrival + 1.5:
heavy_n, _light_n = occupy(start)
if heavy_n < 2:
heavy.append((start + duration, kind))
placed = True
stamp = start
break
nxt = min(item[0] for item in heavy)
if nxt > arrival + 1.5:
break
start = nxt
elif mode == "O4" and kind != "consult":
heavy_jobs = sum(1 for _end, slot in heavy if slot != "consult")
if heavy_jobs < 1 and heavy_n < 2:
heavy.append((arrival + duration, kind))
placed = True
else:
if heavy_n < 2:
heavy.append((arrival + duration, kind))
placed = True
if placed:
accepted[kind] += 1
else:
rejected[kind] += 1
running, lights = occupy(stamp)
peak = max(peak, running + lights)
def rate(kind: str) -> str:
total = accepted[kind] + rejected[kind]
return "0" if total == 0 else f"{rejected[kind] / total:.2f}"
return {
"scheme": mode,
"consult_429": rate("consult"),
"report_429": rate("report"),
"score_429": rate("score"),
"peak": peak,
"touches_bug_717": mode == "O2",
}
return [run(mode) for mode in ("O1", "O2", "O3", "O4")]
def main() -> None:
counter = _Counter()
counter.install()
charts = _measure_charts(counter)
forced = _measure_report(counter, spawn=True)
native = _measure_report(counter, spawn=False)
report_match = _canon(forced["payload"]) == _canon(native["payload"])
schemes = _simulate()
out = {
"charts": charts,
"report_forced_subprocess": {key: value for key, value in forced.items() if key != "payload"},
"report_short_circuit": {key: value for key, value in native.items() if key != "payload"},
"report_bytes_match": report_match,
"schemes": schemes,
"jhora_installed": False,
}
text = json.dumps(out, ensure_ascii=False, indent=2)
dest = ROOT / "docs" / "testing" / "api-chart-subprocess-gate-ab-20261013.json"
dest.write_text(text + "\n", encoding="utf-8")
print(text)
if __name__ == "__main__":
main()
+21
View File
@@ -1501,7 +1501,28 @@ print(json.dumps({str(k): float(v) for k, v in dict(scores).items()}))
return None
_external_pyjhora_python_cached = False
_external_pyjhora_python_value: Optional[str] = None
def reset_external_pyjhora_python_cache() -> None:
"""Tests and a fresh process both start with no remembered interpreter."""
global _external_pyjhora_python_cached, _external_pyjhora_python_value
_external_pyjhora_python_cached = False
_external_pyjhora_python_value = None
def _resolve_external_pyjhora_python() -> Optional[str]:
"""Remember the lookup, including "not installed", for this process."""
global _external_pyjhora_python_cached, _external_pyjhora_python_value
if _external_pyjhora_python_cached:
return _external_pyjhora_python_value
_external_pyjhora_python_value = _lookup_external_pyjhora_python()
_external_pyjhora_python_cached = True
return _external_pyjhora_python_value
def _lookup_external_pyjhora_python() -> Optional[str]:
candidates = [
os.environ.get('JYOTISH_PYJHORA_PYTHON'),
shutil.which('python3.14'),