File size: 14,047 Bytes
f234112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
"""Phase 7 β€” transparent risk fusion engine.

Fuses four inputs into a heuristic risk score and a LOW/MEDIUM/HIGH/CRITICAL
level:

- ``near_miss_probability`` β€” calibrated P(class_1) from the Phase 4
  near-miss/conflict model (kept semantically separate from the accident
  probability: different model, different event, never merged into one
  probability).
- ``accident_probability`` β€” calibrated accident probability from the Phase 6
  detector. Raw detector confidence is NEVER accepted as a probability here.
- ``severity``             β€” caller-supplied relative severity in [0, 1].
  Neither source dataset carries severity labels; the meaning of this number
  is owned by the caller. This engine does not measure or interpret severity.
- ``event_age_hours``      β€” caller-supplied event age; converted to a
  recency factor via an exponential half-life decay from config.

Missing inputs (``None``) are excluded and the weights of the PRESENT inputs
are renormalized, so an assessment is always computable from whatever
evidence exists β€” but an assessment with no inputs at all raises ValueError
instead of silently returning a confident-looking LOW.

The score is a HEURISTIC engineering fusion, not a probability and not a
scientifically validated quantity. All weights/thresholds come from
``src/config.py``; nothing is hardcoded here.
"""

from __future__ import annotations

import math
from typing import Any

try:  # src/ root import (risk_engine.py sits next to config.py)
    import config as _config
except ImportError:  # package-style import
    from . import config as _config

__all__ = ["assess_risk", "compute_risk_level", "recency_factor", "RiskInputError"]

#: Config keys this module reads, exposed so callers can build overrides.
CONFIG_KEYS = ("risk_weights", "risk_level_thresholds", "risk_score_scale", "recency_half_life_hours")

#: Fusion input keys, in canonical output order.
INPUT_KEYS = ("near_miss", "accident", "severity", "recency")


class RiskInputError(ValueError):
    """Raised for missing-evidence or invalid risk-fusion inputs."""


# ---------------------------------------------------------------------------
# Validation helpers β€” validate at the boundary, fail fast, never guess
# ---------------------------------------------------------------------------

def _validate_unit_interval(name: str, value: Any) -> float | None:
    """Validate a probability-like input; ``None`` means genuinely missing."""
    if value is None:
        return None
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise RiskInputError(f"{name} must be a number or None, got {type(value).__name__}")
    numeric = float(value)
    if not math.isfinite(numeric):
        raise RiskInputError(f"{name} must be finite, got {numeric}")
    if not 0.0 <= numeric <= 1.0:
        raise RiskInputError(f"{name} must be within [0, 1], got {numeric}")
    return numeric


def _validate_age_hours(value: Any) -> float | None:
    """Validate a caller-supplied event age in hours; ``None`` = missing."""
    if value is None:
        return None
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise RiskInputError(f"event_age_hours must be a number or None, got {type(value).__name__}")
    numeric = float(value)
    if not math.isfinite(numeric):
        raise RiskInputError(f"event_age_hours must be finite, got {numeric}")
    if numeric < 0.0:
        raise RiskInputError(f"event_age_hours must be >= 0, got {numeric}")
    return numeric


def _validate_weights(weights: dict[str, Any]) -> dict[str, float]:
    missing = [key for key in INPUT_KEYS if key not in weights]
    if missing:
        raise RiskInputError(f"risk_weights is missing keys: {missing}")
    validated: dict[str, float] = {}
    for key in INPUT_KEYS:
        raw = weights[key]
        if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not math.isfinite(float(raw)):
            raise RiskInputError(f"risk_weights[{key!r}] must be a finite number, got {raw!r}")
        if float(raw) <= 0.0:
            raise RiskInputError(f"risk_weights[{key!r}] must be > 0, got {raw}")
        validated[key] = float(raw)
    return validated


def _validate_thresholds(thresholds: dict[str, Any]) -> dict[str, float]:
    missing = [name for name in ("medium", "high", "critical") if name not in thresholds]
    if missing:
        raise RiskInputError(f"risk_level_thresholds is missing keys: {missing}")
    validated = {name: float(thresholds[name]) for name in ("medium", "high", "critical")}
    ordered = [validated["medium"], validated["high"], validated["critical"]]
    if any(not math.isfinite(v) for v in ordered):
        raise RiskInputError("risk_level_thresholds must be finite")
    if not 0 < ordered[0] < ordered[1] < ordered[2]:
        raise RiskInputError(
            "risk_level_thresholds must satisfy 0 < medium < high < critical, "
            f"got medium={ordered[0]}, high={ordered[1]}, critical={ordered[2]}"
        )
    return validated


