"""Conversation-level features: turn probabilities and a gate verdict, in, one vector out. from lighthouse.model.features import featurise, FEATURE_GROUPS The turn classifier reads one message at a time. Tiers are a property of a conversation. This module is the bridge, and its design is the answer to the question a judge will ask: *why not just take the highest-risk turn and call it a day?* Because three of the corpus's hardest cases defeat that. `syn-050` and `syn-064` are T3 conversations in which no single turn is severe: the risk is that the student is sliding across seven turns. `syn-079` opens flat and crosses into T4 only in the fifth turn. A max-only model reads all three as their calmest moment. ## The groups, and why each exists * **gate** — the deterministic verdict, as features rather than as an override. The floor is applied separately and unconditionally in ``gate.safety.apply_verdict``; what these features add is the *soft* information the floor throws away, such as "two weak weapon hits and nothing else", which no floor should act on but a tier head reasonably can. * **turn** — the aggregate shape of the classifier's opinion: max, mean, top-k mean, how many turns cleared tau, and the per-class ceiling. Top-k mean sits between max (one turn, hostage to a single false positive) and mean (diluted to nothing by the logistics turns that make up most of a real transcript). * **trend** — direction, not level. Slope, where the peak sits, and the second-half minus first-half delta. This is the group that has to earn its place on `syn-050`. * **history** — prior sessions. **Inert on this corpus**, which is single-session throughout, and reported as such in the ablation rather than quietly dropped. Plumbed now because the day 6 console seeds returning students and the day 7 clustering needs the same field. Every feature is finite and bounded, and ``featurise`` asserts it. A NaN slope from a one-turn conversation would train silently and fail in production. """ from __future__ import annotations from dataclasses import dataclass from typing import Sequence import numpy as np from lighthouse import config from lighthouse.gate.safety import SafetyVerdict from lighthouse.model import markers from lighthouse.taxonomy import GATE_CATEGORIES, HARM_ORDER, Harm #: Group name -> ordered feature names. The ablation selects whole groups, so the order #: here is the column order of the design matrix. FEATURE_GROUPS: dict[str, list[str]] = { "gate": [ "gate_score", "gate_has_floor", "gate_floor_rank", "gate_is_high", "gate_is_grey", "gate_hit_count", "gate_max_weight", *[f"gate_{c.value}" for c in GATE_CATEGORIES], ], "turn": [ "max_risk", "mean_risk", "topk_mean_risk", "frac_above_tau", "count_above_tau", *[f"max_p_{h.value}" for h in HARM_ORDER], ], "trend": [ "trend_slope", "last_risk", "second_half_delta", "peak_position", "n_turns", ], "report": markers.NAMES, "history": [ "prior_sessions", "prior_max_tier_rank", ], } ALL_FEATURES: list[str] = [n for names in FEATURE_GROUPS.values() for n in names] #: The ablation ladder. Each rung adds information, so a rung that does not move the #: numbers is a rung that should be cut, and the table is how we find out. ABLATIONS: dict[str, tuple[str, ...]] = { "gate-only": ("gate",), "turn-only": ("turn",), "gate+turn": ("gate", "turn"), "+trend": ("gate", "turn", "trend"), "+report": ("gate", "turn", "trend", "report"), "full": ("gate", "turn", "trend", "report", "history"), } _NONE_INDEX = HARM_ORDER.index(Harm.NONE) @dataclass(frozen=True) class Features: values: dict[str, float] def vector(self, groups: Sequence[str] = tuple(FEATURE_GROUPS)) -> np.ndarray: return np.array( [self.values[name] for g in groups for name in FEATURE_GROUPS[g]], dtype=np.float64, ) def names_for(groups: Sequence[str]) -> list[str]: return [name for g in groups for name in FEATURE_GROUPS[g]] def risk_series(probs: np.ndarray) -> np.ndarray: """Per-turn risk as ``1 - P(none)``. One number per turn rather than six, because the tier question is "how concerning", not "which flavour". The flavour survives separately in the per-class max features, where it can distinguish a T3 identity attack from a T4 self-harm disclosure. """ return 1.0 - probs[:, _NONE_INDEX] def _slope(risk: np.ndarray) -> float: """OLS slope of risk against turn index, per turn. Returns 0.0 below ``TREND_MIN_TURNS``. A slope fitted through two points is not a trend, it is a line, and it would be the largest-magnitude feature in the matrix for the shortest and least informative conversations. """ n = len(risk) if n < config.TREND_MIN_TURNS: return 0.0 x = np.arange(n, dtype=np.float64) x = x - x.mean() denominator = float((x * x).sum()) if denominator == 0.0: return 0.0 return float((x * (risk - risk.mean())).sum() / denominator) def featurise( probs: np.ndarray, verdict: SafetyVerdict, turns: Sequence[str] = (), *, prior_sessions: int = 0, prior_max_tier_rank: int = 0, ) -> Features: """``probs`` is (n_turns, 6) in ``HARM_ORDER``; ``verdict`` and ``turns`` cover the same turns. ``turns`` may be omitted, in which case the report markers are all zero.""" probs = np.asarray(probs, dtype=np.float64) if probs.ndim != 2 or probs.shape[1] != len(HARM_ORDER): raise ValueError(f"expected (n_turns, {len(HARM_ORDER)}), got {probs.shape}") if len(probs) == 0: raise ValueError("a conversation with no student turns cannot be featurised") risk = risk_series(probs) n = len(risk) order = np.sort(risk)[::-1] k = min(config.TOP_K_TURNS, n) above = risk > config.CONCERN_THRESHOLD fired = {h.category for h in verdict.indicators} values: dict[str, float] = { # gate "gate_score": float(verdict.score), "gate_has_floor": float(verdict.floor is not None), "gate_floor_rank": float(verdict.floor.rank if verdict.floor else 0), "gate_is_high": float(verdict.is_high), "gate_is_grey": float(verdict.is_grey), "gate_hit_count": float(len(verdict.indicators)), "gate_max_weight": float(max((h.weight for h in verdict.indicators), default=0.0)), **{f"gate_{c.value}": float(c in fired) for c in GATE_CATEGORIES}, # turn "max_risk": float(risk.max()), "mean_risk": float(risk.mean()), "topk_mean_risk": float(order[:k].mean()), "frac_above_tau": float(above.mean()), "count_above_tau": float(above.sum()), **{f"max_p_{h.value}": float(probs[:, i].max()) for i, h in enumerate(HARM_ORDER)}, # trend "trend_slope": _slope(risk), "last_risk": float(risk[-1]), "second_half_delta": float( risk[n // 2 :].mean() - risk[: max(1, n // 2)].mean() ), # Where the peak sits, normalised. A conversation that ends at its worst moment is # a different clinical picture from one that peaked in turn 1 and settled. "peak_position": float(int(risk.argmax()) / (n - 1)) if n > 1 else 0.0, "n_turns": float(n), # report markers — duration, frequency, avoidance, disclosure barrier **markers.extract(turns).values, # history "prior_sessions": float(prior_sessions), "prior_max_tier_rank": float(prior_max_tier_rank), } missing = set(ALL_FEATURES) - set(values) assert not missing, f"featurise did not produce {missing}" for name, value in values.items(): assert np.isfinite(value), f"{name} is not finite: {value}" return Features(values) def design_matrix( feature_rows: Sequence[Features], groups: Sequence[str] ) -> np.ndarray: return np.vstack([f.vector(groups) for f in feature_rows])