4e0db55f03
Engine asked_probe_keys no longer include varga split hashes that 400 the scorer, failed compares become visible and retry, user stop can still deliver a range on a stale snapshot, and holdout no longer reasks domains already in the ledger. Co-authored-by: Cursor <cursoragent@cursor.com>
531 lines
22 KiB
Python
531 lines
22 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 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"}
|
|
})
|
|
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)
|
|
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))
|
|
blocks.append({
|
|
"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": [],
|
|
"acceptance_allowed": False,
|
|
"selection_allowed": False,
|
|
"display_allowed": False,
|
|
"decision_receipt": {
|
|
**receipt,
|
|
"precision_stage": {"current": "block_scan"},
|
|
"discriminating_event_probes": [],
|
|
"acceptance_allowed": False,
|
|
"selection_allowed": False,
|
|
},
|
|
}
|