Files
Jyotisha/scripts/rectification/api_service.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

549 lines
23 KiB
Python

from __future__ import annotations
from typing import Any, Mapping, Sequence
from uuid import NAMESPACE_URL, uuid5
from scripts.active_rectification_events import build_candidate_result_summary
from scripts.rectification.candidate_feature_service import build_candidate_feature_snapshot
from scripts.rectification.contracts import (
EVENT_CONTRACT_VERSION,
RectificationRequest,
is_primary_scoreable_event,
)
from scripts.rectification.decision_policy import (
EXECUTION_LEDGER_VERSION,
POLICY_VERSION,
build_candidate_decisions,
build_decision_receipt,
build_execution_ledger,
)
from scripts.rectification.diagnostics_service import run_diagnostics
from scripts.rectification.scoring_service import (
ALGORITHM_VERSION,
build_event_contribution_matrix,
calculation_spec,
score_from_matrix,
scoreable_request,
sha256,
)
def _clock_minutes(value: str) -> int:
hour, minute = value[:5].split(":", 1)
return int(hour) * 60 + int(minute)
def _window_width(start_time: str, end_time: str) -> int:
return (_clock_minutes(end_time) - _clock_minutes(start_time)) % 1_440 + 1
def _report_candidate_range(
request: RectificationRequest,
candidate_scores: Sequence[dict[str, Any]],
representative_time: str | None,
) -> dict[str, Any]:
top_score = max((float(row.get("score") or 0) for row in candidate_scores), default=None)
top_times = [
str(row.get("time"))[:5]
for row in candidate_scores
if top_score is not None and float(row.get("score") or 0) == top_score
]
if "candidate_intervals" in request:
from scripts.rectification.candidate_window import candidate_positions, enumerate_candidate_window, intervals_from_positions, interval_union_width
positions = candidate_positions(request, enumerate_candidate_window(request))
selected = [row for row in positions if not top_times or row["time"] in top_times]
parts = intervals_from_positions(selected)
return {
"start_time": selected[0]["time"], "end_time": selected[-1]["time"],
"candidate_intervals": [{"start_at": part["start_at"], "end_at": part["end_at"]} for part in parts],
"representative_time": representative_time or selected[len(selected) // 2]["time"],
"width_minutes": interval_union_width(parts), "representative_is_unique": False,
}
if not top_times:
return {
"start_time": request["start_time"],
"end_time": request["end_time"],
"representative_time": representative_time,
"width_minutes": _window_width(request["start_time"], request["end_time"]),
"representative_is_unique": False,
}
return {
"start_time": top_times[0],
"end_time": top_times[-1],
"representative_time": representative_time or top_times[len(top_times) // 2],
"width_minutes": len(top_times),
"representative_is_unique": False,
}
def _window_span_minutes(start_time: str, end_time: str) -> int:
start = _clock_minutes(start_time)
end = _clock_minutes(end_time)
return end - start if end >= start else 1440 - start + end
def _adaptive_minute_step(start_time: str, end_time: str) -> int:
width = _window_span_minutes(start_time, end_time)
if width > 360:
return 10
if width > 180:
return 5
return 2
def _block_scan_periods(request: RectificationRequest) -> list[tuple[str, str, str]]:
custom = request.get("blocks")
if isinstance(custom, list) and custom:
periods: list[tuple[str, str, str]] = []
for item in custom:
if not isinstance(item, dict):
continue
label = str(item.get("period") or item.get("label") or "")
start_time = str(item.get("start_time") or "")[:5]
end_time = str(item.get("end_time") or "")[:5]
if label and start_time and end_time:
periods.append((label, start_time, end_time))
if periods:
return periods
return list(BLOCK_SCAN_PERIODS)
def _report_evidence(
request: RectificationRequest,
built: dict[str, Any],
representative_time: str | None,
) -> list[dict[str, Any]]:
matrix = built.get("matrix") or {}
rows: list[dict[str, Any]] = []
for event in request.get("events") or []:
if not is_primary_scoreable_event(event):
continue
contribution = (matrix.get(event["id"]) or {}).get(representative_time or "")
contribution = contribution if isinstance(contribution, dict) else {}
points = float(contribution.get("points") or 0)
status = "supporting" if points > 0 else "contradictory" if points < 0 else "unconfirmed"
rows.append({
"event_id": event["id"],
"summary": str(event.get("summary") or "").strip(),
"domain": event["domain"],
"date": {
"start": event["date_start"],
"end": event["date_end"],
"precision": event["precision"],
},
"status": status,
"supports_candidate_time": representative_time if status == "supporting" else None,
"methods": sorted({str(layer) for layer in contribution.get("technique_layers") or []}),
})
return rows
def _report_excluded_candidates(
candidate_decisions: Sequence[dict[str, Any]],
representative_time: str | None,
) -> list[dict[str, Any]]:
return [
{
"time": str(candidate.get("time") or "")[:5],
"reason": "not_the_leading_candidate",
"representative_time": representative_time,
}
for candidate in candidate_decisions
if str(candidate.get("time") or "")[:5] != (representative_time or "")
]
def _confirmation_blockers(receipt: dict[str, Any]) -> list[dict[str, str]]:
allowed = {"VedAstro 分钟级校验", "唯一分钟确认"}
return [
{
"technique": str(row.get("technique")),
"status": str(row.get("status")),
"user_meaning": str(row.get("note") or ""),
}
for row in receipt.get("technique_audit_table") or []
if isinstance(row, dict)
and str(row.get("technique")) in allowed
and str(row.get("status")) != "executed"
]
def _rectification_report(
request: RectificationRequest,
built: dict[str, Any],
candidate_scores: Sequence[dict[str, Any]],
candidate_decisions: Sequence[dict[str, Any]],
receipt: dict[str, Any],
) -> dict[str, Any]:
representative_time = str(receipt.get("representative_time") or "")[:5] or None
blockers = _confirmation_blockers(receipt)
candidate_range = _report_candidate_range(request, candidate_scores, representative_time)
limitations = [item["user_meaning"] for item in blockers if item["user_meaning"]]
if not limitations:
limitations.append("本会话以代表性时间收口,不确认唯一分钟。")
return {
"candidate_range": candidate_range,
"representative_time": representative_time,
"representative_label": "代表性候选,不是唯一解",
"confidence": receipt.get("overall_confidence", "low"),
"evidence": _report_evidence(request, built, representative_time),
"excluded_candidates": _report_excluded_candidates(candidate_decisions, representative_time),
"next_step_codes": [],
"confirmation_gate_blockers": blockers,
"limitations": limitations,
"claim_status": "candidate_range_not_birth_time_truth",
}
def engine_scoring_versions() -> dict[str, str]:
"""Identity fields also returned by `/api/rectification/v5/score`, without scoring."""
return {
"algorithm_version": ALGORITHM_VERSION,
"event_contract_version": EVENT_CONTRACT_VERSION,
"decision_policy_version": POLICY_VERSION,
}
def candidate_features(request: RectificationRequest) -> dict[str, Any]:
spec = calculation_spec(request)
spec_hash = sha256(spec)
scoring_request = scoreable_request(request)
return {
"algorithm_version": ALGORITHM_VERSION,
"event_contract_version": EVENT_CONTRACT_VERSION,
"decision_policy_version": POLICY_VERSION,
"calculation_spec": spec,
"calculation_spec_hash": spec_hash,
"candidate_feature_snapshot": build_candidate_feature_snapshot(scoring_request, spec_hash),
"can_confirm_exact_minute": False,
}
def score_candidates(request: RectificationRequest) -> dict[str, Any]:
scoring_request = scoreable_request(request)
built = build_event_contribution_matrix(scoring_request)
rows = score_from_matrix(scoring_request, built)
spec = calculation_spec(request)
spec_hash = sha256(spec)
diagnostic_values = run_diagnostics(scoring_request, rows, built)
fingerprint = sha256({
key: value
for key, value in request.items()
if key not in {"asked_probe_keys", "dropped_asked_probe_keys", "declined_domains", "column_times", "refresh_probes"}
})
result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}"))
candidate_decisions = build_candidate_decisions(
rows,
result_id=result_id,
static_contexts=built.get("static_contexts") if isinstance(built.get("static_contexts"), list) else None,
)
decision_receipt = build_decision_receipt(request, candidate_decisions, built, diagnostic_values)
from scripts.rectification.candidate_window import candidate_intervals
decision_receipt.update({"candidate_window_contract": "dated-v1", "candidate_intervals": candidate_intervals(request),
"candidate_timezone_offset": request["tz"], "candidate_timezone_id": request.get("timezone_id", "")})
execution_ledger = build_execution_ledger(request, built, diagnostic_values, candidate_decisions)
representative = candidate_decisions[0] if candidate_decisions else None
representative_time = str(representative.get("time") or "")[:5] if representative else None
report_range = _report_candidate_range(request, rows, representative_time)
report_evidence = _report_evidence(request, built, representative_time)
summary_evidence = [
{
"event_id": item["event_id"],
"domain": item["domain"],
"candidate_time": representative_time or "",
"rule_ids": item["methods"],
"points": 1 if item["status"] == "supporting" else -1 if item["status"] == "contradictory" else 0,
}
for item in report_evidence
]
candidate_summary = build_candidate_result_summary({
"winning_segment": report_range,
"event_count": len(scoring_request.get("events", [])),
"margin_percent": decision_receipt.get("margin_percent", 0),
"reasons": decision_receipt.get("reasons", []),
"evidence": summary_evidence,
})
candidate_summary["stability"] = {"label": decision_receipt.get("overall_confidence", "low")}
rectification_report = _rectification_report(
request, built, [{
"time": row["time"],
"score": row["score"],
} for row in rows], candidate_decisions, decision_receipt,
)
rectification_report["next_step_codes"] = candidate_summary["next_step_codes"]
candidate_summary["report"] = rectification_report
return {
"result_id": result_id,
"algorithm_version": ALGORITHM_VERSION,
"event_contract_version": EVENT_CONTRACT_VERSION,
"decision_policy_version": POLICY_VERSION,
"calculation_spec": spec,
"calculation_spec_hash": spec_hash,
"candidate_scores": [{
"time": row["time"],
"score": row["score"],
"supporting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] > 0],
"conflicting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] < 0],
} for row in rows],
"candidate_decisions": candidate_decisions,
"candidate_decision_receipt": decision_receipt,
"decision_receipt": decision_receipt,
"execution_ledger_version": EXECUTION_LEDGER_VERSION,
"execution_ledger": execution_ledger,
"event_contribution_matrix": built["matrix"],
"candidate_feature_snapshot": build_candidate_feature_snapshot(
scoring_request, spec_hash, built.get("static_contexts")
),
"diagnostics": diagnostic_values,
"candidate_summary": candidate_summary,
"next_step_codes": candidate_summary["next_step_codes"],
"stability": candidate_summary["stability"],
"rectification_report": rectification_report,
"robustness": {
"neighbor_support_minutes": diagnostic_values.get("neighbor_support_minutes", 0),
"leave_one_out_retention_rate": diagnostic_values.get("leave_one_event_out_retention_rate", 0),
"leave_one_domain_out_retention_rate": diagnostic_values.get("leave_one_domain_out_retention_rate", 0),
"date_sensitivity_retention_rate": diagnostic_values.get("date_sensitivity_retention_rate", 0),
},
"missing_layers": built["missing_layers"],
"display_allowed": decision_receipt["display_allowed"],
"selection_allowed": decision_receipt["selection_allowed"],
"acceptance_allowed": decision_receipt["acceptance_allowed"],
"propose_allowed": decision_receipt["propose_allowed"],
"confirmation_allowed": decision_receipt["confirmation_allowed"],
"representative_candidate_id": representative["candidate_id"] if representative else None,
"representative_time": representative["time"] if representative else None,
"overall_confidence": decision_receipt["overall_confidence"],
"margin_percent": decision_receipt["margin_percent"],
"can_confirm_exact_minute": decision_receipt["confirmation_allowed"],
}
def diagnostics(request: RectificationRequest) -> dict[str, Any]:
scored = score_candidates(request)
return {
"result_id": scored["result_id"],
"algorithm_version": scored["algorithm_version"],
"event_contract_version": scored["event_contract_version"],
"decision_policy_version": scored["decision_policy_version"],
"calculation_spec_hash": scored["calculation_spec_hash"],
"candidate_decisions": scored["candidate_decisions"],
"candidate_decision_receipt": scored["candidate_decision_receipt"],
"decision_receipt": scored["decision_receipt"],
"execution_ledger_version": scored["execution_ledger_version"],
"execution_ledger": scored["execution_ledger"],
"diagnostics": scored["diagnostics"],
"candidate_summary": scored.get("candidate_summary", {"next_step_codes": ["do_not_apply_as_birth_time_truth"]}),
"next_step_codes": scored.get("next_step_codes", ["do_not_apply_as_birth_time_truth"]),
"stability": scored.get("stability", {"label": scored.get("overall_confidence", "low")}),
"rectification_report": scored.get("rectification_report", {}),
"missing_layers": scored["missing_layers"],
"display_allowed": scored["display_allowed"],
"selection_allowed": scored["selection_allowed"],
"acceptance_allowed": scored["acceptance_allowed"],
"propose_allowed": scored["propose_allowed"],
"confirmation_allowed": scored["confirmation_allowed"],
"representative_candidate_id": scored["representative_candidate_id"],
"representative_time": scored["representative_time"],
"overall_confidence": scored["overall_confidence"],
"margin_percent": scored["margin_percent"],
"can_confirm_exact_minute": scored["confirmation_allowed"],
}
def range_reading(request: Mapping[str, Any]) -> dict[str, Any]:
"""Stable vs minute-sensitive themes for one unresolved clock window."""
from types import SimpleNamespace
from scripts.jyotish_engine import _build_birth_time_sensitivity
body = dict(request or {})
birth_date = str(body.get("birth_date") or "").strip()
if birth_date:
year_text, month_text, day_text = birth_date.split("-", 2)
year, month, day = int(year_text), int(month_text), int(day_text)
else:
year, month, day = int(body["year"]), int(body["month"]), int(body["day"])
representative = str(body.get("representative_time") or "")[:5]
if len(representative) == 5 and representative[2] == ":":
hour, minute = int(representative[:2]), int(representative[3:])
else:
hour = int(body.get("hour") or 12)
minute = int(body.get("minute") or 0)
representative = f"{hour:02d}:{minute:02d}"
raw_range = body.get("candidate_range")
if isinstance(raw_range, Mapping):
start_time = str(raw_range.get("start_time") or "")[:5]
end_time = str(raw_range.get("end_time") or "")[:5]
representative = str(raw_range.get("representative_time") or representative)[:5]
else:
start_time = str(body.get("start_time") or "")[:5]
end_time = str(body.get("end_time") or "")[:5]
hour, minute = int(representative[:2]), int(representative[3:])
accuracy = str(body.get("birth_time_accuracy") or "provisional")
args = SimpleNamespace(
year=year,
month=month,
day=day,
hour=hour,
minute=minute,
second=0,
lat=float(body["lat"]),
lon=float(body["lon"]),
tz=float(body["tz"]),
ayanamsa=body.get("ayanamsa") or "raman",
node_mode=body.get("node_mode") or "mean",
birth_time_accuracy=accuracy,
candidate_range={
"start_time": start_time,
"end_time": end_time,
"representative_time": representative,
},
representative_time=representative,
declared_window_start=None,
declared_window_end=None,
uncertainty_before_minutes=None,
uncertainty_after_minutes=None,
)
sensitivity = _build_birth_time_sensitivity(args)
themes = sensitivity.get("theme_sensitivity")
themes = themes if isinstance(themes, dict) else {}
stable = [
key for key, row in themes.items()
if isinstance(row, dict) and row.get("status") == "stable"
]
sensitive = [
key for key, row in themes.items()
if isinstance(row, dict) and row.get("status") == "sensitive"
]
return {
"window": sensitivity.get("window"),
"stable_themes": stable,
"sensitive_themes": sensitive,
"claim_boundary": sensitivity.get("claim_boundary"),
"theme_sensitivity": themes,
"accuracy": sensitivity.get("accuracy"),
"status": sensitivity.get("status"),
}
BLOCK_SCAN_PERIODS: tuple[tuple[str, str, str], ...] = (
("early_morning", "04:00", "07:59"),
("morning", "08:00", "11:59"),
("afternoon", "12:00", "17:59"),
("evening", "18:00", "22:59"),
("late_night", "23:00", "03:59"),
)
def _clock_in_declared_period(clock: str, start_time: str, end_time: str) -> bool:
current = _clock_minutes(clock[:5])
start = _clock_minutes(start_time)
end = _clock_minutes(end_time)
if start <= end:
return start <= current <= end
return current >= start or current <= end
def _normalize_relative_support(raw: Sequence[float]) -> list[float]:
floored = [max(0.0, float(value)) for value in raw]
total = sum(floored)
if not floored:
return []
if total <= 0:
shares = [round(100.0 / len(floored), 1) for _ in floored]
else:
shares = [round(100.0 * value / total, 1) for value in floored]
delta = round(100.0 - sum(shares), 1)
if shares:
shares[shares.index(max(shares))] = round(shares[shares.index(max(shares))] + delta, 1)
return shares
def block_scan(request: RectificationRequest) -> dict[str, Any]:
"""Aggregate event scores into declared periods or caller-supplied sub-blocks."""
requested_step = request.get("minute_step")
if isinstance(requested_step, int) and requested_step > 1:
step = requested_step
else:
step = _adaptive_minute_step(request["start_time"], request["end_time"])
if not request.get("blocks"):
step = int(request.get("minute_step") or 10)
if step <= 1:
step = 10
scoring_request = {**request, "minute_step": step}
scored = score_candidates(scoring_request)
events_by_id = {
str(event.get("id") or ""): event
for event in request.get("events") or []
if isinstance(event, dict)
}
rows = [
row for row in scored.get("candidate_scores") or []
if isinstance(row, dict) and str(row.get("time") or "")[:5]
]
day_scores = [float(row.get("score") or 0) for row in rows]
min_day = min(day_scores) if day_scores else 0.0
periods = _block_scan_periods(request)
raw_support: list[float] = []
blocks: list[dict[str, Any]] = []
for period, start_time, end_time in periods:
members = [
row for row in rows
if _clock_in_declared_period(str(row.get("time") or "")[:5], start_time, end_time)
]
counts: dict[str, int] = {}
for row in members:
for event_id in row.get("supporting_event_ids") or []:
key = str(event_id)
if key:
counts[key] = counts.get(key, 0) + 1
top_events = []
for event_id, _count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))[:3]:
event = events_by_id.get(event_id) or {}
top_events.append({
"event_id": event_id,
"domain": event.get("domain"),
"summary": event.get("summary"),
})
member_scores = [float(row.get("score") or 0) for row in members]
mean = (sum(member_scores) / len(member_scores)) if member_scores else 0.0
raw_support.append(max(mean - min_day, 0.0))
from scripts.rectification.candidate_window import narrow_candidate_intervals
blocks.append({
"candidate_intervals": narrow_candidate_intervals(request, start_time, end_time),
"period": period,
"start_time": start_time,
"end_time": end_time,
"relative_support": 0,
"top_events": top_events,
"candidate_count": len(members),
})
shares = _normalize_relative_support(raw_support)
for block, share in zip(blocks, shares):
block["relative_support"] = share
receipt = scored.get("decision_receipt") if isinstance(scored.get("decision_receipt"), dict) else {}
return {
"result_id": scored.get("result_id"),
"algorithm_version": scored.get("algorithm_version"),
"calculation_spec": scored.get("calculation_spec"),
"calculation_spec_hash": scored.get("calculation_spec_hash"),
"minute_step": step,
"candidate_count": len(rows),
"precision_stage": {"current": "block_scan"},
"blocks": blocks,
"discriminating_event_probes": [],
"guided_collect_windows": [],
"acceptance_allowed": False,
"selection_allowed": False,
"display_allowed": False,
"decision_receipt": {
**receipt,
"precision_stage": {"current": "block_scan"},
"discriminating_event_probes": [],
"guided_collect_windows": [],
"acceptance_allowed": False,
"selection_allowed": False,
},
}