feat: complete verifiable birth-time rectification flow
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Event-specific fact ranker for minute rectification development.
|
||||
|
||||
The ranker consumes the lossless v4 fact contract. It is deliberately shadow
|
||||
only: it can be evaluated and frozen, but it cannot apply a birth minute until
|
||||
an independent public-AA holdout passes the release gates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from typing import Any, Final
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from scripts.active_rectification_event_engine import DOMAIN_CONFIG
|
||||
from scripts.active_rectification_events import (
|
||||
CandidateScoreRow,
|
||||
LifeEvent,
|
||||
build_stability_diagnostics,
|
||||
precision_weight,
|
||||
)
|
||||
from scripts.minute_candidate_discriminability import analyze_candidate_rows
|
||||
|
||||
ALGORITHM_VERSION: Final = "birth-time-event-fact-ranker-v4-shadow"
|
||||
LAYER_FAMILIES: Final = (
|
||||
"vimshottari_d1",
|
||||
"varga",
|
||||
"narayana",
|
||||
"arudha",
|
||||
"ashtakavarga",
|
||||
"shadbala",
|
||||
)
|
||||
VIM_WEIGHTS: Final = {"md": 2.0, "ad": 1.5, "pd": 0.75}
|
||||
LAYER_WEIGHTS: Final = {
|
||||
"vimshottari_d1": 0.20,
|
||||
"varga": 0.30,
|
||||
"narayana": 0.25,
|
||||
"arudha": 0.10,
|
||||
"ashtakavarga": 0.075,
|
||||
"shadbala": 0.075,
|
||||
}
|
||||
|
||||
|
||||
def _relative_house(sign_index: int, ascendant_index: int) -> int:
|
||||
return (sign_index - ascendant_index) % 12 + 1
|
||||
|
||||
|
||||
def _event_layer_points(fact: Mapping[str, Any]) -> dict[str, float]:
|
||||
domain = str(fact["domain"])
|
||||
target_houses = set(DOMAIN_CONFIG[domain][1])
|
||||
d1 = fact["d1"]
|
||||
active_lords = fact["vimshottari"]
|
||||
target_lords = set(d1["target_house_lords"])
|
||||
|
||||
vimshottari_d1 = 0.0
|
||||
for level, weight in VIM_WEIGHTS.items():
|
||||
lord = active_lords[level]
|
||||
if d1["active_lord_houses"].get(level) in target_houses:
|
||||
vimshottari_d1 += weight
|
||||
if lord in target_lords:
|
||||
vimshottari_d1 += weight
|
||||
|
||||
vargas = list(fact["vargas"])
|
||||
varga_points = 0.0
|
||||
if vargas:
|
||||
for level, weight in VIM_WEIGHTS.items():
|
||||
matches = sum(
|
||||
chart["active_lord_houses"].get(level) in target_houses
|
||||
for chart in vargas
|
||||
)
|
||||
varga_points += weight * matches / len(vargas)
|
||||
|
||||
ascendant = int(d1["ascendant_sign"])
|
||||
narayana = fact["narayana"]
|
||||
narayana_points = 0.0
|
||||
for key, weight in (("md_sign", 2.0), ("ad_sign", 1.0)):
|
||||
sign = narayana.get(key)
|
||||
if isinstance(sign, int) and _relative_house(sign, ascendant) in target_houses:
|
||||
narayana_points += weight
|
||||
|
||||
arudha_signs = {
|
||||
value for value in fact["arudha_signs"].values() if isinstance(value, int)
|
||||
}
|
||||
arudha_points = 0.0
|
||||
if arudha_signs:
|
||||
if narayana.get("md_sign") in arudha_signs:
|
||||
arudha_points += 1.5
|
||||
if narayana.get("ad_sign") in arudha_signs:
|
||||
arudha_points += 0.75
|
||||
|
||||
av_values = [
|
||||
float(value)
|
||||
for value in fact["ashtakavarga_target_house_scores"].values()
|
||||
if isinstance(value, int | float)
|
||||
]
|
||||
av_points = sum(av_values) / len(av_values) / 40 if av_values else 0.0
|
||||
|
||||
shadbala_points = 0.0
|
||||
shadbala_states = set(fact["verified_shadbala_state"])
|
||||
if "shadbala_sthana_drik_naisargika_support_auxiliary" in shadbala_states:
|
||||
shadbala_points = 0.25
|
||||
elif "shadbala_sthana_drik_naisargika_pressure_auxiliary" in shadbala_states:
|
||||
shadbala_points = -0.125
|
||||
|
||||
return {
|
||||
"vimshottari_d1": vimshottari_d1,
|
||||
"varga": varga_points,
|
||||
"narayana": narayana_points,
|
||||
"arudha": arudha_points,
|
||||
"ashtakavarga": av_points,
|
||||
"shadbala": shadbala_points,
|
||||
}
|
||||
|
||||
|
||||
def _percentile_ranks(values: Mapping[str, float]) -> dict[str, float]:
|
||||
if not values or len(set(values.values())) == 1:
|
||||
return {candidate: 0.0 for candidate in values}
|
||||
count = len(values)
|
||||
return {
|
||||
candidate: round((
|
||||
sum(other < value for other in values.values())
|
||||
+ 0.5 * (sum(other == value for other in values.values()) - 1)
|
||||
) / (count - 1), 6)
|
||||
for candidate, value in values.items()
|
||||
}
|
||||
|
||||
|
||||
def rank_fact_rows(
|
||||
fact_rows: Sequence[dict[str, Any]],
|
||||
events: Sequence[LifeEvent],
|
||||
*,
|
||||
excluded_layers: Iterable[str] = (),
|
||||
) -> tuple[list[CandidateScoreRow], dict[str, Any]]:
|
||||
rows = list(fact_rows)
|
||||
event_list = list(events)
|
||||
excluded = set(excluded_layers)
|
||||
unknown = excluded - set(LAYER_FAMILIES)
|
||||
if unknown:
|
||||
raise ValueError(f"unsupported fact layer: {sorted(unknown)[0]}")
|
||||
times = [row["time"] for row in rows]
|
||||
by_time = {
|
||||
row["time"]: {fact["event_id"]: fact for fact in row["event_facts"]}
|
||||
for row in rows
|
||||
}
|
||||
raw_event_scores: dict[str, dict[str, float]] = {}
|
||||
event_layer_ranks: dict[str, dict[str, dict[str, float]]] = {}
|
||||
event_layer_points: dict[str, dict[str, dict[str, float]]] = {}
|
||||
for event in event_list:
|
||||
event_id = event["id"]
|
||||
event_layer_points[event_id] = {}
|
||||
for candidate_time in times:
|
||||
fact = by_time[candidate_time].get(event_id)
|
||||
layers = _event_layer_points(fact) if fact else {key: 0.0 for key in LAYER_FAMILIES}
|
||||
event_layer_points[event_id][candidate_time] = layers
|
||||
event_layer_ranks[event_id] = {
|
||||
layer: _percentile_ranks({
|
||||
candidate_time: event_layer_points[event_id][candidate_time][layer]
|
||||
for candidate_time in times
|
||||
})
|
||||
for layer in LAYER_FAMILIES if layer not in excluded
|
||||
}
|
||||
active_weight = sum(
|
||||
weight for layer, weight in LAYER_WEIGHTS.items()
|
||||
if layer not in excluded
|
||||
and any(event_layer_ranks[event_id][layer].values())
|
||||
)
|
||||
raw_event_scores[event_id] = {
|
||||
candidate_time: round(
|
||||
sum(
|
||||
LAYER_WEIGHTS[layer] * ranks[candidate_time]
|
||||
for layer, ranks in event_layer_ranks[event_id].items()
|
||||
) / active_weight if active_weight else 0.0,
|
||||
6,
|
||||
)
|
||||
for candidate_time in times
|
||||
}
|
||||
event_ranks = raw_event_scores
|
||||
event_ids_by_domain: dict[str, list[str]] = defaultdict(list)
|
||||
for event in event_list:
|
||||
event_ids_by_domain[event["domain"]].append(event["id"])
|
||||
|
||||
ranked: list[CandidateScoreRow] = []
|
||||
domain_scores_by_time: dict[str, dict[str, float]] = {}
|
||||
for candidate_time in times:
|
||||
domain_scores = {
|
||||
domain: round(
|
||||
sum(
|
||||
event_ranks[event_id][candidate_time]
|
||||
* precision_weight(next(
|
||||
event["precision"] for event in event_list if event["id"] == event_id
|
||||
))
|
||||
for event_id in event_ids
|
||||
) / sum(
|
||||
precision_weight(next(
|
||||
event["precision"] for event in event_list if event["id"] == event_id
|
||||
))
|
||||
for event_id in event_ids
|
||||
),
|
||||
6,
|
||||
)
|
||||
for domain, event_ids in event_ids_by_domain.items()
|
||||
}
|
||||
domain_scores_by_time[candidate_time] = domain_scores
|
||||
overall = sum(domain_scores.values()) / len(domain_scores) if domain_scores else 0.0
|
||||
evidence = [{
|
||||
"event_id": event["id"],
|
||||
"domain": event["domain"],
|
||||
"candidate_time": candidate_time,
|
||||
"rule_ids": [
|
||||
f"fact_v4_{layer}"
|
||||
for layer, points in event_layer_points[event["id"]][candidate_time].items()
|
||||
if layer not in excluded and points != 0
|
||||
] or ["fact_v4_no_candidate_relative_support"],
|
||||
"points": event_ranks[event["id"]][candidate_time],
|
||||
} for event in event_list]
|
||||
ranked.append({
|
||||
"time": candidate_time,
|
||||
"score": round(overall * 100, 6),
|
||||
"evidence": evidence,
|
||||
"missing_layers": list(next(
|
||||
(row["missing_layers"] for row in rows if row["time"] == candidate_time),
|
||||
[],
|
||||
)),
|
||||
})
|
||||
return ranked, {
|
||||
"schema_version": "minute-event-fact-ranker-v4",
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
"excluded_layers": sorted(excluded),
|
||||
"event_raw_scores": raw_event_scores,
|
||||
"event_layer_ranks": event_layer_ranks,
|
||||
"layer_weights": LAYER_WEIGHTS,
|
||||
"event_percentile_ranks": event_ranks,
|
||||
"candidate_domain_scores": domain_scores_by_time,
|
||||
"aggregation": "fixed_fact_rules_then_tie_aware_event_percentile_then_equal_domain_mean",
|
||||
"shadow_only": True,
|
||||
}
|
||||
|
||||
|
||||
def _winning_segment(rows: Sequence[CandidateScoreRow]) -> dict[str, Any] | None:
|
||||
if not rows:
|
||||
return None
|
||||
top = max(row["score"] for row in rows)
|
||||
leaders = [row for row in rows if row["score"] == top]
|
||||
segments: list[list[CandidateScoreRow]] = []
|
||||
for row in leaders:
|
||||
if segments:
|
||||
previous = segments[-1][-1]["time"]
|
||||
previous_minute = int(previous[:2]) * 60 + int(previous[3:])
|
||||
current_minute = int(row["time"][:2]) * 60 + int(row["time"][3:])
|
||||
if (current_minute - previous_minute) % 1_440 == 1:
|
||||
segments[-1].append(row)
|
||||
continue
|
||||
segments.append([row])
|
||||
if len(segments) != 1:
|
||||
return None
|
||||
winner = segments[0]
|
||||
return {
|
||||
"start_time": winner[0]["time"],
|
||||
"end_time": winner[-1]["time"],
|
||||
"representative_time": winner[(len(winner) - 1) // 2]["time"],
|
||||
"width_minutes": len(winner),
|
||||
}
|
||||
|
||||
|
||||
def _leave_one_event_out(
|
||||
fact_rows: Sequence[dict[str, Any]], events: Sequence[LifeEvent], full_leader: str | None,
|
||||
) -> dict[str, Any]:
|
||||
runs = []
|
||||
passed = full_leader is not None and len(events) >= 2
|
||||
for removed in events:
|
||||
remaining = [event for event in events if event["id"] != removed["id"]]
|
||||
rescored, _ = rank_fact_rows(fact_rows, remaining)
|
||||
segment = _winning_segment(rescored)
|
||||
retained = bool(
|
||||
full_leader and segment and segment["width_minutes"] == 1
|
||||
and segment["representative_time"] == full_leader
|
||||
)
|
||||
passed = passed and retained
|
||||
runs.append({
|
||||
"removed_event_id": removed["id"],
|
||||
"winning_segment": segment,
|
||||
"original_unique_leader_retained": retained,
|
||||
})
|
||||
return {"status": "pass" if passed else "fail", "runs": runs}
|
||||
|
||||
|
||||
def _ablation_report(
|
||||
fact_rows: Sequence[dict[str, Any]], events: Sequence[LifeEvent], full_leader: str | None,
|
||||
) -> dict[str, Any]:
|
||||
runs = []
|
||||
for layer in LAYER_FAMILIES:
|
||||
rescored, _ = rank_fact_rows(fact_rows, events, excluded_layers={layer})
|
||||
segment = _winning_segment(rescored)
|
||||
runs.append({
|
||||
"removed_layer": layer,
|
||||
"winning_segment": segment,
|
||||
"full_unique_leader_retained": bool(
|
||||
full_leader and segment and segment["width_minutes"] == 1
|
||||
and segment["representative_time"] == full_leader
|
||||
),
|
||||
})
|
||||
return {"scope": "fact_layer_ablation", "runs": runs}
|
||||
|
||||
|
||||
def score_fact_ranker_v4(
|
||||
fact_rows: Sequence[dict[str, Any]], events: Sequence[LifeEvent],
|
||||
) -> dict[str, Any]:
|
||||
rows, contract = rank_fact_rows(fact_rows, events)
|
||||
segment = _winning_segment(rows)
|
||||
full_leader = (
|
||||
segment["representative_time"]
|
||||
if segment and segment["width_minutes"] == 1 else None
|
||||
)
|
||||
neighbor = build_stability_diagnostics(rows, winning_segment=segment)
|
||||
leave_one_out = _leave_one_event_out(fact_rows, events, full_leader)
|
||||
ablation = _ablation_report(fact_rows, events, full_leader)
|
||||
discriminability = analyze_candidate_rows(rows, ranking_rows=rows)
|
||||
scores = sorted({row["score"] for row in rows}, reverse=True)
|
||||
top = scores[0] if scores else 0.0
|
||||
second = scores[1] if len(scores) > 1 else top
|
||||
domains = {event["domain"] for event in events}
|
||||
missing = sorted({layer for row in rows for layer in row["missing_layers"]})
|
||||
reasons = []
|
||||
if not full_leader:
|
||||
reasons.append("unique_minute_not_found")
|
||||
if len(events) < 4:
|
||||
reasons.append("insufficient_events")
|
||||
if len(domains) < 3:
|
||||
reasons.append("insufficient_domains")
|
||||
if missing:
|
||||
reasons.append("missing_mandatory_layers")
|
||||
if not neighbor["all_required_passed"]:
|
||||
reasons.append("neighbor_stability_not_passed")
|
||||
if leave_one_out["status"] != "pass":
|
||||
reasons.append("leave_one_event_out_not_passed")
|
||||
if not discriminability["top_candidate_feature_unique"]:
|
||||
reasons.append("top_candidate_feature_not_unique")
|
||||
reasons.append("fact_ranker_v4_holdout_not_ready")
|
||||
fingerprint = hashlib.sha256(json.dumps(
|
||||
{"rows": rows, "events": list(events), "version": ALGORITHM_VERSION},
|
||||
ensure_ascii=True, sort_keys=True, separators=(",", ":"),
|
||||
).encode()).hexdigest()
|
||||
return {
|
||||
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")),
|
||||
"confidence": "high" if len(reasons) == 1 else "low",
|
||||
"can_apply": False,
|
||||
"winning_segment": segment,
|
||||
"event_count": len(events),
|
||||
"domain_count": len(domains),
|
||||
"top_score": top,
|
||||
"second_score": second,
|
||||
"margin_percent": round((top - second) / max(abs(top), 1.0) * 100, 2),
|
||||
"reasons": reasons,
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
"missing_layers": missing,
|
||||
"fact_ranking_contract": contract,
|
||||
"stability_diagnostics": {
|
||||
"neighbor_stability": neighbor,
|
||||
"leave_one_event_out": leave_one_out,
|
||||
"candidate_discriminability": discriminability,
|
||||
"ablation": ablation,
|
||||
},
|
||||
"shadow_only": True,
|
||||
}
|
||||
Reference in New Issue
Block a user