File size: 15,211 Bytes
e2c9d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# Implementation spec: SAPLMA, HaMI, LLM-Check, ICR Probe

All four are **implemented**. Every architecture detail
below was checked against the source paper or the authors' released code, not
recalled. Fidelity argumentation lives in `docs/baseline_fidelity_audit.md`
(Β§4 SAPLMA, Β§5 HaMI); this file is the build record and the remaining work.

## Decisions already made (do not relitigate)

1. **Pipeline:** all three go into the baseline runner
   (`scripts/train_faithful_baselines_dgx.py`) as new `--detector` values, under
   the same suite/protocol contract as EPR/Lookback/MVA. NOT scored ad-hoc from
   the LatticeSpike signal cache. Protocol consistency is what makes the
   fidelity argument defensible, and the same hidden-state extraction pass also
   unblocks WEPR's missing causal number.
2. **HaMI keeps sequence-level supervision.** Learning token scores from
   bag-level labels IS its contribution. This belongs in the "preserved" column
   of the adaptation table, not "adapted".
3. **What we equalise is the evaluation** β€” same token set, same causal horizon,
   same metrics β€” never the methods themselves.
4. **The default detector set is frozen.** `STRICT_DETECTORS` still resolves to
   EPR/WEPR/Lookback/MVA-CRF, so a stored command that reproduced a published
   run keeps reproducing it. Both new methods are opt-in via `--detector`.

## 1. SAPLMA β€” implemented

`baselines/saplma/`, detector name `saplma`.