def _validate_scale(scale: Any) -> float:
    if isinstance(scale, bool) or not isinstance(scale, (int, float)) or not math.isfinite(float(scale)):
        raise RiskInputError(f"risk_score_scale must be a finite number, got {scale!r}")
    if float(scale) <= 0.0:
        raise RiskInputError(f"risk_score_scale must be > 0, got {scale}")
    return float(scale)


def _resolve_config(overrides: dict[str, Any] | None) -> dict[str, Any]:
    """Merge caller overrides onto the src/config.py defaults."""
    resolved = {
        "risk_weights": dict(_config.RISK_WEIGHTS),
        "risk_level_thresholds": dict(_config.RISK_LEVEL_THRESHOLDS),
        "risk_score_scale": _config.RISK_SCORE_SCALE,
        "recency_half_life_hours": _config.RECENCY_HALF_LIFE_HOURS,
    }
    if overrides:
        unknown = [k for k in overrides if k not in CONFIG_KEYS]
        if unknown:
            raise RiskInputError(f"Unknown config override keys: {unknown}; expected {CONFIG_KEYS}")
        for key, value in overrides.items():
            if key in ("risk_weights", "risk_level_thresholds"):
                if not isinstance(value, dict):
                    raise RiskInputError(f"config override {key!r} must be a dict, got {type(value).__name__}")
                resolved[key].update(value)
            else:
                resolved[key] = value
    resolved["risk_weights"] = _validate_weights(resolved["risk_weights"])
    resolved["risk_level_thresholds"] = _validate_thresholds(resolved["risk_level_thresholds"])
    resolved["risk_score_scale"] = _validate_scale(resolved["risk_score_scale"])
    half_life = resolved["recency_half_life_hours"]
    if (
        isinstance(half_life, bool)
        or not isinstance(half_life, (int, float))
        or not math.isfinite(float(half_life))
        or float(half_life) <= 0.0
    ):
        raise RiskInputError(f"recency_half_life_hours must be a finite number > 0, got {half_life!r}")
    resolved["recency_half_life_hours"] = float(half_life)
    return resolved


# ---------------------------------------------------------------------------
# Fusion math
# ---------------------------------------------------------------------------

def recency_factor(event_age_hours: float | None, half_life_hours: float) -> float | None:
    """Exponential recency decay: ``0.5 ** (age / half_life)``.

    Age 0 -> 1.0, one half-life -> 0.5, and sufficiently large ages decay to
    0.0 (float underflow, which is the mathematically correct limit).
    """
    if event_age_hours is None:
        return None
    return 0.5 ** (float(event_age_hours) / float(half_life_hours))


def compute_risk_level(score: float, thresholds: dict[str, float]) -> str:
    """Map a fused score to its level (thresholds are inclusive lower bounds)."""
    if score >= thresholds["critical"]:
        return "CRITICAL"
    if score >= thresholds["high"]:
        return "HIGH"
    if score >= thresholds["medium"]:
        return "MEDIUM"
    return "LOW"


