File size: 8,135 Bytes
92d53f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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])