Files
Jyotisha/scripts/active_rectification_events.py
T
ClaudeandClaude Opus 5.5 0df8a3283f fix(rectification): provisional vs comparable leader under unequal coverage (BUG-1801)
Raw totals are only compared when every candidate computed the same
(event, layer) evidence units. Otherwise winning_segment stays the raw
leader (provisional_leader) and comparable_leader re-ranks over the
units all candidates share; a missing unit is dropped for everyone,
never scored 0. The live stage Python engine now skips an event whose
Narayana is unavailable instead of scoring it 0, repeated facts count
once, and the stage labels an unequal-coverage leader as provisional.

占星师 2026-11-08 验收清单 §6 / §9 / §10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-08 22:41:31 +08:00

553 lines
21 KiB
Python

# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
# ─── How to run ───
# .venv/bin/python -m pytest -q tests/test_active_rectification_events.py
"""Deterministically adjudicate dated events against birth-time candidates."""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any, Final, Literal, NotRequired, TypedDict, assert_never
from uuid import NAMESPACE_URL, uuid5
try:
from scripts.rectification_policy import (
MAX_CONFIRMATION_WIDTH_MINUTES,
MIN_CONFIRMATION_DOMAINS,
MIN_CONFIRMATION_EVENTS,
MIN_CONFIRMATION_MARGIN_PERCENT,
)
except ModuleNotFoundError: # pragma: no cover - direct script execution
from rectification_policy import (
MAX_CONFIRMATION_WIDTH_MINUTES,
MIN_CONFIRMATION_DOMAINS,
MIN_CONFIRMATION_EVENTS,
MIN_CONFIRMATION_MARGIN_PERCENT,
)
EventPrecision = Literal["year", "month", "day"]
EventDomain = Literal[
"education",
"relocation",
"relationship",
"career",
"finance",
"health_pressure",
"family",
"appearance",
"occupation",
]
Confidence = Literal["low", "medium", "high"]
ALGORITHM_VERSION: Final = "birth-time-event-scoring-v2"
PRECISION_WEIGHTS: Final[dict[EventPrecision, float]] = {
"day": 1.0,
"month": 0.8,
"year": 0.5,
}
class CandidateEvidence(TypedDict):
event_id: str
domain: str
candidate_time: str
rule_ids: list[str]
points: float
class LifeEvent(TypedDict):
id: str
domain: EventDomain
event_kind: NotRequired[str]
date: str
precision: EventPrecision
summary: NotRequired[str]
class RectificationEventRequest(TypedDict):
birth_date: str
start_time: str
end_time: str
lat: float
lon: float
tz: float
ayanamsa: NotRequired[str]
node_mode: NotRequired[str]
minute_step: NotRequired[int]
events: list[LifeEvent]
class CandidateScoreRow(TypedDict):
time: str
score: float
evidence: list[CandidateEvidence]
missing_layers: list[str]
# Per scored event, the points of each evidence layer that was actually computed (comparability policy).
layer_points: NotRequired[dict[str, dict[str, float]]]
class WinningSegment(TypedDict):
start_time: str
end_time: str
representative_time: str
width_minutes: int
class CandidateResult(TypedDict):
result_id: str
confidence: Confidence
can_apply: bool
winning_segment: WinningSegment | None
event_count: int
domain_count: int
top_score: float
second_score: float
margin_percent: float
reasons: list[str]
evidence: list[CandidateEvidence]
algorithm_version: str
canonical_input_hash: str
calculation_contract: dict[str, Any]
stability_diagnostics: dict[str, Any]
missing_layers: list[str]
leader_status: NotRequired[str]
provisional_leader: NotRequired[WinningSegment | None]
comparable_leader: NotRequired[WinningSegment | None]
coverage_comparison: NotRequired[dict[str, Any]]
candidate_ranking_summary: NotRequired[list[dict[str, Any]]]
candidate_summary: NotRequired[dict[str, Any]]
def build_candidate_result_summary(result: dict[str, Any]) -> dict[str, Any]:
"""Project a candidate result into stable, non-confirmatory next steps."""
supported: dict[str, dict[str, Any]] = {}
unconfirmed: dict[str, dict[str, Any]] = {}
contradictory: dict[str, dict[str, Any]] = {}
for item in result.get("evidence", []):
if not isinstance(item, dict):
continue
domain = str(item.get("domain") or "unknown")
points = float(item.get("points") or 0)
bucket = supported if points > 0 else contradictory if points < 0 else unconfirmed
row = bucket.setdefault(domain, {"domain": domain, "event_count": 0, "total_points": 0.0, "rule_ids": set()})
row["event_count"] += 1
row["total_points"] += points
row["rule_ids"].update(str(rule_id) for rule_id in item.get("rule_ids", []))
def rows(bucket: dict[str, dict[str, Any]], *, include_points: bool) -> list[dict[str, Any]]:
output = []
for row in bucket.values():
item: dict[str, Any] = {
"domain": row["domain"],
"event_count": row["event_count"],
"rule_ids": sorted(row["rule_ids"]),
}
if include_points:
item["total_points"] = round(row["total_points"], 3)
output.append(item)
return sorted(output, key=lambda item: (-item.get("total_points", 0), item["domain"]))
segment = result.get("winning_segment")
next_steps: list[str] = []
if int(result.get("event_count") or 0) < 5:
next_steps.append("collect_at_least_five_events")
if float(result.get("margin_percent") or 0) <= 0 or "tied_leader" in (result.get("reasons") or []):
next_steps.append("resolve_candidate_tie_or_narrow_window")
if isinstance(segment, dict) and int(segment.get("width_minutes") or 0) > 5:
next_steps.append("narrow_window_before_minute_claim")
next_steps.append("do_not_apply_as_birth_time_truth")
return {
"claim_status": "candidate_range_not_birth_time_truth",
"candidate_range": segment,
"supporting_evidence": rows(supported, include_points=True),
"unconfirmed_evidence": rows(unconfirmed, include_points=False),
"contradictory_evidence": rows(contradictory, include_points=True),
"reasons": list(result.get("reasons") or []),
"next_step_codes": next_steps,
}
def independent_events(events: Sequence[LifeEvent]) -> tuple[list[LifeEvent], list[str]]:
"""Keep the first event of each (domain, date, precision); return the dropped ids.
The score of an event depends only on those three fields, so a repeated entry is the same test
re-run, not independent support (占星师 2026-11-08 验收清单 §6).
"""
seen: set[tuple[str, str, str]] = set()
kept: list[LifeEvent] = []
duplicates: list[str] = []
for event in events:
key = (str(event["domain"]), str(event["date"]).strip(), str(event["precision"]))
if key in seen:
duplicates.append(str(event["id"]))
continue
seen.add(key)
kept.append(event)
return kept, duplicates
def precision_weight(precision: EventPrecision) -> float:
"""Return the fixed evidence weight for a declared date precision."""
match precision:
case "day" | "month" | "year":
return PRECISION_WEIGHTS[precision]
case unreachable:
assert_never(unreachable)
def _minute_value(value: str) -> int:
hour, minute = value.split(":", maxsplit=1)
return int(hour) * 60 + int(minute)
def _is_next_minute(previous: str, current: str) -> bool:
return (_minute_value(current) - _minute_value(previous)) % (24 * 60) == 1
def _top_segments(rows: Sequence[CandidateScoreRow], top_score: float) -> list[list[CandidateScoreRow]]:
segments: list[list[CandidateScoreRow]] = []
for row in rows:
if row["score"] != top_score:
continue
if segments and _is_next_minute(segments[-1][-1]["time"], row["time"]):
segments[-1].append(row)
else:
segments.append([row])
return segments
def _winning_segment(rows: Sequence[CandidateScoreRow]) -> WinningSegment:
width = len(rows)
representative = rows[(width - 1) // 2]["time"]
return {
"start_time": rows[0]["time"],
"end_time": rows[-1]["time"],
"representative_time": representative,
"width_minutes": width,
}
def _clock_distance(left: str, right: str) -> int:
distance = abs(_minute_value(left) - _minute_value(right))
return min(distance, 24 * 60 - distance)
def _time_at_offset(value: str, offset: int) -> str:
total = (_minute_value(value) + offset) % (24 * 60)
return f"{total // 60:02d}:{total % 60:02d}"
def build_stability_diagnostics(
rows: Sequence[CandidateScoreRow],
*,
winning_segment: WinningSegment | None,
) -> dict[str, Any]:
"""Describe exact-minute neighbor separation without claiming calibrated accuracy."""
representative = winning_segment["representative_time"] if winning_segment else None
by_time = {row["time"]: row for row in rows}
representative_row = by_time.get(representative) if representative else None
neighborhoods: list[dict[str, Any]] = []
for radius in (1, 2, 5):
required_times = {
_time_at_offset(representative, -radius),
_time_at_offset(representative, radius),
} if representative else set()
neighbors = [
row for row in rows
if representative is not None
and 0 < _clock_distance(row["time"], representative) <= radius
]
if representative_row is None or not required_times.issubset(by_time):
neighborhoods.append({
"radius_minutes": radius,
"status": "blocked",
"lead_points": None,
"compared_candidate_count": len(neighbors),
"reason": "candidate_range_does_not_cover_both_sides_of_neighborhood",
})
continue
best_neighbor = max(row["score"] for row in neighbors)
lead = round(representative_row["score"] - best_neighbor, 4)
neighborhoods.append({
"radius_minutes": radius,
"status": "pass" if winning_segment["width_minutes"] == 1 and lead > 0 else "fail",
"lead_points": lead,
"compared_candidate_count": len(neighbors),
"reason": (
"unique_minute_leads_neighbor_candidates"
if winning_segment["width_minutes"] == 1 and lead > 0
else "minute_not_uniquely_separated_from_neighbors"
),
})
return {
"scope": "candidate_neighbor_stability",
"representative_time": representative,
"neighborhoods": neighborhoods,
"all_required_passed": all(item["status"] == "pass" for item in neighborhoods),
"boundary": "Neighbor separation is a diagnostic only until thresholds are frozen before public holdout replay.",
}
COMPARABILITY_POLICY: Final = "common_evidence_coverage_v1"
# Layers whose absence drops the whole event for that candidate (the event is not scored at all).
EVENT_GATING_LAYERS: Final[frozenset[str]] = frozenset({
"Narayana", "Narayana_MD_AD", "Vimshottari_MD_AD_PD",
"D2", "D3", "D4", "D6", "D7", "D8", "D9", "D10", "D11", "D12", "D24", "D30", "D60",
})
def _coverage_units(row: CandidateScoreRow) -> dict[tuple[str, str], float]:
"""(event, layer) -> points for every evidence unit this candidate actually computed.
Rows that carry ``layer_points`` expose each layer; older rows expose one opaque unit per scored event.
"""
explicit = row.get("layer_points")
if isinstance(explicit, dict):
return {
(str(event_id), str(layer)): float(points)
for event_id, layers in explicit.items()
for layer, points in layers.items()
}
return {(str(item["event_id"]), "event_total"): float(item["points"]) for item in row["evidence"]}
def assess_leader_comparability(
rows: Sequence[CandidateScoreRow],
provisional_leader: WinningSegment | None,
) -> dict[str, Any]:
"""Explicit comparability policy (占星师 2026-11-08 验收清单 §6 / §9 / §10).
Raw totals are only comparable when every candidate computed the same evidence units. When coverage
differs, the raw-total leader stays ``provisional_leader`` (and keeps ``winning_segment``), and the
candidates are re-ranked over the units that *every* candidate has; that leader is ``comparable_leader``.
A unit one candidate lacks is dropped for all candidates, so it never counts as 0, negative or an old
algorithm score, and a candidate cannot lead by computing less. When the differing layer is folded into
another unit (no per-layer points), the comparison is ``not_reducible`` and there is no comparable leader.
"""
units = [_coverage_units(row) for row in rows]
row_layers = [set(row["missing_layers"]) for row in rows]
unit_keys = [set(item) for item in units]
equal = all(keys == unit_keys[0] for keys in unit_keys) and all(layers == row_layers[0] for layers in row_layers)
common = set.intersection(*unit_keys) if unit_keys else set()
base: dict[str, Any] = {
"policy": COMPARABILITY_POLICY,
"candidate_count": len(rows),
"common_unit_count": len(common),
"candidate_unit_counts": {
"min": min((len(keys) for keys in unit_keys), default=0),
"max": max((len(keys) for keys in unit_keys), default=0),
},
}
if equal:
return {
**base,
"status": "comparable",
"comparison": "raw_totals",
"candidates_with_extra_coverage": 0,
"comparable_leader": provisional_leader,
"leader_changed": False,
}
separable = {layer for keys in unit_keys for _, layer in keys} - {"event_total"}
differing = set.union(*row_layers) - set.intersection(*row_layers)
unexplained = sorted(layer for layer in differing if layer not in EVENT_GATING_LAYERS and layer not in separable)
extra = sum(1 for keys in unit_keys if keys != common)
if unexplained:
return {
**base,
"status": "provisional_unequal_coverage",
"comparison": "not_reducible",
"unreducible_layers": unexplained,
"candidates_with_extra_coverage": extra,
"comparable_leader": None,
"leader_changed": None,
}
common_rows: list[CandidateScoreRow] = [
{
"time": row["time"],
"score": round(sum(points for key, points in unit.items() if key in common), 4),
"evidence": [],
"missing_layers": [],
}
for row, unit in zip(rows, units, strict=True)
]
common_top = max(row["score"] for row in common_rows)
common_segments = _top_segments(common_rows, common_top)
comparable = _winning_segment(common_segments[0]) if len(common_segments) == 1 else None
return {
**base,
"status": "provisional_unequal_coverage",
"comparison": "common_coverage",
"candidates_with_extra_coverage": extra,
"common_top_score": common_top,
"common_scores": [{"time": row["time"], "score": row["score"]} for row in common_rows],
"comparable_leader": comparable,
"comparable_tied": len(common_segments) != 1,
"leader_changed": (
comparable is None
or provisional_leader is None
or (comparable["start_time"], comparable["end_time"])
!= (provisional_leader["start_time"], provisional_leader["end_time"])
),
}
def adjudicate_candidate_rows(
rows: Sequence[CandidateScoreRow],
*,
event_count: int,
domain_count: int,
request_fingerprint: str,
canonical_input_hash: str = "",
calculation_contract: dict[str, Any] | None = None,
leave_one_event_out: dict[str, Any] | None = None,
) -> CandidateResult:
"""Rank precomputed candidate rows and apply conservative confidence gates."""
if not rows:
return {
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{request_fingerprint}")),
"confidence": "low",
"can_apply": False,
"winning_segment": None,
"event_count": event_count,
"domain_count": domain_count,
"top_score": 0.0,
"second_score": 0.0,
"margin_percent": 0.0,
"reasons": ["no_candidate_rows"],
"evidence": [],
"algorithm_version": ALGORITHM_VERSION,
"canonical_input_hash": canonical_input_hash,
"calculation_contract": calculation_contract or {},
"stability_diagnostics": {
"neighbor_stability": build_stability_diagnostics([], winning_segment=None),
"leave_one_event_out": leave_one_event_out or {"status": "not_evaluated", "runs": []},
},
"missing_layers": [],
"leader_status": "no_leader",
"provisional_leader": None,
"comparable_leader": None,
"coverage_comparison": {"policy": COMPARABILITY_POLICY, "status": "no_candidate_rows"},
}
ranked_scores = sorted({row["score"] for row in rows}, reverse=True)
top_score = ranked_scores[0]
second_score = ranked_scores[1] if len(ranked_scores) > 1 else top_score
margin = round((top_score - second_score) / max(abs(top_score), 1.0) * 100, 2)
segments = _top_segments(rows, top_score)
missing_layers = sorted({layer for row in rows for layer in row["missing_layers"]})
reasons: list[str] = []
if len(segments) != 1:
reasons.append("tied_leader")
if event_count < 3:
reasons.append("insufficient_events")
if domain_count < 2:
reasons.append("insufficient_domains")
if missing_layers:
reasons.append("missing_mandatory_layers")
segment = _winning_segment(segments[0]) if len(segments) == 1 else None
comparability = assess_leader_comparability(rows, segment)
if comparability["status"] != "comparable":
reasons.append("unequal_evidence_coverage")
if segment and segment["width_minutes"] > 15:
reasons.append("winning_interval_too_wide")
if margin < 10:
reasons.append("lead_margin_below_medium_threshold")
if reasons:
confidence: Confidence = "low"
elif (
event_count >= MIN_CONFIRMATION_EVENTS
and domain_count >= MIN_CONFIRMATION_DOMAINS
and segment is not None
and segment["width_minutes"] <= MAX_CONFIRMATION_WIDTH_MINUTES
and margin >= MIN_CONFIRMATION_MARGIN_PERCENT
):
confidence = "high"
else:
confidence = "medium"
top_rows = segments[0] if len(segments) == 1 else []
representative_row = top_rows[(len(top_rows) - 1) // 2] if top_rows else None
evidence = list(representative_row["evidence"]) if representative_row else []
neighbor_stability = build_stability_diagnostics(rows, winning_segment=segment)
if not neighbor_stability["all_required_passed"]:
reasons.append("neighbor_stability_not_passed")
if (leave_one_event_out or {}).get("status") != "pass":
reasons.append("leave_one_event_out_not_passed")
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 comparability["status"] == "comparable"
and not any(reason in reasons for reason in (
"unequal_evidence_coverage",
"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": can_apply,
"winning_segment": segment,
"event_count": event_count,
"domain_count": domain_count,
"top_score": top_score,
"second_score": second_score,
"margin_percent": margin,
"reasons": reasons,
"evidence": evidence,
"algorithm_version": ALGORITHM_VERSION,
"canonical_input_hash": canonical_input_hash,
"calculation_contract": calculation_contract or {},
"stability_diagnostics": {
"neighbor_stability": neighbor_stability,
"leave_one_event_out": leave_one_event_out or {"status": "not_evaluated", "runs": []},
},
"missing_layers": missing_layers,
# winning_segment stays the raw-total leader; it is only "comparable" when coverage is equal.
"leader_status": (
"no_single_leader" if segment is None
else "comparable" if comparability["status"] == "comparable"
else "provisional_unequal_coverage"
),
"provisional_leader": segment,
"comparable_leader": comparability["comparable_leader"],
"coverage_comparison": {key: value for key, value in comparability.items() if key != "comparable_leader"},
"candidate_ranking_summary": ranking_summary,
}
def score_life_events(request: RectificationEventRequest) -> CandidateResult:
"""Compute actual candidate rows, then apply the versioned confidence gates."""
from scripts.active_rectification_event_engine import compute_event_candidate_result
result = compute_event_candidate_result(request)
result["candidate_summary"] = build_candidate_result_summary(result)
return result