814c924e4a
Keep askable cards after exhaustion, explain each probe, read the adopted credible range in reports and chat, and compare declared periods before the minute grid when the clock is unknown. Co-authored-by: Cursor <cursoragent@cursor.com>
324 lines
13 KiB
Python
324 lines
13 KiB
Python
"""Build a read-only sensitivity profile for an unresolved birth-time window."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
from datetime import datetime
|
|
from hashlib import sha256
|
|
import json
|
|
import re
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
SCHEMA_VERSION = "jyotish.flexible_birth_time_profile.v1"
|
|
MAX_CANDIDATE_MINUTES = 31
|
|
_CLOCK_RE = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
|
_PROHIBITED_AUTHORITY_FIELDS = frozenset({
|
|
"approved_birth_time",
|
|
"final_birth_time",
|
|
"winner",
|
|
"approval",
|
|
"approval_authority",
|
|
})
|
|
_PROHIBITED_SOURCE_STATUSES = frozenset({"approved", "confirmed"})
|
|
|
|
|
|
class FlexibleBirthTimeProfileError(ValueError):
|
|
"""Raised when a candidate window cannot be represented safely."""
|
|
|
|
|
|
def build_flexible_birth_time_profile(
|
|
candidates: Sequence[Mapping[str, Any]],
|
|
*,
|
|
source_reference: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
_reject_candidate_window_authority(candidates)
|
|
rows = _normalize_candidates(candidates)
|
|
source = _normalize_source_reference(source_reference)
|
|
stable: dict[str, Any] = {}
|
|
sensitive: dict[str, dict[str, Any]] = {}
|
|
for key in sorted({key for row in rows for key in row["evidence"]}):
|
|
values = {row["candidate_time"]: row["evidence"].get(key) for row in rows}
|
|
if len({_canonical(value) for value in values.values()}) == 1:
|
|
stable[key] = deepcopy(next(iter(values.values())))
|
|
else:
|
|
sensitive[key] = deepcopy(values)
|
|
times = [row["candidate_time"] for row in rows]
|
|
profile_id = _profile_id(times, source["review_id"])
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"flexible_profile_id": profile_id,
|
|
"birth_time_window": {
|
|
"start_time": times[0],
|
|
"end_time": times[-1],
|
|
"candidate_count": len(times),
|
|
"candidate_times": times,
|
|
},
|
|
"candidate_references": [
|
|
{"candidate_id": row["candidate_id"], "candidate_time": row["candidate_time"]}
|
|
for row in rows
|
|
],
|
|
"stable_evidence": stable,
|
|
"sensitive_evidence": sensitive,
|
|
"source_reference": source,
|
|
"trace": [
|
|
{"kind": "flexible_birth_time_profile", "reference": profile_id},
|
|
{"kind": "candidate_window", "reference": source["review_id"]},
|
|
*(
|
|
{"kind": "candidate", "reference": f"candidate://{row['candidate_id']}"}
|
|
for row in rows
|
|
),
|
|
],
|
|
"status": "candidate_window_only",
|
|
"claim_boundary": (
|
|
"Read-only candidate-window comparison. It cannot select or confirm a birth minute, "
|
|
"replace chart identity, or grant authority to a candidate chart."
|
|
),
|
|
}
|
|
|
|
|
|
def build_flexible_birth_time_profile_from_window(
|
|
*,
|
|
birth_date: str,
|
|
start_time: str,
|
|
end_time: str,
|
|
candidate_times: Sequence[str],
|
|
lat: float,
|
|
lon: float,
|
|
tz: float,
|
|
ayanamsa: str = "raman",
|
|
node_mode: str = "mean",
|
|
source_reference: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
start, end = _parse_window(birth_date, start_time, end_time)
|
|
normalized_times = _normalize_candidate_times(birth_date, start, end, candidate_times)
|
|
candidates = [
|
|
_candidate_from_recast(
|
|
candidate_at=value,
|
|
recast=_recast_candidate_layers(
|
|
value, lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa, node_mode=node_mode,
|
|
),
|
|
ayanamsa=ayanamsa,
|
|
node_mode=node_mode,
|
|
)
|
|
for value in normalized_times
|
|
]
|
|
profile = build_flexible_birth_time_profile(candidates, source_reference=source_reference)
|
|
profile["calculation_profile"] = {
|
|
"ayanamsa": ayanamsa,
|
|
"node_mode": node_mode,
|
|
"coordinate_mode": "explicit_lat_lon_tz",
|
|
"candidate_recast": "native_domain_calculation_service",
|
|
}
|
|
return profile
|
|
|
|
|
|
def _recast_candidate_layers(
|
|
candidate: datetime,
|
|
*,
|
|
lat: float,
|
|
lon: float,
|
|
tz: float,
|
|
ayanamsa: str,
|
|
node_mode: str,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
import domain_calculation_service
|
|
import jaimini
|
|
import kp_system
|
|
import varga
|
|
except ModuleNotFoundError: # pragma: no cover - package import
|
|
from scripts import domain_calculation_service, jaimini, kp_system, varga
|
|
|
|
chart = domain_calculation_service.compute_chart({
|
|
"year": candidate.year,
|
|
"month": candidate.month,
|
|
"day": candidate.day,
|
|
"hour": candidate.hour,
|
|
"minute": candidate.minute,
|
|
"second": candidate.second,
|
|
"lat": lat,
|
|
"lon": lon,
|
|
"tz": tz,
|
|
"ayanamsa": ayanamsa,
|
|
"node_mode": node_mode,
|
|
})
|
|
planets = {
|
|
name: row["lon"]
|
|
for name, row in (chart.get("planets") or {}).items()
|
|
if name in {"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"}
|
|
}
|
|
ascendant = chart.get("ascendant") or {}
|
|
asc_lon = float(ascendant["lon"])
|
|
divisions = [2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 16, 24, 30, 40, 45, 60]
|
|
vargas = varga.calc_all_vargas(planets, asc_lon, divisions=divisions)
|
|
arudha = jaimini.calc_arudha_padas(int(asc_lon // 30), planets)
|
|
padas = arudha.get("padas") or {}
|
|
kp_cusps: dict[str, Any] = {}
|
|
for house_key in ("house_1", "house_4", "house_7", "house_10"):
|
|
degree = ((chart.get("houses") or {}).get(house_key) or {}).get("cusp_degree")
|
|
if degree is None:
|
|
continue
|
|
lords = kp_system.get_kp_lords(float(degree))
|
|
kp_cusps[house_key] = {
|
|
"sign": lords.get("sign"),
|
|
"nakshatra_lord": lords.get("nakshatra_lord"),
|
|
"sub_lord": lords.get("sub_lord"),
|
|
}
|
|
return {
|
|
"ascendant": ascendant,
|
|
"varga_lagna": {key: value.get("Ascendant") or {} for key, value in vargas.items()},
|
|
"arudha": {"A7": padas.get("A7") or {}, "A10": padas.get("A10") or {}, "UL": arudha.get("upapada") or {}},
|
|
"kp_cusps": kp_cusps,
|
|
}
|
|
|
|
|
|
def _candidate_from_recast(
|
|
*,
|
|
candidate_at: datetime,
|
|
recast: Mapping[str, Any],
|
|
ayanamsa: str,
|
|
node_mode: str,
|
|
) -> dict[str, Any]:
|
|
evidence: dict[str, Any] = {"D1.ascendant": (recast.get("ascendant") or {}).get("sign")}
|
|
for key, value in (recast.get("varga_lagna") or {}).items():
|
|
if isinstance(key, str) and key.startswith("D") and isinstance(value, Mapping):
|
|
evidence[f"{key.split('_', 1)[0]}.ascendant"] = value.get("sign")
|
|
for key in ("A7", "A10", "UL"):
|
|
value = (recast.get("arudha") or {}).get(key)
|
|
if isinstance(value, Mapping):
|
|
evidence[f"arudha.{key}"] = value.get("sign")
|
|
evidence["KP.cusp_observation"] = recast.get("kp_cusps") or {}
|
|
candidate_time = candidate_at.strftime("%H:%M")
|
|
candidate_id = f"candidate-{candidate_time.replace(':', '')}"
|
|
return {
|
|
"candidate_id": candidate_id,
|
|
"candidate_time": candidate_time,
|
|
"evidence": evidence,
|
|
"trace": [
|
|
{"kind": "candidate_chart_recast", "reference": f"candidate-chart://{candidate_id}"},
|
|
{"kind": "calculation_profile", "reference": f"ayanamsa://{ayanamsa}/node/{node_mode}"},
|
|
],
|
|
}
|
|
|
|
|
|
def _parse_window(birth_date: str, start_time: str, end_time: str) -> tuple[datetime, datetime]:
|
|
if not _is_hh_mm(start_time) or not _is_hh_mm(end_time):
|
|
raise FlexibleBirthTimeProfileError("birth_date_or_candidate_time_invalid")
|
|
try:
|
|
start = datetime.strptime(f"{birth_date} {start_time}", "%Y-%m-%d %H:%M")
|
|
end = datetime.strptime(f"{birth_date} {end_time}", "%Y-%m-%d %H:%M")
|
|
except (TypeError, ValueError) as exc:
|
|
raise FlexibleBirthTimeProfileError("birth_date_or_candidate_time_invalid") from exc
|
|
if end < start:
|
|
raise FlexibleBirthTimeProfileError("candidate_window_must_not_cross_midnight")
|
|
return start, end
|
|
|
|
|
|
def _normalize_candidate_times(
|
|
birth_date: str,
|
|
start: datetime,
|
|
end: datetime,
|
|
candidate_times: Sequence[str],
|
|
) -> list[datetime]:
|
|
if isinstance(candidate_times, (str, bytes)) or not isinstance(candidate_times, Sequence):
|
|
raise FlexibleBirthTimeProfileError("candidate_times_required")
|
|
if len(candidate_times) < 2 or len(candidate_times) > MAX_CANDIDATE_MINUTES:
|
|
raise FlexibleBirthTimeProfileError("candidate_count_must_be_two_to_thirty_one")
|
|
values: list[datetime] = []
|
|
for raw in candidate_times:
|
|
if not _is_hh_mm(raw):
|
|
raise FlexibleBirthTimeProfileError("candidate_time_invalid")
|
|
try:
|
|
value = datetime.strptime(f"{birth_date} {raw}", "%Y-%m-%d %H:%M")
|
|
except (TypeError, ValueError) as exc:
|
|
raise FlexibleBirthTimeProfileError("candidate_time_invalid") from exc
|
|
if value < start or value > end:
|
|
raise FlexibleBirthTimeProfileError("candidate_time_outside_window")
|
|
values.append(value)
|
|
if len(set(values)) != len(values):
|
|
raise FlexibleBirthTimeProfileError("candidate_times_must_be_unique")
|
|
return sorted(values)
|
|
|
|
|
|
def _normalize_candidates(candidates: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
|
if isinstance(candidates, (str, bytes)) or not isinstance(candidates, Sequence):
|
|
raise FlexibleBirthTimeProfileError("candidates_required")
|
|
if len(candidates) < 2 or len(candidates) > MAX_CANDIDATE_MINUTES:
|
|
raise FlexibleBirthTimeProfileError("candidate_count_must_be_two_to_thirty_one")
|
|
rows: list[dict[str, Any]] = []
|
|
for candidate in candidates:
|
|
if not isinstance(candidate, Mapping):
|
|
raise FlexibleBirthTimeProfileError("candidate_must_be_mapping")
|
|
candidate_id = candidate.get("candidate_id")
|
|
candidate_time = candidate.get("candidate_time")
|
|
evidence = candidate.get("evidence")
|
|
trace = candidate.get("trace")
|
|
if not isinstance(candidate_id, str) or not candidate_id:
|
|
raise FlexibleBirthTimeProfileError("candidate_id_required")
|
|
if not _is_hh_mm(candidate_time):
|
|
raise FlexibleBirthTimeProfileError("candidate_time_required")
|
|
if not isinstance(evidence, Mapping) or not evidence:
|
|
raise FlexibleBirthTimeProfileError("candidate_evidence_required")
|
|
if not isinstance(trace, list) or not trace:
|
|
raise FlexibleBirthTimeProfileError("candidate_trace_required")
|
|
rows.append({"candidate_id": candidate_id, "candidate_time": candidate_time, "evidence": dict(evidence), "trace": trace})
|
|
if len({row["candidate_id"] for row in rows}) != len(rows):
|
|
raise FlexibleBirthTimeProfileError("candidate_ids_must_be_unique")
|
|
if len({row["candidate_time"] for row in rows}) != len(rows):
|
|
raise FlexibleBirthTimeProfileError("candidate_times_must_be_unique")
|
|
return sorted(rows, key=lambda row: row["candidate_time"])
|
|
|
|
|
|
def _normalize_source_reference(value: Mapping[str, Any]) -> dict[str, str]:
|
|
if not isinstance(value, Mapping):
|
|
raise FlexibleBirthTimeProfileError("source_reference_must_be_mapping")
|
|
_reject_candidate_window_authority(value)
|
|
status = value.get("status")
|
|
if isinstance(status, str) and status.strip().lower() in _PROHIBITED_SOURCE_STATUSES:
|
|
raise FlexibleBirthTimeProfileError("approved_or_confirmed_source_reference_forbidden")
|
|
review_id = value.get("review_id")
|
|
if not isinstance(review_id, str) or not review_id:
|
|
raise FlexibleBirthTimeProfileError("source_review_id_required")
|
|
return {"review_id": review_id, "status": "review_required"}
|
|
|
|
|
|
def _profile_id(candidate_times: Sequence[str], review_id: str) -> str:
|
|
digest = sha256(repr((tuple(candidate_times), review_id)).encode("utf-8")).hexdigest()[:24]
|
|
return f"flexible-birth-time://{digest}"
|
|
|
|
|
|
def _canonical(value: Any) -> str:
|
|
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str)
|
|
|
|
|
|
def _is_hh_mm(value: Any) -> bool:
|
|
return isinstance(value, str) and _CLOCK_RE.fullmatch(value) is not None
|
|
|
|
|
|
def _candidate_window_authority_violation(value: Any, path: str = "$") -> str | None:
|
|
if isinstance(value, Mapping):
|
|
for raw_key, item in value.items():
|
|
key = str(raw_key).strip().lower()
|
|
child_path = f"{path}.{raw_key}"
|
|
if key in _PROHIBITED_AUTHORITY_FIELDS:
|
|
return child_path
|
|
if key == "source_reference" and isinstance(item, Mapping):
|
|
status = item.get("status")
|
|
if isinstance(status, str) and status.strip().lower() in _PROHIBITED_SOURCE_STATUSES:
|
|
return f"{child_path}.status"
|
|
violation = _candidate_window_authority_violation(item, child_path)
|
|
if violation:
|
|
return violation
|
|
elif isinstance(value, (list, tuple)):
|
|
for index, item in enumerate(value):
|
|
violation = _candidate_window_authority_violation(item, f"{path}[{index}]")
|
|
if violation:
|
|
return violation
|
|
return None
|
|
|
|
|
|
def _reject_candidate_window_authority(value: Any) -> None:
|
|
violation = _candidate_window_authority_violation(value)
|
|
if violation:
|
|
raise FlexibleBirthTimeProfileError(f"candidate_window_authority_forbidden:{violation}")
|