def assess_risk(
    near_miss_probability: float | None = None,
    accident_probability: float | None = None,
    severity: float | None = None,
    event_age_hours: float | None = None,
    config: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Fuse the four inputs into a transparent heuristic risk assessment.

    All inputs are optional; ``None`` means the input is genuinely missing and
    it is excluded, with the weights of the present inputs renormalized. At
    least one input must be present.

    Returns a JSON-serializable dict (see the module docstring for the input
    semantics and the caveats).
    """
    cfg = _resolve_config(config)
    weights = cfg["risk_weights"]
    thresholds = cfg["risk_level_thresholds"]
    scale = cfg["risk_score_scale"]

    values: dict[str, float | None] = {
        "near_miss": _validate_unit_interval("near_miss_probability", near_miss_probability),
        "accident": _validate_unit_interval("accident_probability", accident_probability),
        "severity": _validate_unit_interval("severity", severity),
        "recency": recency_factor(
            _validate_age_hours(event_age_hours), cfg["recency_half_life_hours"]
        ),
    }
    present = {key: value for key, value in values.items() if value is not None}
    if not present:
        raise RiskInputError(
            "No risk inputs provided β€” refused to invent a risk level. "
            f"Provide at least one of: {', '.join(INPUT_KEYS)}."
        )

    weight_total = sum(weights[key] for key in present)
    contributions = {key: weights[key] * value for key, value in present.items()}
    fused = sum(contributions.values())
    risk_score = scale * fused / weight_total
    risk_level = compute_risk_level(risk_score, thresholds)

    echo = {
        "near_miss": near_miss_probability,
        "accident": accident_probability,
        "severity": severity,
        "event_age_hours": event_age_hours,
    }
    missing = [key for key in INPUT_KEYS if key not in present]
    explanation = _build_explanation(
        echo=echo,
        present=present,
        missing=missing,
        weights=weights,
        contributions=contributions,
        weight_total=weight_total,
        risk_score=risk_score,
        thresholds=thresholds,
        half_life=cfg["recency_half_life_hours"],
    )

    return {
        "near_miss_probability": near_miss_probability,
        "accident_probability": accident_probability,
        "severity": severity,
        "event_age_hours": event_age_hours,
        "risk_score": round(risk_score, 4),
        "risk_level": risk_level,
        "present_inputs": sorted(present),
        "missing_inputs": missing,
        "weights_applied": {key: round(weights[key] / weight_total, 6) for key in present},
        "contributions": {key: round(scale * contributions[key] / weight_total, 4) for key in present},
        "recency_factor": None if values["recency"] is None else round(values["recency"], 6),
        "is_probability": False,
        "explanation": explanation,
    }


def _fmt(value: float | None) -> str:
    return "missing" if value is None else f"{value}"


def _build_explanation(
    echo: dict[str, float | None],
    present: dict[str, float],
    missing: list[str],
    weights: dict[str, float],
    contributions: dict[str, float],
    weight_total: float,
    risk_score: float,
    thresholds: dict[str, float],
    half_life: float,
) -> list[str]:
    """Human-readable, formula-transparent rationale for the assessment."""
    lines = [
        (
            "risk_score is a heuristic fusion score, NOT a probability and NOT "
            "scientifically validated; it must not be used to control traffic signals."
        ),
        (
            "Formula: risk_score = scale * sum(weight_i * value_i for present inputs) "
            "/ sum(weight_i for present inputs)."
        ),
        (
            "near_miss_probability and accident_probability are kept semantically "
            "separate: they come from different models measuring different events "
            "and are never merged into a single probability."
        ),
        (
            "accident_probability is the calibrated accident probability from the "
            "Phase 6 detector; raw detector confidence is never treated as a probability."
        ),
        (
            "near-miss label semantics are unconfirmed in the source data (class_1 is "
            "only statistically consistent with a conflict/near-miss); the value is "
            "echoed from the Phase 4 artifact without added interpretation."
        ),
        (
            "severity is a caller-supplied relative value in [0, 1]; this pipeline "
            "does not measure severity and does not define its meaning."
        ),
    ]
    for key in INPUT_KEYS:
        label = {
            "near_miss": "near_miss_probability",
            "accident": "accident_probability",
            "severity": "severity",
            "recency": f"recency factor from event_age_hours (half-life {half_life} h)",
        }[key]
        if key in present:
            display = echo["event_age_hours"] if key == "recency" else echo[key]
            lines.append(
                f"Input {label} = {_fmt(display)}"
                f" -> contribution {contributions[key]:.4f} (weight {weights[key]})."
            )
        else:
            lines.append(f"Input {label} is missing -> excluded from the fusion.")
    if missing:
        lines.append(
            "Missing inputs are excluded and weights are renormalized over the "
            f"present inputs (weight total used: {weight_total:.4f}); absence of an "
            "input is treated as absence of evidence, not as evidence of absence."
        )
    lines.append(
        f"Fused score {risk_score:.4f} vs thresholds (inclusive lower bounds) "
        f"medium={thresholds['medium']}, high={thresholds['high']}, "
        f"critical={thresholds['critical']}."
    )
    return lines