Spaces:
Running on Zero
Running on Zero
| """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) | |
| 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]) | |