"""Decision trace and explanation helpers for risk graph observations.""" from __future__ import annotations from dataclasses import dataclass, field from decimal import Decimal from typing import Any from .models import PeerBaseline, RiskEvidence RISK_SCORE_FORMULA = ( "0.35*S_rule + 0.25*S_anomaly + " "0.20*S_graph + 0.15*S_policy + 0.05*S_history" ) @dataclass(slots=True) class DecisionTrace: formula: str algorithm_version: str input_scores: dict[str, int] output_score: int decision_row: str feature_contributions_json: list[dict[str, Any]] uncertainty_reasons_json: list[str] explanation_template_key: str metadata: dict[str, Any] = field(default_factory=dict) def as_dict(self) -> dict[str, Any]: return { "formula": self.formula, "algorithm_version": self.algorithm_version, "input_scores": dict(self.input_scores), "output_score": self.output_score, "decision_row": self.decision_row, "feature_contributions_json": list(self.feature_contributions_json), "uncertainty_reasons_json": list(self.uncertainty_reasons_json), "explanation_template_key": self.explanation_template_key, **self.metadata, } class DecisionTraceBuilder: def build( self, *, algorithm_version: str, risk_signal: str, risk_level: str, raw_risk_score: int, risk_score: int, contribution_scores: dict[str, int], evidence: list[RiskEvidence], baseline: PeerBaseline, confidence: Decimal, metadata: dict[str, Any], ) -> DecisionTrace: return DecisionTrace( formula=RISK_SCORE_FORMULA, algorithm_version=algorithm_version, input_scores=contribution_scores, output_score=risk_score, decision_row=_decision_row(risk_score=risk_score, risk_level=risk_level), feature_contributions_json=_feature_contributions(contribution_scores), uncertainty_reasons_json=_uncertainty_reasons( raw_risk_score=raw_risk_score, risk_score=risk_score, evidence=evidence, baseline=baseline, confidence=confidence, metadata=metadata, ), explanation_template_key=f"risk.{risk_signal}.{risk_level}", metadata=metadata, ) def _decision_row(*, risk_score: int, risk_level: str) -> str: if risk_score >= 90: return f"{risk_level}:score>=90" if risk_score >= 70: return f"{risk_level}:70<=score<90" if risk_score >= 45: return f"{risk_level}:45<=score<70" return f"{risk_level}:score<45" def _feature_contributions(scores: dict[str, int]) -> list[dict[str, Any]]: weights = { "S_rule": Decimal("0.35"), "S_anomaly": Decimal("0.25"), "S_graph": Decimal("0.20"), "S_policy": Decimal("0.15"), "S_history": Decimal("0.05"), } rows = [] for key, score in scores.items(): weighted_score = Decimal(int(score or 0)) * weights.get(key, Decimal("0")) rows.append( { "feature": key, "score": int(score or 0), "weight": str(weights.get(key, Decimal("0"))), "weighted_score": float(weighted_score), } ) return sorted(rows, key=lambda item: item["weighted_score"], reverse=True) def _uncertainty_reasons( *, raw_risk_score: int, risk_score: int, evidence: list[RiskEvidence], baseline: PeerBaseline, confidence: Decimal, metadata: dict[str, Any], ) -> list[str]: reasons: list[str] = [] if risk_score < raw_risk_score: reasons.append("score_capped_by_gate") if baseline.scope == "insufficient_sample" or baseline.sample_size <= 0: reasons.append("peer_baseline_insufficient") if confidence < Decimal("0.55"): reasons.append("low_confidence") if len({item.source for item in evidence if item.source}) < 2: reasons.append("single_evidence_source") if metadata.get("ontology_gate") == "candidate_only": reasons.append("ontology_candidate_only") if metadata.get("data_quality_gate") not in {"", "passed", None}: reasons.append("data_quality_gate_not_passed") return list(dict.fromkeys(reasons))