fix: harden dynamic rectification boundary

This commit is contained in:
Jesse_Chen
2026-07-19 01:06:23 +08:00
parent df0adcdfc8
commit 49ab717752
7 changed files with 691 additions and 458 deletions
+87 -252
View File
@@ -3,26 +3,35 @@
# dependencies = []
# ///
# ─── How to run ───
# .venv/bin/python -m pytest -q tests/test_dynamic_rectification.py
"""Candidate-backed opportunities and deterministic dynamic-choice scoring."""
# .venv/bin/python -m pytest -q tests/test_dynamic_rectification_scoring.py
"""Public dynamic-rectification packet and deterministic scoring entrypoints."""
from __future__ import annotations
import hashlib
import json
import math
from collections import defaultdict
from collections.abc import Mapping, Sequence
from datetime import date, datetime, time, timedelta
from typing import Final, Literal, TypedDict
from uuid import NAMESPACE_URL, UUID, uuid5
from collections.abc import Sequence
from typing import Literal, TypedDict
from uuid import NAMESPACE_URL, uuid5
ALGORITHM_VERSION: Final = "birth-time-choice-scoring-v2"
MIN_INFORMATION_GAIN: Final = 0.15
SUPPORTED_DIMENSIONS: Final = frozenset(
{"education", "relocation", "relationship", "career", "health_pressure"}
from scripts.dynamic_rectification_opportunities import (
ALGORITHM_VERSION,
SUPPORTED_DIMENSIONS,
candidate_times,
candidate_window_rows,
canonical_hash,
compute_candidate_model,
experience_windows,
opportunities,
validate_candidate_model,
)
Confidence = Literal["low", "medium", "high"]
_candidate_times = candidate_times
_candidate_window_rows = candidate_window_rows
_canonical_hash = canonical_hash
_experience_windows = experience_windows
_opportunities = opportunities
_validate_candidate_model = validate_candidate_model
class ChoiceRow(TypedDict):
time: str
@@ -36,228 +45,40 @@ class WinningSegment(TypedDict):
width_minutes: int
Confidence = Literal["low", "medium", "high"]
def _canonical_hash(value: Mapping | Sequence) -> str:
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _candidate_times(birth_date: str, start_time: str, end_time: str) -> list[str]:
day = date.fromisoformat(birth_date)
start = datetime.combine(day, time.fromisoformat(start_time))
end = datetime.combine(day, time.fromisoformat(end_time))
if end < start:
end += timedelta(days=1)
count = int((end - start).total_seconds() // 60) + 1
if not 1 <= count <= 1_440:
raise ValueError("candidate range must contain between 1 and 1440 minutes")
return [(start + timedelta(minutes=offset)).strftime("%H:%M") for offset in range(count)]
def _experience_windows(birth_date: str, as_of_date: str) -> list[tuple[date, date]]:
born = date.fromisoformat(birth_date)
as_of = date.fromisoformat(as_of_date)
try:
first = born.replace(year=born.year + 12)
except ValueError:
first = born.replace(year=born.year + 12, day=28)
if as_of < first:
return []
day_count = (as_of - first).days + 1
window_count = min(4, day_count, max(2, math.ceil(day_count / (6 * 365))))
boundaries = [first + timedelta(days=day_count * index // window_count) for index in range(window_count)]
return [
(start, as_of if index == window_count - 1 else boundaries[index + 1] - timedelta(days=1))
for index, start in enumerate(boundaries)
]
def _candidate_window_rows(request: dict) -> list[dict]:
"""Compute each candidate chart once and reuse it for every dimension/window."""
from scripts.active_rectification_event_engine import (
DOMAIN_CONFIG,
_candidate_datetimes,
_candidate_row,
)
windows = _experience_windows(request["birth_date"], request["as_of_date"])
if not windows:
return []
synthetic_events = []
event_windows: dict[str, tuple[str, date, date]] = {}
for dimension in sorted(SUPPORTED_DIMENSIONS):
for window_start, window_end in windows:
event_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{dimension}:{window_start}:{window_end}"))
midpoint = window_start + (window_end - window_start) / 2
synthetic_events.append(
{"id": event_id, "domain": dimension, "date": midpoint.isoformat(), "precision": "day"}
)
event_windows[event_id] = (dimension, window_start, window_end)
calculation_request = {
"birth_date": request["birth_date"],
"start_time": request["start_time"],
"end_time": request["end_time"],
"lat": request["lat"],
"lon": request["lon"],
"tz": request["tz"],
"events": synthetic_events,
}
rows = [_candidate_row(calculation_request, candidate) for candidate in _candidate_datetimes(calculation_request)]
activations = {
event_id: {row["time"]: 0.0 for row in rows}
for event_id in event_windows
}
missing_layers = sorted({layer for row in rows for layer in row["missing_layers"]})
for row in rows:
for evidence in row["evidence"]:
activations[evidence["event_id"]][row["time"]] = float(evidence["points"])
return [
{
"dimension_code": dimension,
"window_start": window_start.isoformat(),
"window_end": window_end.isoformat(),
"activations": activations[event_id],
"missing_layers": [DOMAIN_CONFIG[dimension][0]]
if DOMAIN_CONFIG[dimension][0] in missing_layers else [],
}
for event_id, (dimension, window_start, window_end) in event_windows.items()
]
def _compute_candidate_model(request: dict) -> dict:
return {
"version": ALGORITHM_VERSION,
"birth_date": request["birth_date"],
"as_of_date": request["as_of_date"],
"range": {"start_time": request["start_time"], "end_time": request["end_time"]},
"candidate_times": _candidate_times(request["birth_date"], request["start_time"], request["end_time"]),
"windows": _candidate_window_rows(request),
}
def _validate_candidate_model(model: dict, request: dict) -> dict:
expected_keys = {"version", "birth_date", "as_of_date", "range", "candidate_times", "windows"}
candidates = _candidate_times(request["birth_date"], request["start_time"], request["end_time"])
try:
valid_header = (
set(model) == expected_keys
and model["version"] == ALGORITHM_VERSION
and model["birth_date"] == request["birth_date"]
and model["as_of_date"] == request["as_of_date"]
and model["range"] == {"start_time": request["start_time"], "end_time": request["end_time"]}
and model["candidate_times"] == candidates
and isinstance(model["windows"], list)
)
first_window = _experience_windows(request["birth_date"], request["as_of_date"])
minimum_date = first_window[0][0] if first_window else date.max
maximum_date = date.fromisoformat(request["as_of_date"])
window_keys = [
(row.get("dimension_code"), row.get("window_start"), row.get("window_end"))
for row in model["windows"] if isinstance(row, dict)
]
valid_windows = len(window_keys) == len(set(window_keys)) and all(
isinstance(row, dict)
and set(row) == {"dimension_code", "window_start", "window_end", "activations", "missing_layers"}
and row["dimension_code"] in SUPPORTED_DIMENSIONS
and minimum_date <= date.fromisoformat(row["window_start"])
<= date.fromisoformat(row["window_end"]) <= maximum_date
and isinstance(row["activations"], dict)
and set(row["activations"]) == set(candidates)
and all(
not isinstance(score, bool)
and isinstance(score, int | float)
and math.isfinite(score)
and score >= 0
for score in row["activations"].values()
)
and isinstance(row["missing_layers"], list)
and all(isinstance(layer, str) and layer for layer in row["missing_layers"])
for row in model["windows"]
)
except (KeyError, TypeError, ValueError):
valid_header = valid_windows = False
if not valid_header or not valid_windows:
raise ValueError("candidate model does not match the submitted request")
return model
def _opportunities(model: dict) -> list[dict]:
grouped: dict[str, list[dict]] = defaultdict(list)
for row in model["windows"]:
if not row["missing_layers"]:
grouped[row["dimension_code"]].append(row)
opportunities = []
candidates = model["candidate_times"]
for dimension, windows in sorted(grouped.items()):
memberships: dict[int, list[str]] = defaultdict(list)
for candidate in candidates:
winner = max(range(len(windows)), key=lambda index: (windows[index]["activations"][candidate], -index))
memberships[winner].append(candidate)
populated = [(windows[index], members) for index, members in sorted(memberships.items()) if members]
if not 2 <= len(populated) <= 4:
continue
probabilities = [len(members) / len(candidates) for _, members in populated]
information_gain = -sum(value * math.log(value) for value in probabilities) / math.log(len(populated))
if information_gain < MIN_INFORMATION_GAIN:
continue
partition_basis = []
partitions = []
for window, members in populated:
basis = {
"version": ALGORITHM_VERSION,
"dimension": dimension,
"window_start": window["window_start"],
"window_end": window["window_end"],
"members": sorted(members),
}
partition_basis.append(basis)
partitions.append(
{
"partition_id": _canonical_hash(basis),
"descriptor": f"{window['window_start']}--{window['window_end']}",
"fallback_label": f"{window['window_start'][:4]}{window['window_end'][:4]}",
"candidate_scores": {candidate: 1.0 if candidate in members else 0.0 for candidate in candidates},
}
)
fingerprint = _canonical_hash({"version": ALGORITHM_VERSION, "partitions": partition_basis})
opportunities.append(
{
"opportunity_id": _canonical_hash({"version": ALGORITHM_VERSION, "dimension": dimension, "partitions": partition_basis}),
"dimension_code": dimension,
"neutral_context": dimension,
"estimated_information_gain": round(information_gain, 6),
"candidate_partition_fingerprint": fingerprint,
"fallback_prompt": f"下面哪个时间段更接近你在 {dimension} 方面的明显变化?",
"partitions": partitions,
}
)
return sorted(opportunities, key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"]))
return compute_candidate_model(request, _candidate_window_rows)
def build_difference_packet(request: dict) -> dict:
"""Build reusable candidate activations and unused high-gain opportunities."""
candidates = _candidate_times(request["birth_date"], request["start_time"], request["end_time"])
candidates = _candidate_times(
request["birth_date"], request["start_time"], request["end_time"]
)
_validated_choice_evidence(request.get("evidence"), candidates)
model = request.get("candidate_model")
candidate_model = _compute_candidate_model(request) if model is None else _validate_candidate_model(model, request)
persisted = request.get("candidate_model")
model = (
_compute_candidate_model(request)
if persisted is None
else _validate_candidate_model(persisted, request)
)
dismissed = set(request.get("dismissed_opportunity_ids", []))
fingerprints = set(request.get("partition_fingerprints", []))
opportunities = [
item for item in _opportunities(candidate_model)
unused = [
item for item in _opportunities(model)
if item["opportunity_id"] not in dismissed
and item["candidate_partition_fingerprint"] not in fingerprints
]
return {
"case_id": request["case_id"],
"scoring_version": ALGORITHM_VERSION,
"current_range": {"start_time": request["start_time"], "end_time": request["end_time"]},
"opportunities": opportunities,
"current_range": {
"start_time": request["start_time"], "end_time": request["end_time"]
},
"opportunities": unused,
"asked_question_fingerprints": list(request.get("question_fingerprints", [])),
"candidate_partition_fingerprints": list(request.get("partition_fingerprints", [])),
"recent_range_history": list(request.get("recent_ranges", [])),
"candidate_model": candidate_model,
"candidate_model": model,
}
@@ -271,7 +92,7 @@ def _winning_segments(rows: Sequence[ChoiceRow], top_score: float) -> list[list[
for row in rows:
if row["score"] != top_score:
continue
follows = segments and (
follows = bool(segments) and (
_minute_value(row["time"]) - _minute_value(segments[-1][-1]["time"])
) % 1_440 == 1
if follows:
@@ -285,8 +106,8 @@ def adjudicate_choice_rows(
rows: Sequence[ChoiceRow], *, effective_answer_count: int, dimension_count: int,
missing_layers: Sequence[str], request_fingerprint: str = "",
) -> dict:
"""Apply v2 confidence gates to precomputed effective choice evidence."""
ranked = sorted(rows, key=lambda row: _minute_value(row["time"]))
"""Apply v2 confidence gates while preserving submitted candidate chronology."""
ranked = list(rows)
scores = sorted({row["score"] for row in ranked}, reverse=True)
top_score = scores[0] if scores else 0.0
second_score = scores[1] if len(scores) > 1 else top_score
@@ -318,7 +139,7 @@ def adjudicate_choice_rows(
reasons.append("missing_mandatory_layers")
if confidence == "low" and effective_answer_count < 3:
reasons.append("insufficient_effective_evidence")
fingerprint = request_fingerprint or _canonical_hash(list(ranked))
fingerprint = request_fingerprint or _canonical_hash(ranked)
return {
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")),
"confidence": confidence,
@@ -338,7 +159,9 @@ def adjudicate_choice_rows(
}
def _validated_choice_evidence(evidence_rows: list | None, candidates: Sequence[str]) -> tuple[list[dict], set[str]]:
def _validated_choice_evidence(
evidence_rows: list | None, candidates: Sequence[str],
) -> tuple[list[dict], set[str]]:
if not isinstance(evidence_rows, list):
raise ValueError("choice evidence must contain partition evidence")
if len(evidence_rows) > 10:
@@ -351,54 +174,66 @@ def _validated_choice_evidence(evidence_rows: list | None, candidates: Sequence[
}
for evidence in evidence_rows:
if not isinstance(evidence, dict) or set(evidence) != required:
field = "option_id" if isinstance(evidence, dict) and "option_id" in evidence else "partition evidence"
field = (
"option_id"
if isinstance(evidence, dict) and "option_id" in evidence
else "partition evidence"
)
raise ValueError(f"choice evidence contains invalid {field}")
try:
UUID(evidence["question_id"])
except (ValueError, TypeError, AttributeError) as exc:
raise ValueError("partition evidence question_id must be a UUID") from exc
if evidence["question_id"] in question_ids:
question_id = evidence["question_id"].strip() if isinstance(evidence["question_id"], str) else ""
if not question_id:
raise ValueError("partition evidence question identifier must be non-empty")
if question_id in question_ids:
raise ValueError("duplicate question evidence is not allowed")
if any(
not isinstance(evidence[key], str) or not evidence[key]
not isinstance(evidence[key], str) or not evidence[key].strip()
for key in ("opportunity_id", "partition_id")
):
raise ValueError("partition evidence identifier must be a non-empty string")
scores = evidence["candidate_scores"]
gain = evidence["information_gain"]
valid_scores = isinstance(scores, dict) and set(scores) == set(candidates) and all(
not isinstance(score, bool)
and isinstance(score, int | float)
and math.isfinite(score)
and score >= 0
for score in scores.values()
)
if not valid_scores:
raise ValueError("candidate scores must exactly match the submitted range")
_validate_scores(evidence, candidates)
if evidence["dimension_code"] not in SUPPORTED_DIMENSIONS:
raise ValueError("choice evidence dimension is unsupported")
if (
isinstance(gain, bool) or not isinstance(gain, int | float)
or not math.isfinite(gain) or gain < 0
):
raise ValueError("choice evidence information gain must be finite")
question_ids.add(evidence["question_id"])
question_ids.add(question_id)
dimensions.add(evidence["dimension_code"])
return evidence_rows, dimensions
def _validate_scores(evidence: dict, candidates: Sequence[str]) -> None:
import math
scores = evidence["candidate_scores"]
gain = evidence["information_gain"]
valid_scores = isinstance(scores, dict) and set(scores) == set(candidates) and all(
not isinstance(score, bool)
and isinstance(score, int | float)
and math.isfinite(score)
and score >= 0
for score in scores.values()
)
if not valid_scores:
raise ValueError("candidate scores must exactly match the submitted range")
if (
isinstance(gain, bool) or not isinstance(gain, int | float)
or not math.isfinite(gain) or gain < 0
):
raise ValueError("choice evidence information gain must be finite")
def score_choice_evidence(request: dict) -> dict:
"""Sum only strict server-resolved primary evidence, then adjudicate it."""
candidates = _candidate_times(request["birth_date"], request["start_time"], request["end_time"])
candidates = _candidate_times(
request["birth_date"], request["start_time"], request["end_time"]
)
evidence_rows, dimensions = _validated_choice_evidence(
request.get("choice_evidence"), candidates
)
totals = {candidate: 0.0 for candidate in candidates}
for evidence in evidence_rows:
scores = evidence["candidate_scores"]
gain = evidence["information_gain"]
for candidate in candidates:
totals[candidate] += float(scores[candidate]) * float(gain)
totals[candidate] += (
float(evidence["candidate_scores"][candidate])
* float(evidence["information_gain"])
)
rows: list[ChoiceRow] = [
{"time": candidate, "score": round(score, 6)} for candidate, score in totals.items()
]