Files
Jyotisha/scripts/minute_rectification_feature_facts_v4.py

287 lines
11 KiB
Python

#!/usr/bin/env python3
"""Lossless categorical fact atoms for adjacent-minute rectification diagnostics.
This is a shadow-only feature contract. It preserves the identities and
placements behind generic scoring rule names, but never changes candidate
scores or opens minute confirmation.
"""
from __future__ import annotations
import hashlib
import json
import math
from collections import defaultdict
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Final
from scripts import active_rectification_event_engine as engine
from scripts.active_rectification_events import LifeEvent, RectificationEventRequest
FEATURE_CONTRACT_VERSION: Final = "minute-rectification-feature-facts-v4-shadow"
def _canonical_hash(value: Any) -> str:
payload = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _arudha_keys(domain: str) -> tuple[str, ...]:
if domain == "relationship":
return ("A7", "UL")
if domain == "career":
return ("A10",)
return ()
def _candidate_facts(
request: RectificationEventRequest,
candidate_at: datetime,
) -> dict[str, Any]:
chart = engine.domain_calculation_service.compute_chart({
"year": candidate_at.year,
"month": candidate_at.month,
"day": candidate_at.day,
"hour": candidate_at.hour,
"minute": candidate_at.minute,
"lat": request["lat"],
"lon": request["lon"],
"tz": request["tz"],
"ayanamsa": engine.AYANAMSA,
"node_mode": engine.NODE_MODE,
})
planet_longitudes = {
name: float(value["lon"])
for name, value in chart.get("planets", {}).items()
if isinstance(value, dict) and isinstance(value.get("lon"), int | float)
}
ascendant_longitude = float(chart["ascendant"]["lon"])
ascendant_index = int(ascendant_longitude // 30)
vargas = engine.varga.calc_all_vargas(
planet_longitudes,
ascendant_longitude,
divisions=[2, 4, 9, 10, 24, 30],
)
d11 = engine._d11_chart(planet_longitudes, ascendant_longitude)
arudha_result = engine.jaimini.calc_arudha_padas(ascendant_index, planet_longitudes)
arudha = {
**(arudha_result.get("padas") or {}),
"UL": arudha_result.get("upapada") or {},
}
av = engine.ashtakavarga.calc_ashtakavarga(chart.get("planets", {}), ascendant_index)
av_scores = av.get("house_scores_full") or {}
event_facts: list[dict[str, Any]] = []
missing_layers: set[str] = set()
for event in request["events"]:
prefixes, target_houses = engine.DOMAIN_CONFIG[event["domain"]]
domain_vargas = [
d11 if prefix == "D11" else engine._varga_chart(vargas, prefix)
for prefix in prefixes
]
if any(item is None for item in domain_vargas):
missing_layers.update(prefixes)
continue
event_at = engine._event_datetime(event)
try:
vim = engine._active_vimshottari(
candidate_at.date().isoformat(), planet_longitudes["Moon"], event_at,
)
except (KeyError, TypeError, ValueError):
missing_layers.add("Vimshottari_MD_AD_PD")
continue
try:
narayana = engine._active_narayana(
ascendant_index, planet_longitudes, candidate_at, event_at,
)
except (KeyError, TypeError, ValueError):
missing_layers.add("Narayana_MD_AD")
continue
if narayana[0] is None or narayana[1] is None:
missing_layers.add("Narayana_MD_AD")
continue
active_lords = {"md": vim[0], "ad": vim[1], "pd": vim[2]}
varga_facts = []
for prefix, varga_chart in zip(prefixes, domain_vargas, strict=True):
assert varga_chart is not None
varga_facts.append({
"chart": prefix,
"ascendant_sign": (varga_chart.get("Ascendant") or {}).get("sign_idx"),
"active_lord_houses": {
level: engine._varga_house(varga_chart, lord)
for level, lord in active_lords.items()
},
})
relevant_arudha = {
key: (arudha.get(key) or {}).get("sign_idx")
for key in _arudha_keys(event["domain"])
}
shadbala_rules, _ = engine._shadbala_verified_components_auxiliary(
chart,
candidate_at.hour + candidate_at.minute / 60,
vim,
)
event_facts.append({
"event_id": event["id"],
"domain": event["domain"],
"vimshottari": active_lords,
"narayana": {"md_sign": narayana[0], "ad_sign": narayana[1]},
"d1": {
"ascendant_sign": ascendant_index,
"target_house_lords": sorted(engine._house_lords(ascendant_index, target_houses)),
"active_lord_houses": {
level: engine._planet_house(chart, lord)
for level, lord in active_lords.items()
},
},
"vargas": varga_facts,
"arudha_signs": relevant_arudha,
"ashtakavarga_target_house_scores": {
str(house): (av_scores.get(f"house_{house}") or {}).get("sav_score")
for house in target_houses
},
"verified_shadbala_state": sorted(shadbala_rules),
})
return {
"time": candidate_at.strftime("%H:%M"),
"feature_contract_version": FEATURE_CONTRACT_VERSION,
"event_facts": event_facts,
"missing_layers": sorted(missing_layers),
}
def build_feature_fact_rows(
request: RectificationEventRequest,
*,
candidates: Sequence[datetime] | None = None,
) -> list[dict[str, Any]]:
candidate_datetimes = list(candidates) if candidates is not None else engine._candidate_datetimes(request)
return [_candidate_facts(request, candidate) for candidate in candidate_datetimes]
def feature_fact_fingerprint(row: dict[str, Any]) -> str:
"""Hash facts only; candidate time and scorer output are deliberately excluded."""
return _canonical_hash({
"feature_contract_version": row["feature_contract_version"],
"event_facts": row["event_facts"],
"missing_layers": row["missing_layers"],
})
def analyze_feature_fact_rows(rows: Sequence[dict[str, Any]]) -> dict[str, Any]:
candidates = list(rows)
if not candidates:
return {
"scope": "minute_feature_fact_discriminability",
"feature_contract_version": FEATURE_CONTRACT_VERSION,
"candidate_count": 0,
"unique_feature_fingerprint_count": 0,
"indistinguishable_adjacent_pair_count": 0,
"status": "blocked_no_candidates",
"shadow_only": True,
}
fingerprints = {row["time"]: feature_fact_fingerprint(row) for row in candidates}
classes: dict[str, list[str]] = defaultdict(list)
for row in candidates:
classes[fingerprints[row["time"]]].append(row["time"])
transitions = []
for previous, current in zip(candidates, candidates[1:]):
previous_events = {item["event_id"]: item for item in previous["event_facts"]}
current_events = {item["event_id"]: item for item in current["event_facts"]}
changed = sorted(
event_id
for event_id in set(previous_events) | set(current_events)
if previous_events.get(event_id) != current_events.get(event_id)
)
transitions.append({
"between": [previous["time"], current["time"]],
"feature_changed": fingerprints[previous["time"]] != fingerprints[current["time"]],
"changed_event_ids": changed,
})
unique_count = len(classes)
return {
"scope": "minute_feature_fact_discriminability",
"feature_contract_version": FEATURE_CONTRACT_VERSION,
"candidate_count": len(candidates),
"unique_feature_fingerprint_count": unique_count,
"distinguishable_candidate_ratio": round(unique_count / len(candidates), 4),
"equivalence_classes": [
{"feature_fingerprint": fingerprint, "candidate_times": times, "size": len(times)}
for fingerprint, times in sorted(classes.items(), key=lambda item: item[1][0])
],
"adjacent_transitions": transitions,
"indistinguishable_adjacent_pair_count": sum(
not item["feature_changed"] for item in transitions
),
"status": "facts_have_candidate_differences" if unique_count > 1 else "blocked_fact_equivalent_range",
"shadow_only": True,
"may_affect_candidate_score": False,
"boundary": "Fact atoms may select a discriminating question, but cannot rank or confirm a minute without a separately frozen evidence rule.",
}
def build_fact_difference_opportunities(rows: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
"""Partition candidates only where one event's actual fact atoms differ.
The output deliberately contains no user-facing claim and no candidate
scores. It is an auditable input for a later question-planning layer.
"""
candidates = list(rows)
if not candidates:
return []
facts_by_event: dict[str, dict[str, tuple[str, dict[str, Any]]]] = defaultdict(dict)
domains: dict[str, str] = {}
for row in candidates:
for fact in row["event_facts"]:
event_id = fact["event_id"]
domains[event_id] = fact["domain"]
material = {
key: value for key, value in fact.items()
if key not in {"event_id", "domain"}
}
facts_by_event[event_id][row["time"]] = (_canonical_hash(material), material)
opportunities = []
for event_id, by_time in sorted(facts_by_event.items()):
groups: dict[str, list[str]] = defaultdict(list)
material_by_hash: dict[str, dict[str, Any]] = {}
for candidate_time, (fingerprint, material) in by_time.items():
groups[fingerprint].append(candidate_time)
material_by_hash[fingerprint] = material
if len(groups) < 2:
continue
probabilities = [len(times) / len(by_time) for times in groups.values()]
information_gain = (
-sum(value * math.log(value) for value in probabilities) / math.log(len(groups))
if len(groups) > 1 else 0.0
)
ordered = sorted(groups.items(), key=lambda item: item[1][0])
question_ready = 2 <= len(ordered) <= 4
opportunities.append({
"opportunity_id": _canonical_hash({
"version": FEATURE_CONTRACT_VERSION,
"event_id": event_id,
"partitions": ordered,
}),
"event_id": event_id,
"domain": domains[event_id],
"estimated_information_gain": round(information_gain, 6),
"partitions": [
{
"fact_fingerprint": fingerprint,
"candidate_times": times,
"fact_atoms": material_by_hash[fingerprint],
}
for fingerprint, times in ordered
],
"question_ready": question_ready,
"requires_partition_coalescing": not question_ready,
"shadow_only": True,
"may_score_candidates": False,
})
return sorted(
opportunities,
key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"]),
)