feat: add rectification event decision contract v2
This commit is contained in:
@@ -4,13 +4,21 @@ from typing import Any
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from scripts.rectification.candidate_feature_service import build_candidate_feature_snapshot
|
||||
from scripts.rectification.contracts import RectificationRequest
|
||||
from scripts.rectification.contracts import EVENT_CONTRACT_VERSION, RectificationRequest
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -18,25 +26,36 @@ from scripts.rectification.scoring_service import (
|
||||
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(request, 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]:
|
||||
built = build_event_contribution_matrix(request)
|
||||
rows = score_from_matrix(request, built)
|
||||
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)
|
||||
diagnostics = run_diagnostics(request, rows, built)
|
||||
diagnostic_values = run_diagnostics(scoring_request, rows, built)
|
||||
fingerprint = sha256(request)
|
||||
result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}"))
|
||||
candidate_decisions = build_candidate_decisions(rows, result_id=result_id)
|
||||
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
|
||||
return {
|
||||
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")),
|
||||
"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": [{
|
||||
@@ -45,17 +64,32 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
"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(request, spec_hash, built.get("static_contexts")),
|
||||
"diagnostics": diagnostics,
|
||||
"candidate_feature_snapshot": build_candidate_feature_snapshot(
|
||||
scoring_request, spec_hash, built.get("static_contexts")
|
||||
),
|
||||
"diagnostics": diagnostic_values,
|
||||
"robustness": {
|
||||
"neighbor_support_minutes": diagnostics["neighbor_support_minutes"],
|
||||
"leave_one_out_retention_rate": diagnostics["leave_one_event_out_retention_rate"],
|
||||
"leave_one_domain_out_retention_rate": diagnostics["leave_one_domain_out_retention_rate"],
|
||||
"date_sensitivity_retention_rate": diagnostics["date_sensitivity_retention_rate"],
|
||||
"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"],
|
||||
"can_confirm_exact_minute": False,
|
||||
"display_allowed": decision_receipt["display_allowed"],
|
||||
"selection_allowed": decision_receipt["selection_allowed"],
|
||||
"acceptance_allowed": decision_receipt["acceptance_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"],
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +98,23 @@ def diagnostics(request: RectificationRequest) -> dict[str, Any]:
|
||||
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"],
|
||||
"missing_layers": scored["missing_layers"],
|
||||
"can_confirm_exact_minute": False,
|
||||
"display_allowed": scored["display_allowed"],
|
||||
"selection_allowed": scored["selection_allowed"],
|
||||
"acceptance_allowed": scored["acceptance_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"],
|
||||
}
|
||||
|
||||
@@ -8,19 +8,41 @@ from typing import Any, Literal, NotRequired, TypedDict, cast
|
||||
|
||||
DatePrecision = Literal["day", "month", "quarter", "year", "range"]
|
||||
|
||||
EVENT_CONTRACT_VERSION = "rectification-event-contract-v2"
|
||||
|
||||
EVENT_KINDS: dict[str, frozenset[str]] = {
|
||||
"education": frozenset({
|
||||
"education_start", "education_completion", "education_interruption", "education_change",
|
||||
"education_milestone", # v1 compatibility
|
||||
}),
|
||||
"career": frozenset({
|
||||
"career_entry", "career_change", "promotion", "career_pressure", "career_exit", "business_start",
|
||||
}),
|
||||
"relationship": frozenset({
|
||||
"relationship_start", "relationship_commitment", "relationship_separation", "relationship_end",
|
||||
"relationship_change", # v1 compatibility
|
||||
}),
|
||||
"relocation": frozenset({"relocation", "foreign_move", "return", "home_change"}),
|
||||
"finance": frozenset({"finance_gain", "finance_loss", "income_change", "asset_change", "finance_change"}),
|
||||
"health": frozenset({"self_health_event", "pressure_period"}),
|
||||
"health_pressure": frozenset({"self_health_event", "pressure_period"}), # v1 domain compatibility
|
||||
"family": frozenset({"family_event"}),
|
||||
"other": frozenset({"other"}),
|
||||
}
|
||||
BACKGROUND_EVENT_KINDS = frozenset({"family_event", "other"})
|
||||
SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = {
|
||||
"education": frozenset({"education_milestone"}),
|
||||
"relocation": frozenset({"relocation"}),
|
||||
"relationship": frozenset({"relationship_start", "relationship_change"}),
|
||||
"career": frozenset({"career_change"}),
|
||||
"finance": frozenset({"finance_change"}),
|
||||
"health_pressure": frozenset({"self_health_event"}),
|
||||
domain: frozenset(kind for kind in kinds if kind not in BACKGROUND_EVENT_KINDS)
|
||||
for domain, kinds in EVENT_KINDS.items()
|
||||
if any(kind not in BACKGROUND_EVENT_KINDS for kind in kinds)
|
||||
}
|
||||
DATE_PRECISIONS = frozenset({"day", "month", "quarter", "year", "range"})
|
||||
_BIRTH_TIME_SOURCES = frozenset({"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"})
|
||||
_LOCAL_TIME_STATUSES = frozenset({"resolved", "not_provided", "ambiguous", "nonexistent"})
|
||||
_REQUEST_PROVENANCE_FIELDS = frozenset({"birth_time_source", "timezone_id", "timezone_source", "local_time_status"})
|
||||
_EVENT_PROVENANCE_FIELDS = frozenset({"date_source", "date_reliability", "date_corroboration", "date_conflict_status"})
|
||||
_EVENT_PROVENANCE_FIELDS = frozenset({
|
||||
"date_source", "date_reliability", "date_corroboration", "date_conflict_status",
|
||||
"source_turn_id", "subject",
|
||||
})
|
||||
_REQUEST_FIELDS = frozenset({"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events"}) | _REQUEST_PROVENANCE_FIELDS
|
||||
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
|
||||
_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z")
|
||||
@@ -38,6 +60,8 @@ class LifeEvent(TypedDict):
|
||||
date_reliability: NotRequired[str | None]
|
||||
date_corroboration: NotRequired[str | None]
|
||||
date_conflict_status: NotRequired[str | None]
|
||||
source_turn_id: NotRequired[str | None]
|
||||
subject: NotRequired[Literal["self", "family", "other"]]
|
||||
|
||||
|
||||
class RectificationRequest(TypedDict):
|
||||
@@ -57,6 +81,10 @@ class RectificationRequest(TypedDict):
|
||||
JsonObject = dict[str, Any]
|
||||
|
||||
|
||||
def is_scoreable_event(event: LifeEvent) -> bool:
|
||||
return event["event_kind"] not in BACKGROUND_EVENT_KINDS
|
||||
|
||||
|
||||
def _bounded_number(body: dict[str, Any], name: str, minimum: float, maximum: float) -> float:
|
||||
value = body.get(name)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
@@ -123,9 +151,9 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
except (ValueError, AttributeError) as exc:
|
||||
raise ValueError(f"events[{index}].id must be a UUID") from exc
|
||||
domain, event_kind = raw_event.get("domain"), raw_event.get("event_kind")
|
||||
if domain not in SCOREABLE_EVENT_KINDS:
|
||||
raise ValueError(f"events[{index}].domain is not scoreable")
|
||||
if event_kind not in SCOREABLE_EVENT_KINDS[cast(str, domain)]:
|
||||
if domain not in EVENT_KINDS:
|
||||
raise ValueError(f"events[{index}].domain is invalid")
|
||||
if event_kind not in EVENT_KINDS[cast(str, domain)]:
|
||||
raise ValueError(f"events[{index}].event_kind does not match domain")
|
||||
precision = raw_event.get("precision")
|
||||
if precision not in DATE_PRECISIONS:
|
||||
@@ -139,6 +167,13 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
summary = raw_event.get("summary", "")
|
||||
if not isinstance(summary, str) or len(summary) > 1_000:
|
||||
raise ValueError(f"events[{index}].summary must be a string up to 1000 characters")
|
||||
subject = raw_event.get("subject")
|
||||
if subject is None:
|
||||
subject = "family" if domain == "family" else "other" if domain == "other" else "self"
|
||||
if subject not in {"self", "family", "other"}:
|
||||
raise ValueError(f"events[{index}].subject is invalid")
|
||||
if event_kind not in BACKGROUND_EVENT_KINDS and subject != "self":
|
||||
raise ValueError(f"events[{index}].subject must be self for scoreable events")
|
||||
cleaned_event: dict[str, Any] = {
|
||||
"id": event_id,
|
||||
"domain": cast(str, domain),
|
||||
@@ -147,11 +182,21 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
"date_end": end_day.isoformat(),
|
||||
"precision": cast(DatePrecision, precision),
|
||||
"summary": summary.strip(),
|
||||
"subject": cast(Literal["self", "family", "other"], subject),
|
||||
}
|
||||
_copy_nullable_text(raw_event, cleaned_event, "date_source", f"events[{index}].date_source", 120)
|
||||
_copy_nullable_text(raw_event, cleaned_event, "date_reliability", f"events[{index}].date_reliability", 120)
|
||||
_copy_nullable_text(raw_event, cleaned_event, "date_corroboration", f"events[{index}].date_corroboration", 1_000)
|
||||
_copy_nullable_text(raw_event, cleaned_event, "date_conflict_status", f"events[{index}].date_conflict_status", 120)
|
||||
if "source_turn_id" in raw_event:
|
||||
source_turn_id = raw_event.get("source_turn_id")
|
||||
if source_turn_id is None:
|
||||
cleaned_event["source_turn_id"] = None
|
||||
else:
|
||||
try:
|
||||
cleaned_event["source_turn_id"] = str(uuid.UUID(str(source_turn_id)))
|
||||
except (ValueError, AttributeError) as exc:
|
||||
raise ValueError(f"events[{index}].source_turn_id must be null or a UUID") from exc
|
||||
cleaned_events.append(cast(LifeEvent, cleaned_event))
|
||||
|
||||
cleaned_request: dict[str, Any] = {
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal
|
||||
from typing import Any
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from scripts.active_rectification_events import CandidateScoreRow
|
||||
from scripts.rectification.contracts import (
|
||||
EVENT_CONTRACT_VERSION,
|
||||
RectificationRequest,
|
||||
is_scoreable_event,
|
||||
)
|
||||
from scripts.rectification.scoring_service import precision_weight
|
||||
|
||||
POLICY_VERSION = "rectification-candidate-policy-v2"
|
||||
RECEIPT_VERSION = "candidate-decision-receipt-v2"
|
||||
EXECUTION_LEDGER_VERSION = "rectification-execution-ledger-v2"
|
||||
SCORE_QUANTUM = Decimal("0.0001")
|
||||
TIE_ABSOLUTE_TOLERANCE = Decimal("0.0001")
|
||||
MIN_ACCEPTANCE_EVENTS = 3
|
||||
MIN_ACCEPTANCE_DOMAINS = 2
|
||||
MIN_DATE_QUALITY_MEAN = Decimal("0.65")
|
||||
MIN_DIAGNOSTIC_RETENTION = Decimal("0.75")
|
||||
MIN_ACCEPTANCE_MARGIN_PERCENT = Decimal("10")
|
||||
|
||||
_BAD_DATE_RELIABILITY = frozenset({"low", "uncertain", "unreliable"})
|
||||
_CLEAR_DATE_CONFLICT = frozenset({"", "none", "resolved", "no_conflict"})
|
||||
|
||||
|
||||
def _decimal(value: Any, default: str = "0") -> Decimal:
|
||||
if isinstance(value, bool):
|
||||
return Decimal(default)
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except Exception:
|
||||
return Decimal(default)
|
||||
|
||||
|
||||
def _quantized_score(row: CandidateScoreRow) -> Decimal:
|
||||
return _decimal(row.get("score")).quantize(SCORE_QUANTUM, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _relative_support(scores: Sequence[Decimal]) -> list[int]:
|
||||
if not scores:
|
||||
return []
|
||||
weights = [max(score, Decimal(0)) for score in scores]
|
||||
total = sum(weights, Decimal(0))
|
||||
if total == 0:
|
||||
base, remainder = divmod(100, len(scores))
|
||||
return [base + (1 if index < remainder else 0) for index in range(len(scores))]
|
||||
|
||||
exact = [weight * Decimal(100) / total for weight in weights]
|
||||
floors = [int(value.to_integral_value(rounding=ROUND_FLOOR)) for value in exact]
|
||||
remaining = 100 - sum(floors)
|
||||
order = sorted(
|
||||
range(len(scores)),
|
||||
key=lambda index: (-(exact[index] - Decimal(floors[index])), index),
|
||||
)
|
||||
for index in order[:remaining]:
|
||||
floors[index] += 1
|
||||
return floors
|
||||
|
||||
|
||||
def build_candidate_decisions(
|
||||
rows: Sequence[CandidateScoreRow],
|
||||
*,
|
||||
result_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
ranked = sorted(rows, key=lambda row: (-_quantized_score(row), row["time"]))
|
||||
public_rows = ranked[:3]
|
||||
supports = _relative_support([_quantized_score(row) for row in public_rows])
|
||||
all_scores = [_quantized_score(row) for row in ranked]
|
||||
decisions = []
|
||||
for index, row in enumerate(public_rows):
|
||||
score = _quantized_score(row)
|
||||
tied_minute_count = sum(
|
||||
abs(score - other) <= TIE_ABSOLUTE_TOLERANCE
|
||||
for other in all_scores
|
||||
)
|
||||
decisions.append({
|
||||
"candidate_id": str(uuid5(NAMESPACE_URL, f"{POLICY_VERSION}:{result_id}:{row['time']}")),
|
||||
"rank": index + 1,
|
||||
"time": row["time"],
|
||||
"relative_support": supports[index],
|
||||
"tied_minute_count": tied_minute_count,
|
||||
})
|
||||
return decisions
|
||||
|
||||
|
||||
def _gate(passed: bool, **details: Any) -> dict[str, Any]:
|
||||
return {"passed": passed, **details}
|
||||
|
||||
|
||||
def _date_quality(events: Sequence[dict[str, Any]]) -> dict[str, Any]:
|
||||
weights = [_decimal(precision_weight(str(event["precision"]))) for event in events]
|
||||
total = sum(weights, Decimal(0))
|
||||
mean = total / Decimal(len(weights)) if weights else Decimal(0)
|
||||
low_reliability = sorted(
|
||||
event["id"]
|
||||
for event in events
|
||||
if str(event.get("date_reliability") or "").strip().lower() in _BAD_DATE_RELIABILITY
|
||||
)
|
||||
unresolved_conflicts = sorted(
|
||||
event["id"]
|
||||
for event in events
|
||||
if str(event.get("date_conflict_status") or "").strip().lower() not in _CLEAR_DATE_CONFLICT
|
||||
)
|
||||
passed = bool(events) and mean >= MIN_DATE_QUALITY_MEAN and not low_reliability and not unresolved_conflicts
|
||||
return _gate(
|
||||
passed,
|
||||
precision_weight_total=float(total),
|
||||
precision_weight_mean=float(mean.quantize(SCORE_QUANTUM, rounding=ROUND_HALF_UP)),
|
||||
minimum_precision_weight_mean=float(MIN_DATE_QUALITY_MEAN),
|
||||
low_reliability_event_ids=low_reliability,
|
||||
unresolved_conflict_event_ids=unresolved_conflicts,
|
||||
)
|
||||
|
||||
|
||||
def _diagnostic_quality(diagnostics: dict[str, Any]) -> dict[str, Any]:
|
||||
retention_names = (
|
||||
"leave_one_event_out_retention_rate",
|
||||
"leave_one_domain_out_retention_rate",
|
||||
"date_sensitivity_retention_rate",
|
||||
)
|
||||
retentions = {name: _decimal(diagnostics.get(name)) for name in retention_names}
|
||||
margin = _decimal(diagnostics.get("primary_secondary_margin_percent"))
|
||||
passed = (
|
||||
all(value >= MIN_DIAGNOSTIC_RETENTION for value in retentions.values())
|
||||
and margin >= MIN_ACCEPTANCE_MARGIN_PERCENT
|
||||
)
|
||||
return _gate(
|
||||
passed,
|
||||
minimum_retention=float(MIN_DIAGNOSTIC_RETENTION),
|
||||
minimum_margin_percent=float(MIN_ACCEPTANCE_MARGIN_PERCENT),
|
||||
margin_percent=float(margin),
|
||||
**{name: float(value) for name, value in retentions.items()},
|
||||
)
|
||||
|
||||
|
||||
def build_decision_receipt(
|
||||
request: RectificationRequest,
|
||||
candidate_decisions: Sequence[dict[str, Any]],
|
||||
built: dict[str, Any],
|
||||
diagnostics: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
scoreable_events = [event for event in request["events"] if is_scoreable_event(event)]
|
||||
domains = sorted({event["domain"] for event in scoreable_events})
|
||||
candidate_presence = _gate(bool(candidate_decisions), candidate_count=len(candidate_decisions))
|
||||
event_quality = _gate(
|
||||
len(scoreable_events) >= MIN_ACCEPTANCE_EVENTS,
|
||||
scoreable_event_count=len(scoreable_events),
|
||||
minimum=MIN_ACCEPTANCE_EVENTS,
|
||||
)
|
||||
domain_diversity = _gate(
|
||||
len(domains) >= MIN_ACCEPTANCE_DOMAINS,
|
||||
scoreable_domain_count=len(domains),
|
||||
minimum=MIN_ACCEPTANCE_DOMAINS,
|
||||
domains=domains,
|
||||
)
|
||||
date_quality = _date_quality(scoreable_events)
|
||||
top_tied_count = candidate_decisions[0]["tied_minute_count"] if candidate_decisions else 0
|
||||
unique_top = _gate(top_tied_count == 1, tied_minute_count=top_tied_count)
|
||||
diagnostic_quality = _diagnostic_quality(diagnostics)
|
||||
required_layers = _gate(
|
||||
not built.get("missing_layers"),
|
||||
missing_layers=sorted(built.get("missing_layers") or []),
|
||||
)
|
||||
|
||||
acceptance_allowed = all((
|
||||
candidate_presence["passed"],
|
||||
event_quality["passed"],
|
||||
domain_diversity["passed"],
|
||||
date_quality["passed"],
|
||||
unique_top["passed"],
|
||||
diagnostic_quality["passed"],
|
||||
))
|
||||
margin = _decimal(diagnostics.get("primary_secondary_margin_percent"))
|
||||
if acceptance_allowed and margin >= Decimal("20"):
|
||||
overall_confidence = "high"
|
||||
elif acceptance_allowed:
|
||||
overall_confidence = "medium"
|
||||
else:
|
||||
overall_confidence = "low"
|
||||
|
||||
acceptance_reasons: list[str] = []
|
||||
for passed, reason in (
|
||||
(candidate_presence["passed"], "no_candidates"),
|
||||
(event_quality["passed"], "insufficient_events"),
|
||||
(domain_diversity["passed"], "insufficient_domain_diversity"),
|
||||
(date_quality["passed"], "low_date_quality"),
|
||||
(unique_top["passed"], "tied_top_score"),
|
||||
(diagnostic_quality["passed"], "insufficient_diagnostic_stability"),
|
||||
):
|
||||
if not passed:
|
||||
acceptance_reasons.append(reason)
|
||||
confirmation_reasons = []
|
||||
if not required_layers["passed"]:
|
||||
confirmation_reasons.append("missing_mandatory_layers")
|
||||
confirmation_reasons.extend(("engine_exact_confirmation_not_granted", "external_validation_not_passed"))
|
||||
reasons = [*acceptance_reasons, *confirmation_reasons]
|
||||
|
||||
representative = candidate_decisions[0] if candidate_decisions else None
|
||||
exact_confirmation = {
|
||||
"passed": False,
|
||||
"fail_closed": True,
|
||||
"engine_granted": False,
|
||||
"external_validation_status": "not_evaluated",
|
||||
"required_scoreable_events": 4,
|
||||
"required_scoreable_domains": 3,
|
||||
"reason": "engine_and_external_validation_must_explicitly_pass",
|
||||
}
|
||||
receipt = {
|
||||
"receipt_version": RECEIPT_VERSION,
|
||||
"contract_version": "v2",
|
||||
"event_contract_version": EVENT_CONTRACT_VERSION,
|
||||
"policy_version": POLICY_VERSION,
|
||||
"decision_policy_version": POLICY_VERSION,
|
||||
"display_allowed": bool(candidate_decisions),
|
||||
"selection_allowed": acceptance_allowed,
|
||||
"acceptance_allowed": acceptance_allowed,
|
||||
"confirmation_allowed": False,
|
||||
"accept_allowed": acceptance_allowed,
|
||||
"confirm_allowed": False,
|
||||
"representative_candidate_id": representative["candidate_id"] if representative else None,
|
||||
"representative_time": representative["time"] if representative else None,
|
||||
"overall_confidence": overall_confidence,
|
||||
"margin_percent": float(margin),
|
||||
"reasons": reasons,
|
||||
"acceptance_reasons": acceptance_reasons,
|
||||
"confirmation_reasons": confirmation_reasons,
|
||||
"tie_policy": {
|
||||
"score_quantum": float(SCORE_QUANTUM),
|
||||
"absolute_tolerance": float(TIE_ABSOLUTE_TOLERANCE),
|
||||
"rounding": "ROUND_HALF_UP",
|
||||
},
|
||||
"gates": {
|
||||
"candidate_presence": candidate_presence,
|
||||
"event_quality": event_quality,
|
||||
"domain_diversity": domain_diversity,
|
||||
"date_quality": date_quality,
|
||||
"unique_top": unique_top,
|
||||
"diagnostic_quality": diagnostic_quality,
|
||||
"required_layers": required_layers,
|
||||
"exact_confirmation": exact_confirmation,
|
||||
},
|
||||
}
|
||||
return receipt
|
||||
|
||||
|
||||
def build_execution_ledger(
|
||||
request: RectificationRequest,
|
||||
built: dict[str, Any],
|
||||
diagnostics: dict[str, Any],
|
||||
candidate_decisions: Sequence[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
matrix = built.get("matrix") or {}
|
||||
date_sensitivity = {
|
||||
item.get("event_id"): item
|
||||
for item in built.get("date_sensitivity") or []
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
entries: list[dict[str, Any]] = []
|
||||
all_layers: set[str] = set()
|
||||
for event in request["events"]:
|
||||
candidates = matrix.get(event["id"], {})
|
||||
layers = sorted({
|
||||
layer
|
||||
for contribution in candidates.values()
|
||||
for layer in contribution.get("technique_layers", [])
|
||||
})
|
||||
all_layers.update(layers)
|
||||
sensitivity = date_sensitivity.get(event["id"], {})
|
||||
scoreable = is_scoreable_event(event)
|
||||
entries.append({
|
||||
"ledger_version": EXECUTION_LEDGER_VERSION,
|
||||
"stage": "event_scoring",
|
||||
"status": "executed" if scoreable and candidates else "not_executed" if scoreable else "retained_not_scored",
|
||||
"source": "python-engine",
|
||||
"event_id": event["id"],
|
||||
"domain": event["domain"],
|
||||
"event_kind": event["event_kind"],
|
||||
"date_precision": event["precision"],
|
||||
"precision_weight": precision_weight(event["precision"]),
|
||||
"sampled_date_count": len(sensitivity.get("sample_dates") or []),
|
||||
"candidate_count": len(candidates),
|
||||
"technique_layers": layers,
|
||||
})
|
||||
for layer in sorted(all_layers):
|
||||
entries.append({
|
||||
"ledger_version": EXECUTION_LEDGER_VERSION,
|
||||
"stage": "technique_layer",
|
||||
"method": layer,
|
||||
"status": "executed",
|
||||
"source": "python-engine",
|
||||
})
|
||||
entries.extend((
|
||||
{
|
||||
"ledger_version": EXECUTION_LEDGER_VERSION,
|
||||
"stage": "candidate_ranking",
|
||||
"status": "executed" if candidate_decisions else "not_executed",
|
||||
"source": "python-decision-policy",
|
||||
"candidate_count": len(candidate_decisions),
|
||||
},
|
||||
{
|
||||
"ledger_version": EXECUTION_LEDGER_VERSION,
|
||||
"stage": "diagnostics",
|
||||
"status": "executed" if diagnostics else "not_executed",
|
||||
"source": "python-engine",
|
||||
"metrics": sorted(diagnostics),
|
||||
},
|
||||
))
|
||||
return entries
|
||||
@@ -3,16 +3,54 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import date, timedelta
|
||||
from functools import lru_cache
|
||||
from typing import Any, Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from scripts.active_rectification_event_engine import compute_candidate_static_contexts, compute_event_candidate_rows
|
||||
from scripts.active_rectification_events import CandidateScoreRow
|
||||
from scripts.rectification.contracts import LifeEvent, RectificationRequest
|
||||
from scripts.rectification.contracts import LifeEvent, RectificationRequest, is_scoreable_event
|
||||
|
||||
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-2"
|
||||
INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4"
|
||||
PRECISION_WEIGHTS = {
|
||||
"day": 1.0,
|
||||
"month": 0.8,
|
||||
"quarter": 0.65,
|
||||
"year": 0.5,
|
||||
"range": 0.35,
|
||||
}
|
||||
|
||||
_ENGINE_KIND_BY_NATIVE_KIND: dict[str, tuple[str, str]] = {
|
||||
"education_start": ("education", "education_milestone"),
|
||||
"education_completion": ("education", "education_milestone"),
|
||||
"education_interruption": ("education", "education_milestone"),
|
||||
"education_change": ("education", "education_milestone"),
|
||||
"education_milestone": ("education", "education_milestone"),
|
||||
"career_entry": ("career", "career_change"),
|
||||
"career_change": ("career", "career_change"),
|
||||
"promotion": ("career", "career_change"),
|
||||
"career_pressure": ("career", "career_change"),
|
||||
"career_exit": ("career", "career_change"),
|
||||
"business_start": ("career", "career_change"),
|
||||
"relationship_start": ("relationship", "relationship_start"),
|
||||
"relationship_commitment": ("relationship", "relationship_start"),
|
||||
"relationship_separation": ("relationship", "relationship_change"),
|
||||
"relationship_end": ("relationship", "relationship_change"),
|
||||
"relationship_change": ("relationship", "relationship_change"),
|
||||
"relocation": ("relocation", "relocation"),
|
||||
"foreign_move": ("relocation", "relocation"),
|
||||
"return": ("relocation", "relocation"),
|
||||
"home_change": ("relocation", "relocation"),
|
||||
"finance_gain": ("finance", "finance_change"),
|
||||
"finance_loss": ("finance", "finance_change"),
|
||||
"income_change": ("finance", "finance_change"),
|
||||
"asset_change": ("finance", "finance_change"),
|
||||
"finance_change": ("finance", "finance_change"),
|
||||
"self_health_event": ("health_pressure", "self_health_event"),
|
||||
"pressure_period": ("health_pressure", "self_health_event"),
|
||||
}
|
||||
|
||||
|
||||
def _parse(value: str) -> date:
|
||||
@@ -58,6 +96,7 @@ def sample_event_dates(event: LifeEvent) -> list[str]:
|
||||
|
||||
|
||||
def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_date: str) -> dict[str, Any]:
|
||||
engine_domain, _ = _ENGINE_KIND_BY_NATIVE_KIND[event["event_kind"]]
|
||||
return {
|
||||
"birth_date": request["birth_date"],
|
||||
"start_time": request["start_time"],
|
||||
@@ -66,8 +105,8 @@ def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_dat
|
||||
"lon": request["lon"],
|
||||
"tz": request["tz"],
|
||||
"events": [{
|
||||
"id": event["id"], "domain": event["domain"],
|
||||
"event_kind": event.get("event_kind", event["domain"]),
|
||||
"id": event["id"], "domain": engine_domain,
|
||||
"event_kind": event["event_kind"],
|
||||
"date": sampled_date, "precision": "day", "summary": event.get("summary", ""),
|
||||
}],
|
||||
}
|
||||
@@ -82,57 +121,103 @@ def _cached_rows(serialized: str) -> tuple[CandidateScoreRow, ...]:
|
||||
return tuple(compute_event_candidate_rows(json.loads(serialized)))
|
||||
|
||||
|
||||
_RELATIONSHIP_SUPPORT_RULES = (
|
||||
_SUPPORT_RULES = (
|
||||
"functional_benefic_auxiliary",
|
||||
"arudha_auxiliary",
|
||||
"ashtakavarga_target_house_support_auxiliary",
|
||||
"shadbala_sthana_drik_naisargika_support_auxiliary",
|
||||
"controlled_transit_jupiter_domain_house",
|
||||
)
|
||||
_RELATIONSHIP_CHANGE_RULES = (
|
||||
_PRESSURE_RULES = (
|
||||
"functional_malefic_auxiliary",
|
||||
"ashtakavarga_target_house_pressure_auxiliary",
|
||||
"shadbala_sthana_drik_naisargika_pressure_auxiliary",
|
||||
"controlled_transit_saturn_domain_house",
|
||||
)
|
||||
|
||||
_KIND_SEMANTICS: dict[str, tuple[int, float]] = {
|
||||
"education_start": (1, 1.0),
|
||||
"education_completion": (1, 1.2),
|
||||
"education_interruption": (-1, 1.0),
|
||||
"education_change": (0, 1.0),
|
||||
"career_entry": (1, 1.0),
|
||||
"career_change": (0, 1.0),
|
||||
"promotion": (1, 1.2),
|
||||
"career_pressure": (-1, 1.0),
|
||||
"career_exit": (-1, 1.2),
|
||||
"business_start": (1, 1.2),
|
||||
"relationship_start": (1, 1.0),
|
||||
"relationship_commitment": (1, 1.2),
|
||||
"relationship_separation": (-1, 1.0),
|
||||
"relationship_end": (-1, 1.2),
|
||||
"relationship_change": (-1, 1.0),
|
||||
"relocation": (0, 1.0),
|
||||
"foreign_move": (1, 1.0),
|
||||
"return": (1, 0.8),
|
||||
"home_change": (0, 1.0),
|
||||
"finance_gain": (1, 1.0),
|
||||
"finance_loss": (-1, 1.0),
|
||||
"income_change": (0, 1.0),
|
||||
"asset_change": (0, 1.0),
|
||||
"self_health_event": (-1, 1.0),
|
||||
"pressure_period": (-1, 1.2),
|
||||
}
|
||||
|
||||
def _relationship_kind_factor(event_kind: str, rule_ids: Sequence[str]) -> float:
|
||||
if event_kind not in {"relationship_start", "relationship_change"}:
|
||||
return 1.0
|
||||
support = sum(any(rule.endswith(marker) for marker in _RELATIONSHIP_SUPPORT_RULES) for rule in rule_ids)
|
||||
change = sum(any(rule.endswith(marker) for marker in _RELATIONSHIP_CHANGE_RULES) for rule in rule_ids)
|
||||
direction = support - change if event_kind == "relationship_start" else change - support
|
||||
return max(0.8, min(1.2, 1 + 0.08 * direction))
|
||||
|
||||
def precision_weight(precision: str) -> float:
|
||||
return PRECISION_WEIGHTS[precision]
|
||||
|
||||
|
||||
def _event_kind_factor(event_kind: str, rule_ids: Sequence[str]) -> float:
|
||||
direction, intensity = _KIND_SEMANTICS.get(event_kind, (0, 1.0))
|
||||
support = sum(any(rule.endswith(marker) for marker in _SUPPORT_RULES) for rule in rule_ids)
|
||||
pressure = sum(any(rule.endswith(marker) for marker in _PRESSURE_RULES) for rule in rule_ids)
|
||||
semantic_signal = direction * (support - pressure) * intensity
|
||||
return max(0.8, min(1.2, 1 + 0.08 * semantic_signal))
|
||||
|
||||
|
||||
def _kind_adjusted_evidence(event: LifeEvent, evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
if event["domain"] != "relationship":
|
||||
return evidence
|
||||
event_kind = event["event_kind"]
|
||||
rules = list(evidence["rule_ids"])
|
||||
direction, _ = _KIND_SEMANTICS.get(event_kind, (0, 1.0))
|
||||
semantic_label = "support" if direction > 0 else "pressure" if direction < 0 else "change"
|
||||
return {
|
||||
**evidence,
|
||||
"rule_ids": [*rules, f"event_kind_profile:{event_kind}"],
|
||||
"points": round(float(evidence["points"]) * _relationship_kind_factor(event_kind, rules), 4),
|
||||
"rule_ids": [*rules, f"event_kind_profile:{event_kind}:{semantic_label}"],
|
||||
"points": round(
|
||||
float(evidence["points"])
|
||||
* _event_kind_factor(event_kind, rules)
|
||||
* precision_weight(event["precision"]),
|
||||
4,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def scoreable_request(request: RectificationRequest) -> RectificationRequest:
|
||||
return {**request, "events": [event for event in request["events"] if is_scoreable_event(event)]}
|
||||
|
||||
|
||||
def build_event_contribution_matrix(
|
||||
request: RectificationRequest,
|
||||
row_provider: Callable[[dict[str, Any]], Sequence[CandidateScoreRow]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
static_contexts = None if row_provider is not None else compute_candidate_static_contexts(request)
|
||||
scoring_request = scoreable_request(request)
|
||||
if not scoring_request["events"]:
|
||||
return {
|
||||
"candidate_times": [], "matrix": {}, "date_sensitivity": [],
|
||||
"missing_layers": [], "static_contexts": None,
|
||||
}
|
||||
static_contexts = None if row_provider is not None else compute_candidate_static_contexts(scoring_request)
|
||||
provider = row_provider or (lambda value: compute_event_candidate_rows(value, static_contexts=static_contexts))
|
||||
matrix: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict)
|
||||
missing_layers: set[str] = set()
|
||||
date_sensitivity: list[dict[str, Any]] = []
|
||||
candidate_grid: list[str] | None = None
|
||||
for event in request["events"]:
|
||||
for event in scoring_request["events"]:
|
||||
samples = sample_event_dates(event)
|
||||
sample_rows = []
|
||||
for sampled in samples:
|
||||
rows = list(provider(_legacy_request(request, event, sampled)))
|
||||
rows = list(provider(_legacy_request(scoring_request, event, sampled)))
|
||||
sample_rows.append([
|
||||
{**row, "score": adjusted["points"], "evidence": [adjusted]}
|
||||
for row in rows
|
||||
@@ -184,6 +269,8 @@ def score_from_matrix(request: RectificationRequest, built: dict[str, Any]) -> l
|
||||
for candidate_time in built["candidate_times"]:
|
||||
evidence = []
|
||||
for event in request["events"]:
|
||||
if not is_scoreable_event(event):
|
||||
continue
|
||||
contribution = built["matrix"][event["id"]][candidate_time]
|
||||
evidence.append({
|
||||
"event_id": event["id"], "domain": event["domain"], "candidate_time": candidate_time,
|
||||
|
||||
Reference in New Issue
Block a user