feat: complete minute birth-time rectification flow
This commit is contained in:
@@ -88,6 +88,7 @@ class CandidateResult(TypedDict):
|
||||
calculation_contract: dict[str, Any]
|
||||
stability_diagnostics: dict[str, Any]
|
||||
missing_layers: list[str]
|
||||
candidate_ranking_summary: NotRequired[list[dict[str, Any]]]
|
||||
|
||||
|
||||
def precision_weight(precision: EventPrecision) -> float:
|
||||
@@ -270,12 +271,39 @@ def adjudicate_candidate_rows(
|
||||
reasons.append("neighbor_stability_not_passed")
|
||||
if (leave_one_event_out or {}).get("status") != "pass":
|
||||
reasons.append("leave_one_event_out_not_passed")
|
||||
# Minute confirmation remains release-gated until the frozen public AA holdout passes.
|
||||
reasons.append("minute_holdout_not_ready")
|
||||
can_apply = (
|
||||
confidence == "high"
|
||||
and segment is not None
|
||||
and neighbor_stability["all_required_passed"]
|
||||
and (leave_one_event_out or {}).get("status") == "pass"
|
||||
and not missing_layers
|
||||
and not any(reason in reasons for reason in (
|
||||
"tied_leader",
|
||||
"insufficient_events",
|
||||
"insufficient_domains",
|
||||
"winning_interval_too_wide",
|
||||
"lead_margin_below_medium_threshold",
|
||||
))
|
||||
)
|
||||
ranking_summary: list[dict[str, Any]] = []
|
||||
for score in ranked_scores[:3]:
|
||||
score_rows = sorted(
|
||||
(row for row in rows if row["score"] == score),
|
||||
key=lambda row: _minute_value(row["time"]),
|
||||
)
|
||||
if not score_rows:
|
||||
continue
|
||||
representative = score_rows[(len(score_rows) - 1) // 2]
|
||||
ranking_summary.append({
|
||||
"rank": len(ranking_summary) + 1,
|
||||
"time": representative["time"],
|
||||
"score": score,
|
||||
"tied_minute_count": len(score_rows),
|
||||
})
|
||||
return {
|
||||
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{request_fingerprint}")),
|
||||
"confidence": confidence,
|
||||
"can_apply": False,
|
||||
"can_apply": can_apply,
|
||||
"winning_segment": segment,
|
||||
"event_count": event_count,
|
||||
"domain_count": domain_count,
|
||||
@@ -292,6 +320,7 @@ def adjudicate_candidate_rows(
|
||||
"leave_one_event_out": leave_one_event_out or {"status": "not_evaluated", "runs": []},
|
||||
},
|
||||
"missing_layers": missing_layers,
|
||||
"candidate_ranking_summary": ranking_summary,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import importlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -19,11 +22,22 @@ def build_report() -> dict:
|
||||
module_name = os.environ.get("PYJHORA_MODULE_NAME", "jhora").strip() or "jhora"
|
||||
adapter_exists = adapter.exists()
|
||||
module_available = importlib.util.find_spec(module_name) is not None
|
||||
import_error = None
|
||||
if module_available:
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
importlib.import_module(f"{module_name}.utils")
|
||||
importlib.import_module(f"{module_name}.horoscope.chart.charts")
|
||||
importlib.import_module(f"{module_name}.panchanga.drik")
|
||||
except Exception as exc:
|
||||
import_error = f"{exc.__class__.__name__}: {exc}"
|
||||
|
||||
if not adapter_exists:
|
||||
status = "missing_adapter"
|
||||
elif not module_available:
|
||||
status = "missing_dependency"
|
||||
elif import_error:
|
||||
status = "dependency_import_failed"
|
||||
else:
|
||||
status = "available"
|
||||
|
||||
@@ -35,14 +49,17 @@ def build_report() -> dict:
|
||||
"dependency_module": module_name,
|
||||
"dependency_available": module_available,
|
||||
"missing_dependency": None if module_available else module_name,
|
||||
"dependency_import_error": import_error,
|
||||
"install_hint": {
|
||||
"package": "PyJHora",
|
||||
"commands": ["pip install PyJHora"],
|
||||
"commands": [
|
||||
"pip install -r requirements.txt -r requirements-reference-engines.txt"
|
||||
],
|
||||
"note": "Install in an isolated optional benchmark environment, not as a hard runtime dependency.",
|
||||
},
|
||||
"license_boundary": "AGPL external benchmark only; do not vendor or make it a runtime dependency.",
|
||||
"ephemeris_data_note": "Recent PyJHora releases may require separate Swiss Ephemeris data download/configuration before full chart comparison can run.",
|
||||
"boundary": "This is an adapter readiness smoke check only; it does not run PyJHora chart comparison.",
|
||||
"boundary": "This verifies required PyJHora modules import successfully, but does not run chart comparison.",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import hashlib
|
||||
import json
|
||||
import math
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -53,18 +53,66 @@ def _lookup_timezone_name(lat: float, lon: float) -> str | None:
|
||||
|
||||
|
||||
def infer_timezone_offset(*, lat: float, lon: float, local_datetime: datetime) -> float:
|
||||
timezone_context = resolve_timezone_context(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
local_datetime=local_datetime,
|
||||
)
|
||||
offset = timezone_context["timezone_offset"]
|
||||
if offset is None:
|
||||
raise TimezoneInferenceError(
|
||||
f"local time is {timezone_context['local_time_status']} in IANA zone"
|
||||
)
|
||||
return float(offset)
|
||||
|
||||
|
||||
def resolve_timezone_context(
|
||||
*, lat: float, lon: float, local_datetime: datetime | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve an IANA zone and, when safe, its historical local UTC offset.
|
||||
|
||||
A missing local time still permits timezone identification. DST folds and
|
||||
gaps deliberately return no offset instead of silently choosing one.
|
||||
"""
|
||||
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
||||
raise TimezoneInferenceError("timezone inference received invalid coordinates")
|
||||
tz_name = _lookup_timezone_name(lat, lon)
|
||||
if not tz_name:
|
||||
raise TimezoneInferenceError("timezone inference returned no IANA zone")
|
||||
if local_datetime is None:
|
||||
return {
|
||||
"timezone_id": tz_name,
|
||||
"timezone_offset": None,
|
||||
"local_time_status": "not_provided",
|
||||
}
|
||||
try:
|
||||
offset = local_datetime.replace(tzinfo=ZoneInfo(tz_name)).utcoffset()
|
||||
zone = ZoneInfo(tz_name)
|
||||
valid_offsets: set[float] = set()
|
||||
for fold in (0, 1):
|
||||
aware = local_datetime.replace(tzinfo=zone, fold=fold)
|
||||
round_trip = aware.astimezone(timezone.utc).astimezone(zone).replace(tzinfo=None)
|
||||
offset = aware.utcoffset()
|
||||
if round_trip == local_datetime and offset is not None:
|
||||
valid_offsets.add(offset.total_seconds() / 3600.0)
|
||||
except Exception as exc:
|
||||
raise TimezoneInferenceError("timezone inference failed for IANA zone") from exc
|
||||
if offset is None:
|
||||
raise TimezoneInferenceError("timezone inference returned no UTC offset")
|
||||
return offset.total_seconds() / 3600.0
|
||||
if not valid_offsets:
|
||||
return {
|
||||
"timezone_id": tz_name,
|
||||
"timezone_offset": None,
|
||||
"local_time_status": "nonexistent",
|
||||
}
|
||||
if len(valid_offsets) > 1:
|
||||
return {
|
||||
"timezone_id": tz_name,
|
||||
"timezone_offset": None,
|
||||
"local_time_status": "ambiguous",
|
||||
}
|
||||
return {
|
||||
"timezone_id": tz_name,
|
||||
"timezone_offset": valid_offsets.pop(),
|
||||
"local_time_status": "resolved",
|
||||
}
|
||||
|
||||
|
||||
def _normalized_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -83,9 +131,16 @@ def _normalized_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
lat = float(payload["lat"])
|
||||
lon = float(payload["lon"])
|
||||
tz_requested = payload.get("tz")
|
||||
timezone_id = payload.get("timezone_id", payload.get("timezoneId"))
|
||||
timezone_source = "explicit_offset"
|
||||
if tz_requested in {None, ""}:
|
||||
tz = infer_timezone_offset(lat=lat, lon=lon, local_datetime=local_dt)
|
||||
timezone_context = resolve_timezone_context(lat=lat, lon=lon, local_datetime=local_dt)
|
||||
timezone_id = timezone_context["timezone_id"]
|
||||
tz = timezone_context["timezone_offset"]
|
||||
if tz is None:
|
||||
raise TimezoneInferenceError(
|
||||
f"local time is {timezone_context['local_time_status']} in IANA zone"
|
||||
)
|
||||
timezone_source = "iana_inferred"
|
||||
else:
|
||||
tz = float(tz_requested)
|
||||
@@ -101,6 +156,7 @@ def _normalized_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"tz": tz,
|
||||
"timezone_id": str(timezone_id).strip() if timezone_id else None,
|
||||
"timezone_source": timezone_source,
|
||||
"ayanamsa": ayanamsa,
|
||||
"node_mode": requested_node,
|
||||
@@ -151,11 +207,16 @@ def compute_chart(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"ephemeris_source": "swisseph_calc_ut",
|
||||
"ephemeris_flags_verified": False,
|
||||
}
|
||||
if request["timezone_id"]:
|
||||
effective["timezone_id"] = request["timezone_id"]
|
||||
requested = {
|
||||
"ayanamsa": payload.get("ayanamsa", "lahiri"),
|
||||
"node_mode": payload.get("node_mode", payload.get("nodeMode", "mean")),
|
||||
"timezone_offset": payload.get("tz"),
|
||||
}
|
||||
requested_timezone_id = payload.get("timezone_id", payload.get("timezoneId"))
|
||||
if requested_timezone_id:
|
||||
requested["timezone_id"] = requested_timezone_id
|
||||
contract = _contract(requested, effective, algorithm="sidereal_natal_chart")
|
||||
hash_payload = {
|
||||
"contract": contract,
|
||||
|
||||
+451
-21
@@ -90,6 +90,246 @@ _RATE_LIMIT_LOCK = threading.Lock()
|
||||
_RATE_LIMIT_BUCKETS: dict[str, tuple[float, int]] = {}
|
||||
|
||||
|
||||
def resolve_location_timezone_payload(body):
|
||||
"""Resolve a global coordinate to IANA timezone and safe historical offset."""
|
||||
calculation_service = _load_local_module('domain_calculation_service')
|
||||
lat = float(body['latitude'])
|
||||
lon = float(body['longitude'])
|
||||
birth_date = str(body.get('birthDate') or '').strip()
|
||||
birth_time = str(body.get('birthTime') or '').strip()
|
||||
local_datetime = None
|
||||
if birth_time and not birth_date:
|
||||
raise ValueError('birthDate is required when birthTime is supplied')
|
||||
if birth_date:
|
||||
if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', birth_date):
|
||||
raise ValueError('birthDate must be YYYY-MM-DD')
|
||||
if birth_time:
|
||||
if not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d', birth_time):
|
||||
raise ValueError('birthTime must be HH:MM')
|
||||
local_datetime = datetime.fromisoformat(f'{birth_date}T{birth_time}:00')
|
||||
else:
|
||||
datetime.fromisoformat(birth_date)
|
||||
context = calculation_service.resolve_timezone_context(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
local_datetime=local_datetime,
|
||||
)
|
||||
return {
|
||||
'available': True,
|
||||
'timezoneId': context['timezone_id'],
|
||||
'timezoneOffset': context['timezone_offset'],
|
||||
'timezoneSource': 'iana_historical',
|
||||
'localTimeStatus': context['local_time_status'],
|
||||
}
|
||||
|
||||
_VEDASTRO_RECTIFICATION_DOMAIN_MAP = {
|
||||
'career': ('career', 'direct'),
|
||||
'finance': ('wealth', 'direct'),
|
||||
'relationship': ('marriage', 'direct'),
|
||||
'education': ('career', 'proxy'),
|
||||
'relocation': ('career', 'proxy'),
|
||||
}
|
||||
|
||||
|
||||
def _rectification_event_date_range(event):
|
||||
value = str(event.get('date') or '')
|
||||
precision = str(event.get('precision') or '')
|
||||
if precision == 'day':
|
||||
return value, value
|
||||
if precision == 'month':
|
||||
start = datetime.strptime(value, '%Y-%m')
|
||||
next_month = (
|
||||
datetime(start.year + 1, 1, 1)
|
||||
if start.month == 12
|
||||
else datetime(start.year, start.month + 1, 1)
|
||||
)
|
||||
return start.strftime('%Y-%m-%d'), (next_month - timedelta(days=1)).strftime('%Y-%m-%d')
|
||||
if precision == 'year':
|
||||
year = int(value)
|
||||
return f'{year:04d}-01-01', f'{year:04d}-12-31'
|
||||
raise ValueError(f'Unsupported event precision: {precision}')
|
||||
|
||||
|
||||
def _rectification_event_representative_date(start_date, end_date):
|
||||
start = datetime.strptime(start_date, '%Y-%m-%d')
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d')
|
||||
return (start + (end - start) / 2).strftime('%Y-%m-%d')
|
||||
|
||||
|
||||
def _select_vedastro_rectification_events(events):
|
||||
precision_rank = {'year': 1, 'month': 2, 'day': 3}
|
||||
selected_by_domain = {}
|
||||
eligible_event_count = 0
|
||||
unsupported_events = []
|
||||
for event in events:
|
||||
mapping = _VEDASTRO_RECTIFICATION_DOMAIN_MAP.get(event['domain'])
|
||||
if mapping is None:
|
||||
unsupported_events.append({
|
||||
'event_id': event['id'],
|
||||
'ui_domain': event['domain'],
|
||||
'status': 'unsupported_range_scan_domain',
|
||||
'reason': 'VedAstro SearchEvents does not expose a supported health-pressure range-scan domain.',
|
||||
})
|
||||
continue
|
||||
eligible_event_count += 1
|
||||
adapter_domain, mapping_mode = mapping
|
||||
start_date, end_date = _rectification_event_date_range(event)
|
||||
rank = (
|
||||
1 if mapping_mode == 'direct' else 0,
|
||||
precision_rank.get(str(event.get('precision') or ''), 0),
|
||||
end_date,
|
||||
)
|
||||
current = selected_by_domain.get(adapter_domain)
|
||||
if current is None or rank > current[0]:
|
||||
representative_date = _rectification_event_representative_date(start_date, end_date)
|
||||
selected_by_domain[adapter_domain] = (
|
||||
rank,
|
||||
event,
|
||||
adapter_domain,
|
||||
mapping_mode,
|
||||
representative_date,
|
||||
representative_date,
|
||||
)
|
||||
selected_events = [
|
||||
selected_by_domain[domain][1:]
|
||||
for domain in sorted(selected_by_domain)
|
||||
]
|
||||
return selected_events, eligible_event_count, unsupported_events
|
||||
|
||||
|
||||
def _safe_vedastro_range_scan_summary(event, adapter_domain, mapping_mode, start_date, end_date, report):
|
||||
ledger = report.get('evidence_ledger') if isinstance(report.get('evidence_ledger'), list) else []
|
||||
signal_lift = round(sum(
|
||||
float(item.get('signal_lift') or 0)
|
||||
for item in ledger
|
||||
if isinstance(item, dict) and isinstance(item.get('signal_lift'), (int, float))
|
||||
), 6)
|
||||
event_count = int(report.get('event_count') or 0)
|
||||
return {
|
||||
'event_id': str(event.get('id') or ''),
|
||||
'ui_domain': str(event.get('domain') or ''),
|
||||
'adapter_domain': adapter_domain,
|
||||
'mapping_mode': mapping_mode,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'status': str(report.get('status') or 'unknown'),
|
||||
'available': bool(report.get('available')),
|
||||
'event_count': event_count,
|
||||
'matched': event_count > 0,
|
||||
'signal_lift': signal_lift,
|
||||
'top_event_id': str((report.get('top_event') or {}).get('event_id') or ''),
|
||||
}
|
||||
|
||||
|
||||
def _vedastro_candidate_metric(event_scans):
|
||||
successful = [item for item in event_scans if item.get('status') == 'ok' and item.get('available')]
|
||||
return {
|
||||
'requested_event_count': len(event_scans),
|
||||
'successful_event_count': len(successful),
|
||||
'matched_event_count': sum(1 for item in successful if item.get('matched')),
|
||||
'event_hit_count': sum(int(item.get('event_count') or 0) for item in successful),
|
||||
'signal_lift': round(sum(float(item.get('signal_lift') or 0) for item in successful), 6),
|
||||
}
|
||||
|
||||
|
||||
def _vedastro_metric_key(metric):
|
||||
return (
|
||||
int(metric.get('matched_event_count') or 0),
|
||||
float(metric.get('signal_lift') or 0),
|
||||
int(metric.get('event_hit_count') or 0),
|
||||
)
|
||||
|
||||
|
||||
_VEDASTRO_MINUTE_SENSITIVE_LAYERS = (
|
||||
'ascendant_house_boundaries',
|
||||
'D9',
|
||||
'D10',
|
||||
'dasha_boundaries',
|
||||
)
|
||||
|
||||
|
||||
def _safe_vedastro_minute_snapshot_summary(candidate_time, report):
|
||||
layers = report.get('layers') if isinstance(report.get('layers'), dict) else {}
|
||||
safe_layers = {}
|
||||
for name in (*_VEDASTRO_MINUTE_SENSITIVE_LAYERS, 'kp_cusp_sub_lord'):
|
||||
layer = layers.get(name) if isinstance(layers.get(name), dict) else {}
|
||||
safe_layer = {
|
||||
'status': str(layer.get('status') or 'missing'),
|
||||
}
|
||||
if name in _VEDASTRO_MINUTE_SENSITIVE_LAYERS:
|
||||
safe_layer['fingerprint'] = layer.get('fingerprint')
|
||||
if name == 'ascendant_house_boundaries':
|
||||
safe_layer['ascendant'] = layer.get('ascendant')
|
||||
safe_layer['house_count'] = len(layer.get('houses') or {})
|
||||
elif name in {'D9', 'D10'}:
|
||||
safe_layer['house_count'] = len(layer.get('houses') or {})
|
||||
safe_layer['planet_count'] = len(layer.get('planets') or {})
|
||||
elif name == 'dasha_boundaries':
|
||||
safe_layer['boundary_count'] = int(layer.get('boundary_count') or 0)
|
||||
elif name == 'kp_cusp_sub_lord':
|
||||
safe_layer['reason'] = str(layer.get('reason') or '')
|
||||
safe_layers[name] = safe_layer
|
||||
return {
|
||||
'candidate_time': candidate_time,
|
||||
'status': str(report.get('status') or 'blocked'),
|
||||
'available': bool(report.get('available')),
|
||||
'source': str(report.get('source') or 'vedastro_official'),
|
||||
'layers': safe_layers,
|
||||
}
|
||||
|
||||
|
||||
def _compare_vedastro_minute_snapshots(candidate_snapshots):
|
||||
comparison_ready = len(candidate_snapshots) == 2 and all(
|
||||
item.get('available') for item in candidate_snapshots
|
||||
)
|
||||
differences = {}
|
||||
discriminated_layers = []
|
||||
for layer_name in _VEDASTRO_MINUTE_SENSITIVE_LAYERS:
|
||||
left = (candidate_snapshots[0].get('layers') or {}).get(layer_name, {}) if candidate_snapshots else {}
|
||||
right = (candidate_snapshots[1].get('layers') or {}).get(layer_name, {}) if len(candidate_snapshots) > 1 else {}
|
||||
both_available = left.get('status') == 'ok' and right.get('status') == 'ok'
|
||||
differs = bool(
|
||||
both_available
|
||||
and left.get('fingerprint')
|
||||
and right.get('fingerprint')
|
||||
and left.get('fingerprint') != right.get('fingerprint')
|
||||
)
|
||||
differences[layer_name] = {
|
||||
'status': 'different' if differs else 'same' if both_available else 'unavailable',
|
||||
'discriminated': differs,
|
||||
}
|
||||
if differs:
|
||||
discriminated_layers.append(layer_name)
|
||||
return {
|
||||
'comparison_ready': comparison_ready,
|
||||
'discriminated': comparison_ready and bool(discriminated_layers),
|
||||
'discriminated_layers': discriminated_layers,
|
||||
'differences': differences,
|
||||
}
|
||||
|
||||
|
||||
def _rectification_candidate_ready_for_external_validation(result):
|
||||
"""Allow external validation once local scoring has a narrow, auditable lead."""
|
||||
segment = result.get('winning_segment')
|
||||
ranking = result.get('candidate_ranking_summary')
|
||||
if not isinstance(segment, dict) or not isinstance(ranking, list) or len(ranking) < 2:
|
||||
return False
|
||||
try:
|
||||
width_minutes = int(segment.get('width_minutes') or 0)
|
||||
top_score = float(result.get('top_score'))
|
||||
second_score = float(result.get('second_score'))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return (
|
||||
int(result.get('event_count') or 0) >= 3
|
||||
and int(result.get('domain_count') or 0) >= 2
|
||||
and 1 <= width_minutes <= 15
|
||||
and bool(segment.get('representative_time'))
|
||||
and top_score > second_score
|
||||
and 'missing_mandatory_layers' not in (result.get('reasons') or [])
|
||||
)
|
||||
|
||||
|
||||
def build_evidence_packet_view(job_record: dict | None) -> dict:
|
||||
"""Public, token-protected job view. Excludes prompt internals and raw input."""
|
||||
job_record = job_record or {}
|
||||
@@ -1693,6 +1933,11 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
lat, lon, tz = CITY_DB[matched]
|
||||
self._json({'status': 'local_city_match', 'city': matched, 'lat': lat, 'lon': lon, 'tz': tz})
|
||||
elif path == '/api/location/timezone':
|
||||
try:
|
||||
self._json(resolve_location_timezone_payload(body))
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
self._error_json(str(exc), 400, 'ERR_INVALID_LOCATION_TIMEZONE_REQUEST')
|
||||
elif path == '/api/chart':
|
||||
result = self._compute_chart(body)
|
||||
self._json(result)
|
||||
@@ -6938,8 +7183,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
lon = self._get_float(body, 'lon', 0, -180, 180)
|
||||
tz = self._get_float(body, 'tz', 0, -14, 14)
|
||||
events = body.get('events')
|
||||
if not isinstance(events, list) or not 3 <= len(events) <= 8:
|
||||
raise BadRequest('events must contain between 3 and 8 items')
|
||||
if not isinstance(events, list) or len(events) < 3:
|
||||
raise BadRequest('events must contain at least 3 items')
|
||||
normalized_events = []
|
||||
allowed_domains = {'education', 'relocation', 'relationship', 'career', 'finance', 'health_pressure'}
|
||||
formats = {'year': '%Y', 'month': '%Y-%m', 'day': '%Y-%m-%d'}
|
||||
@@ -6986,35 +7231,220 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'tz': tz,
|
||||
'events': normalized_events,
|
||||
})
|
||||
external_validation = {
|
||||
'status': 'not_evaluated',
|
||||
'reason': 'local_candidate_not_ready_for_external_validation',
|
||||
'vedastro_status': 'not_evaluated',
|
||||
'vedastro_reason': 'official_vedastro_runs_after_local_scoring_produces_a_narrow_candidate',
|
||||
'blockers': [],
|
||||
}
|
||||
local_candidate_ready = _rectification_candidate_ready_for_external_validation(result)
|
||||
if high_rigor and local_candidate_ready:
|
||||
from scripts.rectification_three_engine_packet import build_packet
|
||||
|
||||
representative_time = result['winning_segment']['representative_time']
|
||||
representative_hour, representative_minute = representative_time.split(':', 1)
|
||||
engine_case = {
|
||||
'year': parsed_birth_date.year,
|
||||
'month': parsed_birth_date.month,
|
||||
'day': parsed_birth_date.day,
|
||||
'hour': int(representative_hour),
|
||||
'minute': int(representative_minute),
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'tz': tz,
|
||||
}
|
||||
three_engine_packet = build_packet(engine_case)
|
||||
vedastro_events = [{
|
||||
'date': event['date'],
|
||||
'domain': event['domain'],
|
||||
'summary': event.get('summary', ''),
|
||||
} for event in normalized_events]
|
||||
vedastro_result = self._compute_vedastro_gateway_run({
|
||||
**engine_case,
|
||||
'question': (
|
||||
'Validate this rectified birth-minute candidate against the dated life events '
|
||||
'and minute-sensitive chart boundaries. Events: '
|
||||
+ json.dumps(vedastro_events, ensure_ascii=False, sort_keys=True)
|
||||
),
|
||||
'theme': sorted({
|
||||
_VEDASTRO_RECTIFICATION_DOMAIN_MAP[event['domain']][0]
|
||||
for event in normalized_events
|
||||
if event['domain'] in _VEDASTRO_RECTIFICATION_DOMAIN_MAP
|
||||
}),
|
||||
'reference_date': datetime.now().strftime('%Y-%m-%d'),
|
||||
})
|
||||
vedastro_status = str(
|
||||
vedastro_result.get('official_closure_state')
|
||||
or vedastro_result.get('status')
|
||||
or 'blocked'
|
||||
)
|
||||
parity_passed = (
|
||||
all(status == 'ok' for status in three_engine_packet.get('engine_status', {}).values())
|
||||
and three_engine_packet.get('mismatch_count') == 0
|
||||
)
|
||||
official_response_present = vedastro_status == 'official_verified'
|
||||
ranking = result.get('candidate_ranking_summary') or []
|
||||
candidate_times = [
|
||||
str(item.get('time') or '')
|
||||
for item in ranking[:2]
|
||||
if isinstance(item, dict) and item.get('time')
|
||||
]
|
||||
supported_events, eligible_event_count, unsupported_events = (
|
||||
_select_vedastro_rectification_events(normalized_events)
|
||||
)
|
||||
|
||||
vedastro_adapter = _load_local_module('vedastro_service_adapter')
|
||||
candidate_validations = []
|
||||
minute_candidate_snapshots = []
|
||||
range_scan_cache = {}
|
||||
for candidate_time in candidate_times:
|
||||
candidate_hour, candidate_minute = candidate_time.split(':', 1)
|
||||
candidate_case = {
|
||||
**engine_case,
|
||||
'hour': int(candidate_hour),
|
||||
'minute': int(candidate_minute),
|
||||
}
|
||||
minute_snapshot = vedastro_adapter.run_rectification_minute_snapshot_for_case(
|
||||
candidate_case,
|
||||
case_id=f'user_rectification_{candidate_time.replace(":", "")}',
|
||||
)
|
||||
minute_candidate_snapshots.append(
|
||||
_safe_vedastro_minute_snapshot_summary(candidate_time, minute_snapshot)
|
||||
)
|
||||
event_scans = []
|
||||
for event, adapter_domain, mapping_mode, event_start, event_end in supported_events:
|
||||
cache_key = (candidate_time, adapter_domain, event_start, event_end)
|
||||
if cache_key not in range_scan_cache:
|
||||
range_scan_cache[cache_key] = vedastro_adapter.run_range_scan_for_case(
|
||||
candidate_case,
|
||||
adapter_domain,
|
||||
event_start,
|
||||
event_end,
|
||||
case_id=f'user_rectification_{candidate_time.replace(":", "")}',
|
||||
)
|
||||
event_scans.append(_safe_vedastro_range_scan_summary(
|
||||
event,
|
||||
adapter_domain,
|
||||
mapping_mode,
|
||||
event_start,
|
||||
event_end,
|
||||
range_scan_cache[cache_key],
|
||||
))
|
||||
candidate_validations.append({
|
||||
'candidate_time': candidate_time,
|
||||
'metric': _vedastro_candidate_metric(event_scans),
|
||||
'events': event_scans,
|
||||
})
|
||||
|
||||
minute_comparison = _compare_vedastro_minute_snapshots(minute_candidate_snapshots)
|
||||
minute_snapshots_verified = bool(
|
||||
minute_comparison['comparison_ready']
|
||||
and all(item.get('source') == 'vedastro_official' for item in minute_candidate_snapshots)
|
||||
)
|
||||
official_response_present = official_response_present or minute_snapshots_verified
|
||||
if minute_snapshots_verified:
|
||||
vedastro_status = 'official_verified'
|
||||
background_comparison_ready = len(candidate_validations) == 2 and bool(supported_events)
|
||||
scans_succeeded = background_comparison_ready and all(
|
||||
item['metric']['successful_event_count'] == item['metric']['requested_event_count']
|
||||
for item in candidate_validations
|
||||
)
|
||||
semantic_validation_passed = (
|
||||
official_response_present
|
||||
and minute_snapshots_verified
|
||||
and minute_comparison['discriminated']
|
||||
)
|
||||
external_blockers = []
|
||||
if not parity_passed:
|
||||
external_blockers.append('three_engine_parity_not_passed')
|
||||
if not official_response_present:
|
||||
external_blockers.append('vedastro_official_response_missing')
|
||||
if len(candidate_times) < 2:
|
||||
external_blockers.append('vedastro_runner_up_candidate_missing')
|
||||
if len(candidate_times) >= 2 and not minute_snapshots_verified:
|
||||
external_blockers.append('vedastro_minute_snapshot_failed')
|
||||
if minute_snapshots_verified and not minute_comparison['discriminated']:
|
||||
external_blockers.append('vedastro_minute_sensitive_layers_not_discriminated')
|
||||
background_status = (
|
||||
'pass'
|
||||
if scans_succeeded
|
||||
else 'partial'
|
||||
if candidate_validations
|
||||
else 'not_evaluated'
|
||||
)
|
||||
external_validation = {
|
||||
'status': 'pass' if parity_passed and semantic_validation_passed else 'fail',
|
||||
'reason': (
|
||||
'winner_must_pass_three_engine_parity_and_official_vedastro_minute_sensitive_candidate_identity_checks; '
|
||||
'SearchEvents_is_background_only'
|
||||
),
|
||||
'vedastro_status': vedastro_status,
|
||||
'vedastro_reason': str(
|
||||
vedastro_result.get('official_closure_reason')
|
||||
or 'official_minute_snapshot_required'
|
||||
),
|
||||
'blockers': external_blockers,
|
||||
'candidate_time': representative_time,
|
||||
'engine_status': three_engine_packet.get('engine_status', {}),
|
||||
'match_count': three_engine_packet.get('match_count', 0),
|
||||
'mismatch_count': three_engine_packet.get('mismatch_count', 0),
|
||||
'minute_sensitive_validation': {
|
||||
'status': 'pass' if semantic_validation_passed else 'fail',
|
||||
'comparison_contract': (
|
||||
'official_ascendant_house_boundaries_plus_D9_plus_D10_plus_dasha_boundary_identity; '
|
||||
'local_event_scoring_keeps_candidate_order'
|
||||
),
|
||||
'candidate_order_source': 'local_dated_event_scoring',
|
||||
'candidates': minute_candidate_snapshots,
|
||||
**minute_comparison,
|
||||
'kp_cusp_sub_lord': {
|
||||
'status': 'unsupported_by_verified_official_interface',
|
||||
'used_for_decision': False,
|
||||
},
|
||||
},
|
||||
'event_background_validation': {
|
||||
'status': background_status,
|
||||
'used_for_decision': False,
|
||||
'comparison_contract': 'SearchEvents provides background event context and never selects the final minute',
|
||||
'eligible_event_count': eligible_event_count,
|
||||
'supported_event_count': len(supported_events),
|
||||
'selection_policy': (
|
||||
'one_strongest_event_per_native_adapter_domain; '
|
||||
'direct_mapping_before_proxy; day_before_month_before_year; '
|
||||
'newest_on_equal_precision; month_or_year_uses_midpoint_day'
|
||||
),
|
||||
'unsupported_events': unsupported_events,
|
||||
'candidates': candidate_validations,
|
||||
},
|
||||
}
|
||||
result['three_engine_packet'] = {
|
||||
**three_engine_packet,
|
||||
'vedastro': {
|
||||
'status': vedastro_status,
|
||||
'official_closure_reason': vedastro_result.get('official_closure_reason'),
|
||||
'minute_sensitive_layers': list(_VEDASTRO_MINUTE_SENSITIVE_LAYERS),
|
||||
'search_events_role': 'background_only',
|
||||
},
|
||||
'can_confirm': parity_passed and semantic_validation_passed,
|
||||
}
|
||||
|
||||
from scripts.rectification_technique_contract import build_rectification_technique_contract
|
||||
result['technique_contract'] = build_rectification_technique_contract(
|
||||
event_count=result.get('event_count', 0),
|
||||
domain_count=result.get('domain_count', 0),
|
||||
high_rigor=high_rigor,
|
||||
local_candidate_ready=local_candidate_ready,
|
||||
stability_diagnostics=result.get('stability_diagnostics'),
|
||||
required_layers_complete='missing_mandatory_layers' not in result.get('reasons', []),
|
||||
canonical_input_hash=result.get('canonical_input_hash', ''),
|
||||
missing_required_layers=result.get('missing_layers', []),
|
||||
external_validation=external_validation,
|
||||
)
|
||||
result['can_apply'] = bool(result['technique_contract']['confirmation_allowed'])
|
||||
if high_rigor:
|
||||
from scripts.rectification_three_engine_packet import build_packet
|
||||
result['three_engine_packet'] = build_packet({
|
||||
'year': parsed_birth_date.year,
|
||||
'month': parsed_birth_date.month,
|
||||
'day': parsed_birth_date.day,
|
||||
'hour': int(start_time.split(':', 1)[0]),
|
||||
'minute': int(start_time.split(':', 1)[1]),
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'tz': tz,
|
||||
},
|
||||
enqueue_vedastro_gateway=True,
|
||||
vedastro_question='High-rigor birth-time rectification evidence packet',
|
||||
vedastro_reference_date=datetime.now().strftime('%Y-%m-%d'),
|
||||
)
|
||||
result['can_apply'] = False
|
||||
result.setdefault('reasons', []).append('three_engine_parity_not_passed')
|
||||
result['reasons'] = list(dict.fromkeys(
|
||||
result.get('reasons', []) + result['technique_contract']['hard_blockers']
|
||||
))
|
||||
return {
|
||||
'success': True,
|
||||
'endpoint': 'active_rectification_events',
|
||||
@@ -7147,7 +7577,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
result['can_apply'] = False
|
||||
result.setdefault('reasons', []).append('minute_holdout_not_ready')
|
||||
result.setdefault('reasons', []).append('vedastro_validation_required')
|
||||
return {'success': True, 'endpoint': 'dynamic_rectification_score', **result}
|
||||
|
||||
def _compute_case_validation(self, body):
|
||||
|
||||
@@ -13,35 +13,57 @@ def build_rectification_technique_contract(
|
||||
event_count: int,
|
||||
domain_count: int,
|
||||
high_rigor: bool = False,
|
||||
local_candidate_ready: bool = False,
|
||||
stability_diagnostics: dict[str, Any] | None = None,
|
||||
required_layers_complete: bool = False,
|
||||
canonical_input_hash: str = "",
|
||||
missing_required_layers: list[str] | None = None,
|
||||
external_validation: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
blockers: list[str] = []
|
||||
if event_count < 3:
|
||||
blockers.append("insufficient_events")
|
||||
if domain_count < 2:
|
||||
blockers.append("insufficient_domains")
|
||||
if high_rigor:
|
||||
blockers.append("three_engine_parity_not_passed")
|
||||
neighbor = (stability_diagnostics or {}).get("neighbor_stability") or {}
|
||||
leave_one_out = (stability_diagnostics or {}).get("leave_one_event_out") or {}
|
||||
if not neighbor.get("all_required_passed"):
|
||||
blockers.append("neighbor_stability_not_passed")
|
||||
if leave_one_out.get("status") != "pass":
|
||||
blockers.append("leave_one_event_out_not_passed")
|
||||
external = external_validation or {}
|
||||
external_status = str(external.get("status") or "not_evaluated")
|
||||
if not local_candidate_ready:
|
||||
blockers.append("local_candidate_not_ready")
|
||||
if not required_layers_complete:
|
||||
blockers.append("required_layers_incomplete")
|
||||
blockers.append("minute_holdout_not_ready")
|
||||
if not high_rigor:
|
||||
blockers.append("vedastro_validation_required")
|
||||
elif external_status != "pass":
|
||||
blockers.extend(external.get("blockers") or ["vedastro_validation_not_passed"])
|
||||
confirmation_allowed = (
|
||||
event_count >= 3
|
||||
and domain_count >= 2
|
||||
and local_candidate_ready
|
||||
and required_layers_complete
|
||||
and high_rigor
|
||||
and external_status == "pass"
|
||||
)
|
||||
gates = {
|
||||
"event_quality": _gate("pass" if event_count >= 3 else "fail", "requires_at_least_three_dated_events"),
|
||||
"cross_domain_coverage": _gate("pass" if domain_count >= 2 else "fail", "requires_at_least_two_event_domains"),
|
||||
"local_candidate": _gate("pass" if local_candidate_ready else "fail", "requires_a_unique_leading_candidate_range_no_wider_than_fifteen_minutes"),
|
||||
"required_layers": _gate("pass" if required_layers_complete else "fail", "all_event_required_layers_must_compute"),
|
||||
"neighbor_stability": _gate("pass" if neighbor.get("all_required_passed") else "fail", "requires_unique_lead_at_plus_minus_1_2_5_minutes"),
|
||||
"leave_one_event_out": _gate("pass" if leave_one_out.get("status") == "pass" else "fail", "leader_must_survive_removing_each_event"),
|
||||
"three_engine_input_parity": _gate("fail" if high_rigor else "not_evaluated", "same_normalized_input_and_domain_parity_required"),
|
||||
"public_holdout_release": _gate("blocked", "frozen_public_AA_minute_holdout_is_below_20_cases"),
|
||||
"neighbor_stability": _gate("pass" if neighbor.get("all_required_passed") else "diagnostic_fail", "diagnostic_only_unique_lead_at_plus_minus_1_2_5_minutes"),
|
||||
"leave_one_event_out": _gate("pass" if leave_one_out.get("status") == "pass" else "diagnostic_fail", "diagnostic_only_leader_survival_after_removing_each_event"),
|
||||
"three_engine_input_parity": _gate(
|
||||
"pass" if external.get("mismatch_count") == 0 and external.get("engine_status") else "fail" if high_rigor and external_status != "not_evaluated" else "not_evaluated",
|
||||
"local_pyjhora_and_jyotishganit_must_match_the_same_candidate_input",
|
||||
),
|
||||
"vedastro_official_response": _gate(
|
||||
"pass" if external.get("vedastro_status") == "official_verified" else "fail" if high_rigor and external_status != "not_evaluated" else "not_evaluated",
|
||||
str(external.get("vedastro_reason") or "official_vedastro_response_required_before_minute_sensitive_validation"),
|
||||
),
|
||||
"vedastro_minute_sensitive_validation": _gate(
|
||||
"pass" if (external.get("minute_sensitive_validation") or {}).get("status") == "pass" else "fail" if high_rigor and external_status != "not_evaluated" else "not_evaluated",
|
||||
"official_ascendant_house_D9_D10_and_dasha_candidate_identity_must_discriminate_the_local_winner_from_runner_up",
|
||||
),
|
||||
}
|
||||
reported_missing_layers = list(dict.fromkeys([
|
||||
*(missing_required_layers or []),
|
||||
@@ -56,12 +78,20 @@ def build_rectification_technique_contract(
|
||||
"missing_layers": reported_missing_layers,
|
||||
"partial_layers": ["shadbala_sthana_drik_naisargika"],
|
||||
"auxiliary_layers": ["functional_benefic_malefic", "controlled_transit", "ashtakavarga", "shadbala_verified_components"],
|
||||
"external_engines": {"status": "required_not_run" if high_rigor else "not_run", "providers": ["pyjhora", "jyotishganit", "vedastro"]},
|
||||
"external_engines": {
|
||||
"status": external_status,
|
||||
"providers": ["pyjhora", "jyotishganit", "vedastro"],
|
||||
"validation": external,
|
||||
},
|
||||
"canonical_input_hash": canonical_input_hash,
|
||||
"gates": gates,
|
||||
"hard_blockers": list(dict.fromkeys(blockers)),
|
||||
"decision": "continue_rectification",
|
||||
"confirmation_allowed": False,
|
||||
"can_narrow_to_minute": False,
|
||||
"boundary": "A candidate range is not a confirmed birth minute. Minute confirmation is disabled until the frozen public AA holdout release gate passes.",
|
||||
"decision": "confirm_minute" if confirmation_allowed else "continue_rectification",
|
||||
"confirmation_allowed": confirmation_allowed,
|
||||
"can_narrow_to_minute": confirmation_allowed,
|
||||
"boundary": (
|
||||
"The narrow local candidate passed required-layer, three-engine, and official VedAstro minute-sensitive identity validation. SearchEvents is background evidence only. Neighbor and leave-one-event-out stability remain diagnostic confidence indicators. Explicit user confirmation is still required before replacing the stored birth time."
|
||||
if confirmation_allowed
|
||||
else "A candidate range is not a confirmed birth minute. Continue until local candidate calculation, required layers, three-engine parity, and official VedAstro minute-sensitive identity validation pass. SearchEvents remains background evidence only; neighbor and leave-one-event-out results are diagnostic rather than hard blockers."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -24,6 +25,7 @@ except ModuleNotFoundError: # pragma: no cover - direct script execution
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
JYOTISHGANIT_ROOT = ROOT / "references" / "open_source_sources" / "jyotishganit"
|
||||
JYOTISHGANIT_DATA_DIR = ROOT / ".cache" / "jyotishganit"
|
||||
PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
|
||||
SIGNS = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces")
|
||||
|
||||
@@ -54,7 +56,12 @@ def _pyjhora_d1(case: dict[str, Any]) -> dict[str, str]:
|
||||
return {index_to_planet[body]: SIGNS[int(position[0])] for body, position in charts.rasi_chart(jd, place) if body in index_to_planet}
|
||||
|
||||
|
||||
def _ensure_jyotishganit_data_dir() -> str:
|
||||
return os.environ.setdefault("JYOTISHGANIT_DATA_DIR", str(JYOTISHGANIT_DATA_DIR))
|
||||
|
||||
|
||||
def _jyotishganit_d1(case: dict[str, Any]) -> dict[str, str]:
|
||||
_ensure_jyotishganit_data_dir()
|
||||
sys.path.insert(0, str(JYOTISHGANIT_ROOT))
|
||||
try:
|
||||
from jyotishganit import calculate_birth_chart, get_birth_chart_json
|
||||
|
||||
@@ -2748,6 +2748,126 @@ def run_official_full_snapshot_for_case(
|
||||
return _store_official_full_snapshot_semantic_cache(case, case_id, result)
|
||||
|
||||
|
||||
def _rectification_position(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict) or not value.get("sign"):
|
||||
return None
|
||||
position = {"sign": str(value["sign"])}
|
||||
degree = value.get("degree_in_sign")
|
||||
if isinstance(degree, (int, float)):
|
||||
position["degree_in_sign"] = round(float(degree), 6)
|
||||
return position
|
||||
|
||||
|
||||
def _rectification_varga_positions(
|
||||
chart: dict[str, Any],
|
||||
varga: str,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
houses = chart.get("houses") if isinstance(chart.get("houses"), dict) else {}
|
||||
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
|
||||
house_positions = {
|
||||
name: position
|
||||
for name, item in sorted(houses.items())
|
||||
if isinstance(item, dict)
|
||||
and (position := _rectification_position((item.get("vargas") or {}).get(varga)))
|
||||
}
|
||||
planet_positions = {
|
||||
name: position
|
||||
for name, item in sorted(planets.items())
|
||||
if isinstance(item, dict)
|
||||
and (position := _rectification_position((item.get("vargas") or {}).get(varga)))
|
||||
}
|
||||
return house_positions, planet_positions
|
||||
|
||||
|
||||
def _rectification_timeline_item_count(value: Any) -> int:
|
||||
if isinstance(value, list):
|
||||
return len(value)
|
||||
if isinstance(value, dict):
|
||||
return max(
|
||||
(_rectification_timeline_item_count(item) for item in value.values()),
|
||||
default=0,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def run_rectification_minute_snapshot_for_case(
|
||||
case: dict[str, Any],
|
||||
*,
|
||||
case_id: str = "user_chart",
|
||||
) -> dict[str, Any]:
|
||||
"""Return a safe official fingerprint for minute-candidate comparison.
|
||||
|
||||
The summary intentionally excludes raw official responses. SearchEvents is
|
||||
not part of this fingerprint; it remains a separate background check.
|
||||
"""
|
||||
snapshot = run_official_full_snapshot_for_case(case, case_id=case_id)
|
||||
chart = snapshot.get("official_chart") if isinstance(snapshot.get("official_chart"), dict) else {}
|
||||
houses = chart.get("houses") if isinstance(chart.get("houses"), dict) else {}
|
||||
ascendant = _rectification_position(chart.get("ascendant"))
|
||||
house_positions = {
|
||||
name: position
|
||||
for name, item in sorted(houses.items())
|
||||
if (position := _rectification_position(item))
|
||||
}
|
||||
d9_houses, d9_planets = _rectification_varga_positions(chart, "D9")
|
||||
d10_houses, d10_planets = _rectification_varga_positions(chart, "D10")
|
||||
sections = snapshot.get("snapshot_sections") if isinstance(snapshot.get("snapshot_sections"), dict) else {}
|
||||
section_statuses = snapshot.get("section_statuses") if isinstance(snapshot.get("section_statuses"), dict) else {}
|
||||
dasha_payload = sections.get("dasha_all")
|
||||
|
||||
ascendant_house_payload = {
|
||||
"ascendant": ascendant,
|
||||
"houses": house_positions,
|
||||
}
|
||||
d9_payload = {"houses": d9_houses, "planets": d9_planets}
|
||||
d10_payload = {"houses": d10_houses, "planets": d10_planets}
|
||||
dasha_available = section_statuses.get("dasha_all") == "ok" and isinstance(dasha_payload, dict)
|
||||
layers = {
|
||||
"ascendant_house_boundaries": {
|
||||
"status": "ok" if ascendant and house_positions else "missing",
|
||||
**ascendant_house_payload,
|
||||
"fingerprint": _hash_payload(ascendant_house_payload) if ascendant and house_positions else None,
|
||||
},
|
||||
"D9": {
|
||||
"status": "ok" if d9_houses or d9_planets else "missing",
|
||||
**d9_payload,
|
||||
"fingerprint": _hash_payload(d9_payload) if d9_houses or d9_planets else None,
|
||||
},
|
||||
"D10": {
|
||||
"status": "ok" if d10_houses or d10_planets else "missing",
|
||||
**d10_payload,
|
||||
"fingerprint": _hash_payload(d10_payload) if d10_houses or d10_planets else None,
|
||||
},
|
||||
"dasha_boundaries": {
|
||||
"status": "ok" if dasha_available else "missing",
|
||||
"boundary_count": _rectification_timeline_item_count(dasha_payload) if dasha_available else 0,
|
||||
"fingerprint": _hash_payload(dasha_payload) if dasha_available else None,
|
||||
},
|
||||
"kp_cusp_sub_lord": {
|
||||
"status": "unsupported_by_verified_official_interface",
|
||||
"reason": "The verified VedAstro interface exposes KP house-membership helpers, not an auditable cusp/sub-lord result.",
|
||||
},
|
||||
}
|
||||
required_statuses = [layers[name]["status"] for name in (
|
||||
"ascendant_house_boundaries", "D9", "D10", "dasha_boundaries"
|
||||
)]
|
||||
available = bool(snapshot.get("available")) and all(status == "ok" for status in required_statuses)
|
||||
source_metadata = snapshot.get("source_metadata") if isinstance(snapshot.get("source_metadata"), dict) else {}
|
||||
return {
|
||||
"available": available,
|
||||
"status": "ok" if available else "partial" if any(status == "ok" for status in required_statuses) else "blocked",
|
||||
"source": "vedastro_official",
|
||||
"operation": "rectification_minute_snapshot",
|
||||
"candidate_time": f'{int(case.get("hour") or 0):02d}:{int(case.get("minute") or 0):02d}',
|
||||
"layers": layers,
|
||||
"kp_cusp_sub_lord": layers["kp_cusp_sub_lord"],
|
||||
"source_metadata": {
|
||||
"response_hash": source_metadata.get("response_hash"),
|
||||
"official_snapshot_status": snapshot.get("status"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _run_range_scan_case(case: dict[str, Any], domain: str, start_date: str, end_date: str) -> dict[str, Any]:
|
||||
if domain not in SUPPORTED_RANGE_SCAN_DOMAINS:
|
||||
return {
|
||||
@@ -2776,17 +2896,6 @@ def _run_range_scan_case(case: dict[str, Any], domain: str, start_date: str, end
|
||||
"source_metadata": _source_metadata(endpoint),
|
||||
}
|
||||
|
||||
range_scan_enabled = os.environ.get("VEDASTRO_RANGE_SCAN_NETWORK_ENABLED", "1").strip().lower() in {"1", "true", "yes", "on"}
|
||||
if not range_scan_enabled:
|
||||
return {
|
||||
"backend": "vedastro_service_adapter_candidate",
|
||||
"available": False,
|
||||
"status": "network_execution_disabled",
|
||||
"reason": "VEDASTRO_RANGE_SCAN_NETWORK_ENABLED is disabled for the interactive chat path.",
|
||||
"request_preview": request_preview,
|
||||
"source_metadata": _source_metadata(endpoint),
|
||||
}
|
||||
|
||||
sample_dates = _iter_sample_dates(start_date, end_date)
|
||||
reports: list[dict[str, Any]] = []
|
||||
for sample_date in sample_dates:
|
||||
|
||||
Reference in New Issue
Block a user