Files
Jyotisha/scripts/flexible_birth_time_profile.py
T
jesse-ux b85c4a686a
Independent Staging Quality Gate / validate (push) Successful in 13m27s
Independent Staging Quality Gate / publish (push) Failing after 1h0m1s
fix(rectification): anchor candidate windows to civil dates across midnight
Carry explicit local date intervals instead of inferring the day from clock
order. Cluster width, delivery, adoption, and reports keep the actual civil
date; adopted date is stored separately from the reported birth_date.

Algorithm identity is scoring-9 / spec-v5. Scoring weights, confirmation
thresholds, and Skill version are unchanged. Isolated Linux final-3 gates
passed; four pre-existing Python failures remain. This is not a production
release.
2026-09-21 02:55:00 +08:00

344 lines
15 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],
candidate_intervals: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
if candidate_intervals is None:
start, end = _parse_window(birth_date, start_time, end_time)
normalized_times = _normalize_candidate_times(birth_date, start, end, candidate_times)
else:
from scripts.rectification.candidate_window import enumerate_candidate_window
moments = enumerate_candidate_window({"birth_date": birth_date, "start_time": start_time,
"end_time": end_time, "candidate_intervals": candidate_intervals})
by_clock = {value.strftime("%H:%M"): value for value in moments}
if not 2 <= len(candidate_times) <= MAX_CANDIDATE_MINUTES or len(set(candidate_times)) != len(candidate_times):
raise FlexibleBirthTimeProfileError("candidate_count_must_be_two_to_thirty_one")
if any(value not in by_clock for value in candidate_times):
raise FlexibleBirthTimeProfileError("candidate_time_outside_window")
normalized_times = sorted(by_clock[value] for value in 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)
if candidate_intervals is not None:
# Keep civil chronology and declared gaps instead of the legacy clock-sorted envelope.
clocks = [value.strftime("%H:%M") for value in normalized_times]
profile["birth_time_window"].update({"start_time": start_time, "end_time": end_time,
"candidate_times": clocks, "candidate_intervals": deepcopy(candidate_intervals),
"candidate_datetimes": [value.isoformat(timespec="minutes") for value in normalized_times]})
refs = {row["candidate_time"]: row for row in profile["candidate_references"]}
profile["candidate_references"] = [{**refs[value.strftime("%H:%M")], "candidate_date": value.date().isoformat()} for value in normalized_times]
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}")