Azaria & Mitchell, *"The Internal State of an LLM Knows When It's Lying"*,
**EMNLP Findings 2023**, [aclanthology.org/2023.findings-emnlp.68](https://aclanthology.org/2023.findings-emnlp.68/),
arXiv:2304.13734.

Independently validated: PARALLAX (2026) found SAPLMA one of only **two**
reliable internal-state probes out of 22 evaluated (0.911 AUROC on HaluBench),
with MIND, HaloScope, ACT, SEPs and HalluShift all near-chance (0.49-0.62).

**Verified from paper section 4, quoted:** "three hidden layers with decreasing
numbers of hidden units (256, 128, 64), all utilizing ReLU activations. The
final layer is a sigmoid output. We use the Adam optimizer. We do not fine-tune
any of these hyper-parameters for this task. The classifier is trained for 5
epochs."

**Correction to an earlier draft of this file:** SAPLMA does **not** average
activations across tokens. It reads the activations at the **last token of the
statement** β€” one 4096-vector per statement. The earlier note here said
"averages across all tokens" and was wrong.

**Layer:** the paper builds five probes (last, 28th, 24th, 20th, middle of 32)
and finds 20/32 best for OPT-6.7b, the **middle layer** best for LLaMA2-7b. We
probe the middle layer of a 16-layer model, matching the LLaMA finding.

**Unstated in the paper:** learning rate and batch size. The reference is Keras,
so its defaults (1e-3, 32) are used β€” the only reading consistent with the
paper's claim that nothing was tuned.

**Supervision:** `--saplma-supervision response` (default, faithful) fits one
row per answer β€” last-token activations against the response label β€” and
localises per token at scoring time, exactly as EPR is handled here.
`--saplma-supervision token` fits every labelled token and reports under
`saplma_token_supervised`, so the more generous configuration is on the table
rather than assumed away.

**NOTE:** `baselines/hidden_state_probe/` is **NOT** SAPLMA β€” it is logistic
regression on the last layer with `class_weight="balanced"`, correctly classed a
control in `fidelity_matrix.md`. Do not relabel it.

## 2. HaMI β€” implemented

`baselines/hami/`, detector name `hami`.

*"Robust Hallucination Detection in LLMs via Adaptive Token Selection"*,
**NeurIPS 2025**, [arXiv:2504.07863](https://arxiv.org/abs/2504.07863),
code [github.com/mala-lab/HaMI](https://github.com/mala-lab/HaMI).

**Architecture (`hami.py`):**

```
Linear(n_features, 256) -> BatchNorm1d -> ReLU -> Dropout(0.3) -> Linear(256, 1) -> Sigmoid
```

**Loss (`hami.py:MIL_loss`), transcribed exactly:**

```python
k = int(length * k_ratio) + 1                      # k_ratio = 0.1
loss += (1. - s_topk_mean**2 + nor_max[i]**2)      # index-matched bag pairs
smooth += torch.sum((s[:-1] - s[1:])**2) * 0.0008  # bags longer than 8 only
loss = loss / len(n_len_list) + smooth / len(n_len_list)
```

The abnormal term is index-matched against the normal term, which is why the
reference draws equal-sized batches from two loaders. Note `1. - m**2 + n**2`,
not `(1 - m)**2`.

**Training (`train.py`, `main.py`):** Adam lr 1e-3, weight decay 5e-4;
`ReduceLROnPlateau(mode='min', factor=0.1, patience=20, min_lr=1e-6)` stepped on
the training loss; batch 128 bags per polarity with `drop_last=True`; **100
optimizer steps, not 100 passes**; validation after every step, keeping the best
model; validation metric is bag-level AUROC where the bag score is the mean of
its top-k token scores (`validate.py`).

**Layer selection:** `main.py` loops over every layer, trains a separate model
for each, and records validation results per layer β€” the layer is a
validation-selected hyperparameter. We run one layer per invocation
(`--hidden-layer`), so a layer sweep is a sweep of runs.

**Two things the reference does that we cannot:**
- Its default `--mode se` scales hidden states by a probability from clustering
  several sampled responses per question. Our dataset has one response per
  prompt, so we use the reference's `ori` (unweighted) mode. HaMI is therefore
  evaluated below its published capability, and that must be stated.
- Bag labels come from answer correctness (`acc`), with ambiguous `acc == 2.0`
  bags dropped under `is_refined`. Ours: abnormal if the response contains any
  hallucinated token, normal if none, uncertain-only responses dropped.

**Step budget caution.** 100 steps is tied to their dataset size. On our larger
training split it sees a fraction of the data. `--hami-steps` exists for this;
any run that raises it must say so in the results, and the reference's own
best-on-validation rule is what makes a longer budget defensible.

## 3. LLM-Check β€” implemented

`baselines/llm_check/`, detector names `token_logprob`,
`llm_check_logit_entropy`, `llm_check_window_entropy`, `llm_check_hidden`,
`llm_check_attention`.

Sriramanan et al., **NeurIPS 2024**, code
[GaurangSriramanan/LLM_Check_Hallucination_Detection](https://github.com/GaurangSriramanan/LLM_Check_Hallucination_Detection).
Five training-free scores from one forward pass, all verified against
`common_utils.py`:

```python
perplexity:      log_softmax(logits)[t-1, tok[t]]        # mean then exp
logit_entropy:   softmax(topk(logits, 50)); (-p*log p).mean()     # own row
window_entropy:  softmax(logits);           (-p*log p).mean()     # own row, w=1
hidden:          log(svdvals(Z.T @ J @ Z + 1e-3*I)).mean()        # J centres features
attention:       sum_h log(diag(A_h))                             # over the answer block
```

Note `.mean()` not `.sum()` in both entropies, and note that the reference reads
the realized-token log-probability at row `t-1` but both entropies at row `t`.
Extraction stores rows `prompt_len-1 .. prompt_len+T-1` so index `t` is the
predicting row and index `t+1` is the own row; both alignments are then served
from one array.

**Three things that are easy to get wrong:**

1. **Orientation is fitted, not fixed.** The authors' notebook negates
   `perplexity` and the attention score, negates neither entropy, and ships no
   orientation for the hidden score. An earlier draft here hard-coded the sign
   from the paper's prose and put both eigen scores at ~0.20 AUROC; the fitted
   bit puts them at ~0.80. One bit per score, fitted on **train**.
2. **`centered_svd_val` centres over the FEATURE axis.** The reference passes
   `(dim, n_tokens)` and builds a `dim x dim` centering matrix, so `Sigma` comes
   out `n_tokens x n_tokens`. Easy to transpose by mistake.
3. **The attention diagonal changes meaning with the row.** Own row = the
   token's self-attention (post-emission). Predicting row = attention to the
   most recent prefix token (causal). Not the same quantity; say so.

Hidden score is pooled per token over the **expanding causal prefix**
(`--llm-check-hidden-window 0`, the default), which is the direct causal
restriction of the reference's whole-response score and adds no hyperparameter.

`token_logprob` is LLM-Check's per-token perplexity term. It gets its own row
because it is the floor every method must clear, but it is computed once, not
double-counted under two names.

## 4. ICR Probe β€” implemented

`baselines/icr_probe/`, detector name `icr_probe`.

Zhang et al., **ACL 2025 Main**, code
[XavierZhang2002/ICR_Probe](https://github.com/XavierZhang2002/ICR_Probe)
(`src/icr_score.py`, `src/icr_probe.py`, `src/utils.py`, `src/config.py`).
An earlier draft of this file said there was no code release. There is.

**ICR Score, per layer `a` and scored row `r`** (`compute_icr`):

```python
dx      = H[a + 1][r] - H[a][r]        # consecutive layers
targets = H[a][topk_idx]               # the LOWER layer, easy to get wrong
w       = (dx * targets).sum(1) / (targets.norm(1) + 1e-8)
ICR     = js_divergence(w, attn_row[topk_idx])   # standardise -> softmax -> JSD
```

**Five details that a paper-only implementation gets wrong:**

1. **Attention is the softmaxed probability, not the pre-softmax score.**
   Equation 3 defines `(Q.K)/sqrt(d)`; the code uses HF `output_attentions`.
2. **`top_p` silently overrides `top_k`.** `top_k = top_k if top_p is None else
   int(top_p * N)`, and the README passes both, so `top_k=20` is dead code and
   k is a *fraction* of the sequence.
3. **Projection targets come from the lower layer**, `origin_hidden_states[a]`,
   not the layer whose update is being measured.
4. **The block mask drops response-to-prompt attention entirely**, keeping only
   prompt-to-prompt and response-to-response.
5. **The induction-head fallback fires on a count test**, not a threshold: if
   fewer than `n_heads // 8` heads pass, the verdict is discarded and the top
   `n_heads // 8` by skewness are taken instead.

**Probe** (`src/utils.py`, `src/config.py`): `L -> 128 -> 64 -> 32 -> 1`, each
hidden layer BatchNorm1d + LeakyReLU(0.01) + Dropout(0.3), sigmoid; Kaiming
uniform `a=0.01`; BCE, Adam(1e-3, wd 1e-5), ReduceLROnPlateau(min, 0.5, 5), 100
epochs, batch 16, best validation loss.

**The featurisation is ours, unavoidably.** `_load_data` is referenced but never
defined in the release, and the README says the training code must be adapted.
Default `--icr-supervision response` mean-pools ICR over the answer's tokens and
fits the response label; scoring feeds each token's own layer-vector to the
fitted probe. `--icr-supervision token` is the token-supervised variant.

**Cost β€” the planning error worth remembering.** ICR needs consecutive-layer
hidden states *and* per-layer attention, which looked like it demanded a
12-24 GB multi-layer cache. It does not: the score reduces to one scalar per
(token, layer) at extraction time, so the cached feature is `(T, L)` β€” about
**15 MB for the whole dataset**. Always check what the feature reduces to before
budgeting for what the computation reads.

**The AWS L4 signal cache cannot serve ICR**, for three independent reasons:
it samples layers 4/8/12/16 so no consecutive difference exists; it stores no
attention, only the collapsed `attn_ent` scalar; and it covers answer prediction
rows only, while the projection needs all N tokens. Checked before spending
instance time.

## What was wired into the runner

- `DETECTOR_SAPLMA` / `DETECTOR_HAMI`, plus `HIDDEN_STATE_DETECTORS` so all
  three hidden-state methods share one extraction path and one feature array.
- `--hidden-alignment {generated_token_position, generation_prediction_position}`.
  The causal suite sets the predicting row automatically; native suites keep the
  token's own row. Both new detectors are in `CAUSAL_CAPABLE_DETECTORS`.
- `hidden_sequence_training_example()` carries the whole answer's hidden states
  plus **both** label granularities, so each detector picks the one its paper
  uses. U rows stay in the matrix and are excluded by the detector, because both
  methods index into the sequence (last token, adjacent-token smoothing) and
  deleting rows would silently change what "last" and "adjacent" mean.
- `--saplma-supervision/-epochs/-lr/-batch-size`,
  `--hami-steps/-batch-size/-k-ratio/-lr/-weight-decay`.
- Summary JSON gains a `hidden_state_detectors` block recording layer,
  alignment, and each method's fitted-run facts (bag counts, selected step,
  validation AUROC, training positive rate).

## Cross-cutting cautions from this codebase

- **Never add a key to a cache spec unconditionally.** The fingerprint is a hash
  of the whole spec, so a new key invalidates every cache built before it
  existed. This broke the 5.1 GB LatticeSpike base cache in r6 and cost a night.
  `hidden_alignment` is therefore added to `feature_cache_spec` **only when
  non-default**, and there is a test asserting its absence from legacy specs.
- **Never bump `EXTRACTION_CODE_VERSION`** unless extraction genuinely changes
  for existing configurations; it invalidates every cache on every box.
- **Note that `selected_features` is already part of the fingerprint.** Running
  `--detector saplma hami` will not reuse a cache built for the four-detector
  set. Extract the two hidden-state methods in their own pass β€” they need no
  attention output, so it is the cheap pass, not the expensive one.
- **Probe before long runs.** A 5-record extraction probe caught two shape bugs
  in r6 that would each have surfaced hours in.
- **Verify the training path, not just extraction.** r6's failures were both in
  training/cache-validation, which the extraction probe did not cover.
  `tests/test_train_faithful_baselines_dgx.py` now fits and scores both new
  detectors end to end on synthetic rows for exactly this reason.
- **Compare single-seed arms against the same seed's control.** Per-seed
  validation AP for the current best LatticeSpike config: seed 42 = 0.2785,
  seed 43 = 0.2632, seed 44 = 0.2633; 3-seed mean 0.2683 +/- 0.0088. Comparing a
  seed-42 arm to the mean inflates it by ~0.010 and turned three nulls into
  apparent passes in r5.

## Where things stand

- Current best documented in `ablations/BEST_MODEL.md`
  (TEST AP 0.2741 +/- 0.0238, AUROC 0.9283 +/- 0.0040, 3 seeds).
- Baseline audit in `docs/baseline_fidelity_audit.md`; SAPLMA is Β§4, HaMI Β§5.
- Adaptation table rows for both are in `scripts/gen_results_tables.py`
  (`tab:adaptation`). The fair results table has **no rows for them yet** β€”
  add rows only once runs exist.
- Rounds archived in `ablations/r0`, `r1`, `r2`, `r3_5`, `r4`, `r5_stage1`,
  `multilayer`, each with `ANALYSIS.md`.
- **r6b results are still unfetched and unarchived** (4 arms: lookback s42/s43,
  head128 s43/s44) in `outputs/sweep_r6` on the 5090.
- WEPR still has no causal-matched number; the same hidden-state extraction pass
  should fix that.
- Planned external evaluation: RAGTruth and Mu-SHROOM (team already working on
  both). FAVA parked but `hallucination_labelling/span_align.py` already
  supports it.