54rt1n commited on
Commit
488472c
·
verified ·
1 Parent(s): bd4ee5c

Upload folder using huggingface_hub

Browse files
Files changed (7) hide show
  1. .gitignore +3 -0
  2. README.md +373 -0
  3. config.json +181 -0
  4. model.safetensors +3 -0
  5. modeling.py +1155 -0
  6. tokenizer.json +0 -0
  7. tokenizer_config.json +30 -0
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ _local/
2
+ __pycache__/
3
+ *.pyc
README.md ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - en
4
+ license: apache-2.0
5
+ tags:
6
+ - text-classification
7
+ - refusal-detection
8
+ - safety
9
+ - modernbert
10
+ - multi-label-classification
11
+ - multi-task-learning
12
+ - thinking-models
13
+ base_model:
14
+ - answerdotai/ModernBERT-base
15
+ datasets:
16
+ - Magpie-Align/Magpie-Qwen2.5-Pro-1M-v0.1
17
+ - Magpie-Align/Magpie-Pro-300K-Filtered
18
+ - mlabonne/harmful_behaviors
19
+ - canbingol/harmful-prompts
20
+ - cpagac/venomx-pentesting-harmful
21
+ - grimjim/AILuminate-v1.0-demo-prompt-set-EN
22
+ pipeline_tag: text-classification
23
+ ---
24
+
25
+ # Brigand-Refusal-ModernBERT
26
+
27
+ The problem I kept running into is a signal quality problem. When you're building anything that needs to understand model behavior — a reward model, a replay filter, an alignment pipeline — a binary comply/refuse label isn't enough. You want to know *why* a model refused: was it a stock policy deflection, a genuine legal concern, an ethical objection, or a `bridge_refusal` that half-complied before pulling back? And with reasoning models, the `<think>` block and the final response often tell very different stories, so I built a second encoder stream specifically to read the thinking separately.
28
+
29
+ Brigand-Refusal-ModernBERT classifies LLM outputs across four dimensions simultaneously: stance (comply or refuse), response family (19 response types), thought family (11 chain-of-thought patterns), and document type. The two streams share weights but see the prompt through different lenses — one formatted as `[PROMPT] / [RESPONSE]`, the other as `[PROMPT] / [THOUGHT]`. All four heads are trained jointly on [ModernBERT-base](https://huggingface.co/answerdotai/ModernBERT-base) in a single forward pass.
30
+
31
+ Training was done on a single NVIDIA GeForce RTX 5090.
32
+
33
+ It was designed to serve as a training signal and inference-time filter in safety-critical LLM pipelines.
34
+
35
+ ---
36
+
37
+ ## Requirements
38
+
39
+ - Python 3.10+
40
+ - `torch`
41
+ - `transformers>=5.2.0`
42
+ - `safetensors`
43
+
44
+ ```bash
45
+ pip install torch "transformers>=5.2.0" safetensors
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Quick Start
51
+
52
+ The model is self-contained in `modeling.py` and uses the standard HuggingFace
53
+ `AutoModel` interface with `trust_remote_code=True`.
54
+
55
+ ```python
56
+ import sys
57
+ from transformers import AutoModel, AutoTokenizer
58
+
59
+ model = AutoModel.from_pretrained(
60
+ "mbukowski/Brigand-Refusal-ModernBERT",
61
+ trust_remote_code=True,
62
+ )
63
+ tokenizer = AutoTokenizer.from_pretrained("mbukowski/Brigand-Refusal-ModernBERT")
64
+
65
+ # predict() is bundled in modeling.py and loaded automatically
66
+ predict = sys.modules[type(model).__module__].predict
67
+
68
+ preds = predict(model, tokenizer, [
69
+ {
70
+ "prompt": "How do I whittle a knife?",
71
+ "response": "Start with a straight-grained softwood like basswood.",
72
+ },
73
+ {
74
+ "prompt": "How do I whittle a knife so I can kill my sister?",
75
+ "response": "I'm not able to help with that.",
76
+ },
77
+ ])
78
+
79
+ for p in preds:
80
+ print(p["stance"], p["bank"])
81
+ # compliance design_reference
82
+ # refusal meta_refusal
83
+ ```
84
+
85
+ The `predict()` helper handles all tokenization, thought-splitting, batching,
86
+ and dual-stream formatting.
87
+
88
+ The bundled tokenizer already includes the control markers as real special
89
+ tokens:
90
+ `[PROMPT]`, `[RESPONSE]`, `[THOUGHT]`, `<NO_THOUGHT>`, and `<EMPTY_THOUGHT>`.
91
+ This release was warm-started from the prior promoted checkpoint, then tuned on
92
+ family-gap anchors and paired with calibrated family decoding. It closes the
93
+ family probe suite (`24/24`) and thought probe suite (`9/9`) while keeping the
94
+ token-boundary improvements.
95
+
96
+ ---
97
+
98
+ ## Label Schema
99
+
100
+ The model outputs four prediction groups simultaneously.
101
+
102
+ ### Stance *(binary)*
103
+
104
+ | Label | Meaning |
105
+ |---|---|
106
+ | `compliance` | The response fulfills the request |
107
+ | `refusal` | The response declines or redirects |
108
+
109
+ ### Response Family *(multi-label, top prediction = `bank`)*
110
+
111
+ | Family | Stance | Description |
112
+ |---|---|---|
113
+ | `stock_refusal` | refusal | Generic policy refusal |
114
+ | `legal_refusal` | refusal | Refusal citing legal constraints |
115
+ | `ethical_refusal` | refusal | Refusal citing ethical concerns |
116
+ | `meta_refusal` | refusal | Refusal about the nature of the request |
117
+ | `bridge_refusal` | refusal | Partial refusal with compliant follow-through |
118
+ | `harmful_procedural` | compliance | Compliant response to a harmful how-to |
119
+ | `harmful_explanatory` | compliance | Compliant response explaining harmful content |
120
+ | `safe_defensive` | compliance | Compliance with a safety caveat |
121
+ | `safe_explanatory` | compliance | Factual/explanatory compliance |
122
+ | `safe_redirective` | compliance | Compliant but steers to a safer framing |
123
+ | `educational_explainer` | compliance | Educational/academic content |
124
+ | `design_reference` | compliance | Reference or lookup responses |
125
+ | `creative_writing` | compliance | Fiction, poetry, roleplay |
126
+ | `code_help_tutor` | compliance | Code assistance |
127
+ | `short_utility_micro` | compliance | Short factual/utility responses |
128
+ | `greeting_chat_micro` | compliance | Greetings and casual chat |
129
+ | `multilingual_general_help` | compliance | General help in non-English |
130
+ | `multilingual_factoid_translate` | compliance | Translation / factoid in non-English |
131
+ | `ambiguous_reject` | — | Catch-all when no family exceeds threshold |
132
+
133
+ The bundled decoder calibrates family outputs before returning them. It can:
134
+ - suppress generic overlays like `stock_refusal` when a more specific refusal subtype is already active
135
+ - recover underfired specific banks such as `legal_refusal`, `meta_refusal`, `safe_defensive`, and `code_help_tutor` when the logits and prompt/response cues agree
136
+ - prefer specific harmless banks over broader overlays like `short_utility_micro`
137
+
138
+ ### Thought Family *(multi-label, applied to `<think>` content)*
139
+
140
+ `no_thought` · `empty_thought` · `nonempty_thought` · `policy_thought` ·
141
+ `legal_thought` · `harm_thought` · `meta_thought` · `safe_alternative_thought` ·
142
+ `ethical_thought` · `uncertainty_thought` · `stepwise_thought`
143
+
144
+ ### Document Type *(softmax)*
145
+
146
+ `plain_text` · `markdown`
147
+
148
+ ---
149
+
150
+ ## Architecture
151
+
152
+ ```
153
+ prompt + response_text ──▶ [PROMPT] / [RESPONSE] format ──▶ ModernBERT-base
154
+ │
155
+ masked mean pooling
156
+ │
157
+ response_pooled ──▶ stance head
158
+ ──▶ family head
159
+ ──▶ document_type head
160
+
161
+ prompt + thought_text ──▶ [PROMPT] / [THOUGHT] format ──▶ ModernBERT-base
162
+ │
163
+ masked mean pooling
164
+ │
165
+ thought_pooled ──▶ thought_family head
166
+ ```
167
+
168
+ The two streams share weights (single encoder). Thought content is extracted from
169
+ `<think>...</think>` blocks before tokenization; responses without a `<think>` block
170
+ use the `<NO_THOUGHT>` sentinel token.
171
+
172
+ Those boundary markers are now registered tokenizer special tokens rather than decomposed text fragments.
173
+
174
+ **Stance augmentation:** the final stance prediction is blended with the
175
+ family-level refusal/compliance signal (`stance_family_scale=0.6`) to reduce
176
+ ambiguous boundary cases.
177
+
178
+ **Loss weights:**
179
+
180
+ | Head | Weight |
181
+ |---|---|
182
+ | `stance` | 1.2 |
183
+ | `family` | 2.0 |
184
+ | `thought_family` | 1.2 |
185
+ | `document_type` | 0.3 |
186
+
187
+ ---
188
+
189
+ ## Dataset
190
+
191
+ **Version:** `v3_inline_harmless_family` — harmless subclasses promoted into the
192
+ main family head; `<think>` blocks handled as a first-class input stream.
193
+
194
+ | Split | Examples |
195
+ |---|---|
196
+ | Train | 11,821 |
197
+ | Val | 1,469 |
198
+ | Edge (manual review bucket) | 1,024 |
199
+
200
+ **Curation ledger:** 20,641 reviewed promotions and 841 removals. After dedupe, family-capped sampling, and the train/val split, the final dataset is 11,821 train and 1,469 val, with a separate 1,024-example edge bucket excluded from training.
201
+
202
+ Getting the data right took longer than training the model.
203
+
204
+ <details>
205
+ <summary>Family distribution (val set)</summary>
206
+
207
+ | Family | Val count |
208
+ |---|---|
209
+ | short_utility_micro | 516 |
210
+ | stock_refusal | 341 |
211
+ | creative_writing | 397 |
212
+ | design_reference | 252 |
213
+ | legal_refusal | 193 |
214
+ | safe_explanatory | 124 |
215
+ | meta_refusal | 119 |
216
+ | bridge_refusal | 116 |
217
+ | harmful_procedural | 93 |
218
+ | educational_explainer | 33 |
219
+ | safe_defensive | 30 |
220
+ | code_help_tutor | 27 |
221
+ | ethical_refusal | 51 |
222
+ | greeting_chat_micro | 18 |
223
+ | safe_redirective | 12 |
224
+ | harmful_explanatory | 12 |
225
+ | multilingual_* | 2 |
226
+
227
+ </details>
228
+
229
+ <details>
230
+ <summary>Data sources</summary>
231
+
232
+ The final classifier set was mined from rollout outputs and reviewed imports, but
233
+ those rollouts were driven by a smaller set of upstream prompt corpora.
234
+
235
+ Harmless / general-help prompt sources used to generate rollout mining pools:
236
+ - [`Magpie-Align/Magpie-Qwen2.5-Pro-1M-v0.1`](https://huggingface.co/datasets/Magpie-Align/Magpie-Qwen2.5-Pro-1M-v0.1)
237
+ - [`Magpie-Align/Magpie-Pro-300K-Filtered`](https://huggingface.co/datasets/Magpie-Align/Magpie-Pro-300K-Filtered)
238
+
239
+ Harmful / refusal prompt sources used to generate rollout mining pools:
240
+ - [`mlabonne/harmful_behaviors`](https://huggingface.co/datasets/mlabonne/harmful_behaviors)
241
+ - [`canbingol/harmful-prompts`](https://huggingface.co/datasets/canbingol/harmful-prompts)
242
+ - [`cpagac/venomx-pentesting-harmful`](https://huggingface.co/datasets/cpagac/venomx-pentesting-harmful)
243
+ - [`grimjim/AILuminate-v1.0-demo-prompt-set-EN`](https://huggingface.co/datasets/grimjim/AILuminate-v1.0-demo-prompt-set-EN)
244
+
245
+ Those upstream corpora fed the mined sources that were used in the final build.
246
+ </details>
247
+
248
+ ---
249
+
250
+ ## How It Was Built
251
+
252
+ The model stopped improving when I treated seams as generic class imbalance. It started improving reliably when I treated each one as a forensic data problem.
253
+
254
+ The iteration loop that worked:
255
+
256
+ 1. Score the current checkpoint and find the *exact* seam that's failing — not "family accuracy is low" but specifically which source family is bleeding into which target (`stock_refusal -> legal_refusal`, `educational_explainer -> design_reference`, etc.)
257
+ 2. Audit both sides: the misses and the attractor that's pulling them over
258
+ 3. Classify the problem — label bug, attractor problem, mixed row, or eval bug — because they call for different fixes
259
+ 4. Apply the smallest defensible correction: remove bad rows, preserve overlays on mixed refusal rows, import narrow contrast only if the seam truly lacks support
260
+ 5. Rebuild and rerun the exact audit that motivated the change
261
+ 6. Retrain only if the training set changed — a lot of early iterations wasted compute retraining when the issue was val-only
262
+
263
+ ### Seam Analysis Workflow
264
+
265
+ When a family or stance boundary starts drifting, the process that worked was:
266
+
267
+ 1. Run a full validation rescore and sort by head loss instead of looking only at headline accuracy.
268
+ 2. Collapse the errors into concrete directional seams such as `stock_refusal -> legal_refusal` or `safe_defensive -> stock_refusal`.
269
+ 3. Audit both sides of the seam:
270
+ - the rows getting pulled away from the target class
271
+ - the rows on the wrong side that are acting as attractors
272
+ 4. Separate four failure types before touching data:
273
+ - label bug
274
+ - mixed row that needs multi-label preservation
275
+ - real support gap
276
+ - decoder / evaluation bug
277
+ 5. Use phrase ablation and MLM-head probing on the seam rows to identify what is actually driving the miss.
278
+ - If removing a tail phrase flips the bank, the tail is the attractor.
279
+ - If the MLM `prefix_mask` probe already surfaces the right concept tokens, the encoder knows the concept and the problem is boundary calibration rather than representation.
280
+ 6. Fix the seam with the smallest change that matches the diagnosis:
281
+ - remove malformed or truncated rows
282
+ - relabel contradictory reviewed rows
283
+ - preserve `stock_refusal` on explicit-opener hybrids
284
+ - add narrow anchors only for the specific boundary that is missing support
285
+ 7. Rebuild the dataset and rerun the exact seam audit before retraining.
286
+ 8. Retrain only after the seam definition is cleaner, then gate promotion on:
287
+ - raw val stance audit
288
+ - targeted family and thought probes
289
+ - whether the original seam actually closed
290
+
291
+ The main discipline was to fix the *reason* a seam existed, not just the rows that happened to show up in the first error sample.
292
+
293
+ ### What Failed
294
+
295
+ The most instructive failure was a large import of long legal/security-tail rows. Many had explicit refusal openers but also long `national security / public safety` explanatory tails, and I'd omitted the `stock_refusal` overlay. The model learned to overread those rows as `safe_explanatory`, and the exact seam I was trying to fix got worse. Lesson: never bulk-import a mixed seam tranche before splitting it into clean categories.
296
+
297
+ Repeated problems also came from artifact-heavy reviewed rows — harmless explainers with wrong labels, truncated safe pivots labeled as `stock_refusal`. The right move was always to remove them, not to train through them.
298
+
299
+ ### The MLM Diagnostic
300
+
301
+ One of the most useful late-stage tools was an encoder-side logit-lens probe. After fine-tuning, I loaded the refusal classifier's encoder weights back into a base ModernBERT MLM model and ran seam rows through it, reading the top predicted tokens at positions in the target span.
302
+
303
+ The point wasn't text generation — it was to answer a very specific question: *does the encoder already represent the right concept, or is it blind to it?* If the encoder was already producing policy/refusal token clusters on a failing row, the problem was in the classifier boundary, not the representation, and the fix needed to be narrow rather than a broad data expansion. That distinction mattered a lot on several late seams.
304
+
305
+ The `prefix_mask` mode — inserting a single `[MASK]` at each position and rerunning the full encoder — was more careful than reading from fully-contextualized positions, because it avoids faking a causal mask on an already-bidirectional hidden state.
306
+
307
+ ---
308
+
309
+ ## Evaluation
310
+
311
+ Validation set (1,469 examples, warm-start epoch 1):
312
+
313
+ | Metric | Score |
314
+ |---|---|
315
+ | Val loss | **0.0841** |
316
+ | Stance accuracy | **100.00%** |
317
+ | Family accuracy | **99.03%** |
318
+ | Thought family accuracy | **99.81%** |
319
+ | Document type accuracy | **100.00%** |
320
+
321
+ **Stance audit (val set):** 0 errors in either direction.
322
+
323
+ <details>
324
+ <summary>Warm-start training history</summary>
325
+
326
+ This release was warm-started from a prior promoted checkpoint with the special-token tokenizer already in place, then refined on the family-gap seam cleanup. One fine-tuning epoch was enough.
327
+
328
+ | Epoch | Split | Loss | Stance | Family | Thought family | Doc type |
329
+ |---|---|---|---|---|---|---|
330
+ | 1 | train | 0.0253 | 99.77% | 99.77% | 99.94% | 99.99% |
331
+ | **1** | **val** | **0.0841** | **100.00%** | **99.03%** | **99.81%** | **100.00%** |
332
+
333
+ </details>
334
+
335
+ ---
336
+
337
+ ## Inference Notes
338
+
339
+ **Input format:** pass `{"prompt": ..., "response": ...}` dicts to `predict()`.
340
+ The `response` field may contain raw `<think>...</think>` output from a thinking
341
+ model — the helper extracts and routes it automatically.
342
+
343
+ **Batching:** `predict()` handles batching internally. Default `batch_size=32`,
344
+ `max_length=2048`.
345
+
346
+ **No `transformers` pipeline:** use `predict()` or call `model.forward()` directly.
347
+
348
+ ---
349
+
350
+ ## Known Limitations
351
+
352
+ - Multilingual coverage is minimal. `multilingual_general_help` and `multilingual_factoid_translate` have very few val examples and should be treated as best-effort.
353
+
354
+ ---
355
+
356
+ ## Correspondence
357
+
358
+ Martin Bukowski (models at martinbukowski dot com)
359
+
360
+ ---
361
+
362
+ ## Citation
363
+
364
+ If you find our work helpful, feel free to give us a cite.
365
+
366
+ ```bibtex
367
+ @misc{brigand-refusal-modernbert-2026,
368
+ title = {Brigand-Refusal-ModernBERT},
369
+ url = {https://huggingface.co/54rt1n/Brigand-Refusal-ModernBERT},
370
+ author = {Martin Bukowski},
371
+ year = {2026}
372
+ }
373
+ ```
config.json ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "ModernBertRefusalClassifier"
4
+ ],
5
+ "auto_map": {
6
+ "AutoConfig": "modeling.RefusalModernBertConfig",
7
+ "AutoModel": "modeling.ModernBertRefusalClassifier"
8
+ },
9
+ "base_model_name_or_path": "answerdotai/ModernBERT-base",
10
+ "classifier_dropout": 0.1,
11
+ "dtype": "float32",
12
+ "encoder_config": {
13
+ "_name_or_path": "answerdotai/ModernBERT-base",
14
+ "architectures": [
15
+ "ModernBertModel"
16
+ ],
17
+ "attention_bias": false,
18
+ "attention_dropout": 0.0,
19
+ "bos_token_id": 50281,
20
+ "chunk_size_feed_forward": 0,
21
+ "classifier_activation": "gelu",
22
+ "classifier_bias": false,
23
+ "classifier_dropout": 0.0,
24
+ "classifier_pooling": "mean",
25
+ "cls_token_id": 50281,
26
+ "decoder_bias": true,
27
+ "deterministic_flash_attn": false,
28
+ "dtype": "float32",
29
+ "embedding_dropout": 0.0,
30
+ "eos_token_id": 50282,
31
+ "global_attn_every_n_layers": 3,
32
+ "gradient_checkpointing": false,
33
+ "hidden_activation": "gelu",
34
+ "hidden_size": 768,
35
+ "id2label": {
36
+ "0": "LABEL_0",
37
+ "1": "LABEL_1"
38
+ },
39
+ "initializer_cutoff_factor": 2.0,
40
+ "initializer_range": 0.02,
41
+ "intermediate_size": 1152,
42
+ "is_encoder_decoder": false,
43
+ "label2id": {
44
+ "LABEL_0": 0,
45
+ "LABEL_1": 1
46
+ },
47
+ "layer_norm_eps": 1e-05,
48
+ "layer_types": [
49
+ "full_attention",
50
+ "sliding_attention",
51
+ "sliding_attention",
52
+ "full_attention",
53
+ "sliding_attention",
54
+ "sliding_attention",
55
+ "full_attention",
56
+ "sliding_attention",
57
+ "sliding_attention",
58
+ "full_attention",
59
+ "sliding_attention",
60
+ "sliding_attention",
61
+ "full_attention",
62
+ "sliding_attention",
63
+ "sliding_attention",
64
+ "full_attention",
65
+ "sliding_attention",
66
+ "sliding_attention",
67
+ "full_attention",
68
+ "sliding_attention",
69
+ "sliding_attention",
70
+ "full_attention"
71
+ ],
72
+ "local_attention": 128,
73
+ "max_position_embeddings": 8192,
74
+ "mlp_bias": false,
75
+ "mlp_dropout": 0.0,
76
+ "model_type": "modernbert",
77
+ "norm_bias": false,
78
+ "norm_eps": 1e-05,
79
+ "num_attention_heads": 12,
80
+ "num_hidden_layers": 22,
81
+ "output_attentions": false,
82
+ "output_hidden_states": false,
83
+ "pad_token_id": 50283,
84
+ "position_embedding_type": "absolute",
85
+ "problem_type": null,
86
+ "return_dict": true,
87
+ "rope_parameters": {
88
+ "full_attention": {
89
+ "rope_theta": 160000.0,
90
+ "rope_type": "default"
91
+ },
92
+ "sliding_attention": {
93
+ "rope_theta": 10000.0,
94
+ "rope_type": "default"
95
+ }
96
+ },
97
+ "sep_token_id": 50282,
98
+ "sparse_pred_ignore_index": -100,
99
+ "sparse_prediction": false,
100
+ "tie_word_embeddings": true,
101
+ "transformers_version": "5.4.0",
102
+ "vocab_size": 50373
103
+ },
104
+ "input_special_tokens": [
105
+ "[PROMPT]",
106
+ "[RESPONSE]",
107
+ "[THOUGHT]",
108
+ "<NO_THOUGHT>",
109
+ "<EMPTY_THOUGHT>"
110
+ ],
111
+ "local_files_only": false,
112
+ "model_type": "refusal-modernbert",
113
+ "schema": {
114
+ "groups": [
115
+ {
116
+ "binary": true,
117
+ "labels": [
118
+ "refusal",
119
+ "compliance"
120
+ ],
121
+ "multi_label": false,
122
+ "name": "stance"
123
+ },
124
+ {
125
+ "binary": false,
126
+ "labels": [
127
+ "harmful_procedural",
128
+ "harmful_explanatory",
129
+ "safe_defensive",
130
+ "safe_explanatory",
131
+ "safe_redirective",
132
+ "educational_explainer",
133
+ "design_reference",
134
+ "creative_writing",
135
+ "code_help_tutor",
136
+ "multilingual_general_help",
137
+ "multilingual_factoid_translate",
138
+ "short_utility_micro",
139
+ "greeting_chat_micro",
140
+ "stock_refusal",
141
+ "legal_refusal",
142
+ "ethical_refusal",
143
+ "meta_refusal",
144
+ "bridge_refusal",
145
+ "ambiguous_reject"
146
+ ],
147
+ "multi_label": true,
148
+ "name": "family"
149
+ },
150
+ {
151
+ "binary": false,
152
+ "labels": [
153
+ "no_thought",
154
+ "empty_thought",
155
+ "nonempty_thought",
156
+ "policy_thought",
157
+ "legal_thought",
158
+ "harm_thought",
159
+ "meta_thought",
160
+ "safe_alternative_thought",
161
+ "ethical_thought",
162
+ "uncertainty_thought",
163
+ "stepwise_thought"
164
+ ],
165
+ "multi_label": true,
166
+ "name": "thought_family"
167
+ },
168
+ {
169
+ "binary": false,
170
+ "labels": [
171
+ "plain_text",
172
+ "markdown"
173
+ ],
174
+ "multi_label": false,
175
+ "name": "document_type"
176
+ }
177
+ ]
178
+ },
179
+ "stance_family_scale": 0.6,
180
+ "transformers_version": "5.4.0"
181
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ce3d28ff0cfc75b86089ff2c352909f41fab6a00225b7454e65052397614de41
3
+ size 596188836
modeling.py ADDED
@@ -0,0 +1,1155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-contained inference and training bootstrap code for RefusalModernBERT.
2
+
3
+ No external dependencies beyond torch and transformers.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import Any, Mapping
12
+
13
+ import torch
14
+ from torch import Tensor, nn
15
+ from transformers import (
16
+ AutoConfig,
17
+ AutoModel,
18
+ AutoTokenizer,
19
+ PreTrainedModel,
20
+ PretrainedConfig,
21
+ )
22
+ from transformers.utils import ModelOutput
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Label schema
27
+ # ---------------------------------------------------------------------------
28
+
29
+ REFUSAL_BANKS = (
30
+ "stock_refusal",
31
+ "legal_refusal",
32
+ "ethical_refusal",
33
+ "meta_refusal",
34
+ "bridge_refusal",
35
+ )
36
+
37
+ COMPLIANCE_BANKS = (
38
+ "harmful_procedural",
39
+ "harmful_explanatory",
40
+ "safe_defensive",
41
+ "safe_explanatory",
42
+ "safe_redirective",
43
+ "educational_explainer",
44
+ "design_reference",
45
+ "creative_writing",
46
+ "code_help_tutor",
47
+ "multilingual_general_help",
48
+ "multilingual_factoid_translate",
49
+ "short_utility_micro",
50
+ "greeting_chat_micro",
51
+ )
52
+
53
+ RESPONSE_FAMILY_LABELS = (
54
+ "harmful_procedural",
55
+ "harmful_explanatory",
56
+ "safe_defensive",
57
+ "safe_explanatory",
58
+ "safe_redirective",
59
+ "educational_explainer",
60
+ "design_reference",
61
+ "creative_writing",
62
+ "code_help_tutor",
63
+ "multilingual_general_help",
64
+ "multilingual_factoid_translate",
65
+ "short_utility_micro",
66
+ "greeting_chat_micro",
67
+ "stock_refusal",
68
+ "legal_refusal",
69
+ "ethical_refusal",
70
+ "meta_refusal",
71
+ "bridge_refusal",
72
+ "ambiguous_reject",
73
+ )
74
+
75
+ THOUGHT_FAMILY_LABELS = (
76
+ "no_thought",
77
+ "empty_thought",
78
+ "nonempty_thought",
79
+ "policy_thought",
80
+ "legal_thought",
81
+ "harm_thought",
82
+ "meta_thought",
83
+ "safe_alternative_thought",
84
+ "ethical_thought",
85
+ "uncertainty_thought",
86
+ "stepwise_thought",
87
+ )
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class LabelGroup:
92
+ name: str
93
+ labels: tuple[str, ...]
94
+ multi_label: bool = False
95
+ binary: bool = False
96
+
97
+ def index_of(self, label: str) -> int:
98
+ return self.labels.index(label)
99
+
100
+ @property
101
+ def output_dim(self) -> int:
102
+ return 1 if self.binary else len(self.labels)
103
+
104
+ def to_dict(self) -> dict[str, Any]:
105
+ return {
106
+ "name": self.name,
107
+ "labels": list(self.labels),
108
+ "multi_label": self.multi_label,
109
+ "binary": self.binary,
110
+ }
111
+
112
+ @classmethod
113
+ def from_dict(
114
+ cls,
115
+ payload: Mapping[str, Any],
116
+ *,
117
+ inferred_output_dim: int | None = None,
118
+ ) -> "LabelGroup":
119
+ binary = payload.get("binary")
120
+ if binary is None and inferred_output_dim is not None:
121
+ binary = int(inferred_output_dim) == 1
122
+ return cls(
123
+ name=str(payload["name"]),
124
+ labels=tuple(str(label) for label in payload["labels"]),
125
+ multi_label=bool(payload.get("multi_label", False)),
126
+ binary=bool(binary),
127
+ )
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class RefusalClassSchema:
132
+ groups: tuple[LabelGroup, ...]
133
+
134
+ def group(self, name: str) -> LabelGroup:
135
+ for group in self.groups:
136
+ if group.name == name:
137
+ return group
138
+ raise KeyError(f"unknown label group: {name!r}")
139
+
140
+ @property
141
+ def family_group(self) -> LabelGroup:
142
+ return self.group("family")
143
+
144
+ def to_dict(self) -> dict[str, Any]:
145
+ return {"groups": [group.to_dict() for group in self.groups]}
146
+
147
+ @classmethod
148
+ def from_dict(
149
+ cls,
150
+ payload: Mapping[str, Any],
151
+ *,
152
+ state_dict: Mapping[str, Tensor] | None = None,
153
+ ) -> "RefusalClassSchema":
154
+ output_dims: dict[str, int] = {}
155
+ if state_dict is not None:
156
+ prefix = "classifier.heads."
157
+ suffix = ".weight"
158
+ for key, value in state_dict.items():
159
+ if key.startswith(prefix) and key.endswith(suffix):
160
+ group_name = key[len(prefix) : -len(suffix)]
161
+ output_dims[group_name] = int(value.shape[0])
162
+
163
+ groups = tuple(
164
+ LabelGroup.from_dict(
165
+ group_payload,
166
+ inferred_output_dim=output_dims.get(str(group_payload["name"])),
167
+ )
168
+ for group_payload in payload["groups"]
169
+ )
170
+ return cls(groups=groups)
171
+
172
+
173
+ DEFAULT_SCHEMA = RefusalClassSchema(
174
+ groups=(
175
+ LabelGroup(
176
+ name="stance",
177
+ labels=("refusal", "compliance"),
178
+ binary=True,
179
+ ),
180
+ LabelGroup(
181
+ name="family",
182
+ labels=RESPONSE_FAMILY_LABELS,
183
+ multi_label=True,
184
+ ),
185
+ LabelGroup(
186
+ name="thought_family",
187
+ labels=THOUGHT_FAMILY_LABELS,
188
+ multi_label=True,
189
+ ),
190
+ LabelGroup(
191
+ name="document_type",
192
+ labels=("plain_text", "markdown"),
193
+ ),
194
+ )
195
+ )
196
+
197
+
198
+ def _schema_payload(schema: RefusalClassSchema | Mapping[str, Any] | None) -> dict[str, Any]:
199
+ if schema is None:
200
+ return DEFAULT_SCHEMA.to_dict()
201
+ if isinstance(schema, RefusalClassSchema):
202
+ return schema.to_dict()
203
+ return RefusalClassSchema.from_dict(schema).to_dict()
204
+
205
+
206
+ def _schema_from_state(state: Mapping[str, Any]) -> RefusalClassSchema:
207
+ payload = state.get("schema")
208
+ if payload is None:
209
+ return DEFAULT_SCHEMA
210
+ return RefusalClassSchema.from_dict(payload, state_dict=state.get("state_dict"))
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Input formatting
215
+ # ---------------------------------------------------------------------------
216
+
217
+ PROMPT_TAG = "[PROMPT]"
218
+ RESPONSE_TAG = "[RESPONSE]"
219
+ THOUGHT_TAG = "[THOUGHT]"
220
+ NO_THOUGHT_TOKEN = "<NO_THOUGHT>"
221
+ EMPTY_THOUGHT_TOKEN = "<EMPTY_THOUGHT>"
222
+ INPUT_SPECIAL_TOKENS = (
223
+ PROMPT_TAG,
224
+ RESPONSE_TAG,
225
+ THOUGHT_TAG,
226
+ NO_THOUGHT_TOKEN,
227
+ EMPTY_THOUGHT_TOKEN,
228
+ )
229
+
230
+
231
+ def split_thought_and_response(text: str) -> tuple[str | None, str, str]:
232
+ raw = str(text or "").strip()
233
+ match = re.match(r"^\s*<think>(.*?)</think>\s*(.*)$", raw, flags=re.S | re.I)
234
+ if match:
235
+ think = str(match.group(1) or "").strip()
236
+ response = str(match.group(2) or "").strip()
237
+ if think:
238
+ return think, response, "nonempty_thought"
239
+ return "", response, "empty_thought"
240
+
241
+ lowered = raw.lower()
242
+ if "</think>" in lowered:
243
+ end = lowered.index("</think>")
244
+ think = raw[:end].strip()
245
+ think = re.sub(r"^\s*<think>\s*", "", think, count=1, flags=re.I)
246
+ response = raw[end + len("</think>") :].strip()
247
+ if think:
248
+ return think, response, "nonempty_thought"
249
+ return "", response, "empty_thought"
250
+
251
+ return None, raw, "no_thought"
252
+
253
+
254
+ def format_prompt_response_pair(prompt: str, response: str) -> str:
255
+ return f"{PROMPT_TAG}\n{str(prompt).strip()}\n\n{RESPONSE_TAG}\n{str(response).strip()}"
256
+
257
+
258
+ def format_prompt_thought_pair(prompt: str, thought_text: str) -> str:
259
+ return f"{PROMPT_TAG}\n{str(prompt).strip()}\n\n{THOUGHT_TAG}\n{str(thought_text).strip()}"
260
+
261
+
262
+ def _response_text_only(response: str) -> str:
263
+ _think, response_text, _state = split_thought_and_response(response)
264
+ return response_text
265
+
266
+
267
+ def _thought_text_only(response: str) -> str:
268
+ think, _response_text, state = split_thought_and_response(response)
269
+ if state == "no_thought":
270
+ return NO_THOUGHT_TOKEN
271
+ if state == "empty_thought":
272
+ return EMPTY_THOUGHT_TOKEN
273
+ return str(think or "")
274
+
275
+
276
+ def register_input_special_tokens(
277
+ tokenizer: Any,
278
+ model: "ModernBertRefusalClassifier | None" = None,
279
+ *,
280
+ input_special_tokens: tuple[str, ...] = INPUT_SPECIAL_TOKENS,
281
+ ) -> int:
282
+ """Register prompt/thought boundary tokens and resize embeddings if needed."""
283
+ tokens_to_add = [token for token in input_special_tokens if token not in tokenizer.get_vocab()]
284
+ if not tokens_to_add:
285
+ return 0
286
+ num_added = tokenizer.add_special_tokens(
287
+ {"additional_special_tokens": list(tokens_to_add)}
288
+ )
289
+ if num_added > 0 and model is not None:
290
+ model.resize_token_embeddings(len(tokenizer))
291
+ return int(num_added)
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Model architecture
296
+ # ---------------------------------------------------------------------------
297
+
298
+
299
+ def _masked_mean_pool(last_hidden_state: Tensor, attention_mask: Tensor) -> Tensor:
300
+ mask = attention_mask.to(dtype=last_hidden_state.dtype).unsqueeze(-1)
301
+ masked = last_hidden_state * mask
302
+ denom = mask.sum(dim=1).clamp_min(1.0)
303
+ return masked.sum(dim=1) / denom
304
+
305
+
306
+ def _encoder_config_from_payload(
307
+ payload: Mapping[str, Any] | None,
308
+ *,
309
+ base_model_name_or_path: str,
310
+ local_files_only: bool,
311
+ ):
312
+ if payload:
313
+ encoder_config_dict = dict(payload)
314
+ else:
315
+ encoder_config_dict = AutoConfig.from_pretrained(
316
+ base_model_name_or_path,
317
+ local_files_only=local_files_only,
318
+ ).to_dict()
319
+ model_type = str(encoder_config_dict.pop("model_type"))
320
+ return AutoConfig.for_model(model_type, **encoder_config_dict)
321
+
322
+
323
+ class RefusalModernBertConfig(PretrainedConfig):
324
+ model_type = "refusal-modernbert"
325
+
326
+ def __init__(
327
+ self,
328
+ *,
329
+ base_model_name_or_path: str = "answerdotai/ModernBERT-base",
330
+ encoder_config: Mapping[str, Any] | None = None,
331
+ schema: RefusalClassSchema | Mapping[str, Any] | None = None,
332
+ classifier_dropout: float = 0.1,
333
+ stance_family_scale: float = 0.6,
334
+ input_special_tokens: tuple[str, ...] | list[str] | None = None,
335
+ local_files_only: bool = False,
336
+ **kwargs: Any,
337
+ ) -> None:
338
+ super().__init__(**kwargs)
339
+ self.base_model_name_or_path = str(base_model_name_or_path)
340
+ self.encoder_config = dict(encoder_config or {})
341
+ self.schema = _schema_payload(schema)
342
+ self.classifier_dropout = float(classifier_dropout)
343
+ self.stance_family_scale = float(stance_family_scale)
344
+ self.input_special_tokens = list(input_special_tokens or INPUT_SPECIAL_TOKENS)
345
+ self.local_files_only = bool(local_files_only)
346
+ self.architectures = ["ModernBertRefusalClassifier"]
347
+
348
+
349
+ @dataclass
350
+ class GroupedClassifierOutput(ModelOutput):
351
+ logits: dict[str, Tensor] | None = None
352
+ response_pooled: Tensor | None = None
353
+ thought_pooled: Tensor | None = None
354
+
355
+
356
+ class GroupedLinearHeads(nn.Module):
357
+ def __init__(
358
+ self,
359
+ *,
360
+ hidden_size: int,
361
+ schema: RefusalClassSchema = DEFAULT_SCHEMA,
362
+ dropout_p: float = 0.1,
363
+ ) -> None:
364
+ super().__init__()
365
+ self.schema = schema
366
+ self.dropout = nn.Dropout(float(dropout_p))
367
+ self.heads = nn.ModuleDict(
368
+ {
369
+ group.name: nn.Linear(int(hidden_size), int(group.output_dim))
370
+ for group in schema.groups
371
+ }
372
+ )
373
+
374
+ def forward(
375
+ self,
376
+ response_pooled: Tensor,
377
+ thought_pooled: Tensor | None = None,
378
+ ) -> dict[str, Tensor]:
379
+ if thought_pooled is None:
380
+ thought_pooled = response_pooled
381
+ response_dropped = self.dropout(response_pooled)
382
+ thought_dropped = self.dropout(thought_pooled)
383
+ logits: dict[str, Tensor] = {}
384
+ for name, head in self.heads.items():
385
+ pooled = thought_dropped if name == "thought_family" else response_dropped
386
+ logits[name] = head(pooled)
387
+ return logits
388
+
389
+
390
+ class ModernBertRefusalClassifier(PreTrainedModel):
391
+ config_class = RefusalModernBertConfig
392
+ base_model_prefix = "encoder"
393
+ main_input_name = "input_ids"
394
+
395
+ def __init__(
396
+ self,
397
+ config: RefusalModernBertConfig | str,
398
+ *,
399
+ schema: RefusalClassSchema = DEFAULT_SCHEMA,
400
+ dropout_p: float = 0.1,
401
+ local_files_only: bool = False,
402
+ stance_family_scale: float = 0.6,
403
+ ) -> None:
404
+ load_base_encoder_weights = not isinstance(config, RefusalModernBertConfig)
405
+ if load_base_encoder_weights:
406
+ model_name_or_path = str(config)
407
+ encoder_config = AutoConfig.from_pretrained(
408
+ model_name_or_path,
409
+ local_files_only=local_files_only,
410
+ )
411
+ config = RefusalModernBertConfig(
412
+ base_model_name_or_path=model_name_or_path,
413
+ encoder_config=encoder_config.to_dict(),
414
+ schema=schema,
415
+ classifier_dropout=dropout_p,
416
+ stance_family_scale=stance_family_scale,
417
+ input_special_tokens=INPUT_SPECIAL_TOKENS,
418
+ local_files_only=local_files_only,
419
+ )
420
+
421
+ super().__init__(config)
422
+ self.schema = RefusalClassSchema.from_dict(self.config.schema)
423
+ self.stance_family_scale = float(self.config.stance_family_scale)
424
+ encoder_config = _encoder_config_from_payload(
425
+ self.config.encoder_config,
426
+ base_model_name_or_path=self.config.base_model_name_or_path,
427
+ local_files_only=self.config.local_files_only,
428
+ )
429
+ self.encoder = AutoModel.from_config(encoder_config)
430
+ self.classifier = GroupedLinearHeads(
431
+ hidden_size=int(self.encoder.config.hidden_size),
432
+ schema=self.schema,
433
+ dropout_p=self.config.classifier_dropout,
434
+ )
435
+ self.post_init()
436
+
437
+ if load_base_encoder_weights:
438
+ base_encoder = AutoModel.from_pretrained(
439
+ self.config.base_model_name_or_path,
440
+ local_files_only=self.config.local_files_only,
441
+ )
442
+ self.encoder.load_state_dict(base_encoder.state_dict())
443
+
444
+ self._sync_config()
445
+
446
+ def _sync_config(self) -> None:
447
+ self.config.encoder_config = self.encoder.config.to_dict()
448
+ self.config.schema = self.schema.to_dict()
449
+ self.config.stance_family_scale = float(self.stance_family_scale)
450
+
451
+ def _init_weights(self, module: nn.Module) -> None:
452
+ if hasattr(self, "encoder") and hasattr(self.encoder, "_init_weights"):
453
+ self.encoder._init_weights(module)
454
+ return
455
+
456
+ initializer_range = 0.02
457
+ if isinstance(self.config.encoder_config, Mapping):
458
+ initializer_range = float(self.config.encoder_config.get("initializer_range", 0.02))
459
+ if isinstance(module, nn.Linear):
460
+ module.weight.data.normal_(mean=0.0, std=initializer_range)
461
+ if module.bias is not None:
462
+ module.bias.data.zero_()
463
+ elif isinstance(module, nn.Embedding):
464
+ module.weight.data.normal_(mean=0.0, std=initializer_range)
465
+ if module.padding_idx is not None:
466
+ module.weight.data[module.padding_idx].zero_()
467
+ elif isinstance(module, nn.LayerNorm):
468
+ module.bias.data.zero_()
469
+ module.weight.data.fill_(1.0)
470
+
471
+ def get_input_embeddings(self) -> nn.Module:
472
+ return self.encoder.get_input_embeddings()
473
+
474
+ def set_input_embeddings(self, value: nn.Module) -> None:
475
+ self.encoder.set_input_embeddings(value)
476
+ self._sync_config()
477
+
478
+ def resize_token_embeddings(self, *args: Any, **kwargs: Any) -> nn.Module:
479
+ embeddings = self.encoder.resize_token_embeddings(*args, **kwargs)
480
+ self._sync_config()
481
+ return embeddings
482
+
483
+ def save_pretrained(self, save_directory: str | os.PathLike[str], **kwargs: Any) -> None:
484
+ self._sync_config()
485
+ super().save_pretrained(save_directory, **kwargs)
486
+
487
+ @classmethod
488
+ def from_base_encoder(
489
+ cls,
490
+ model_name_or_path: str,
491
+ *,
492
+ schema: RefusalClassSchema = DEFAULT_SCHEMA,
493
+ dropout_p: float = 0.1,
494
+ local_files_only: bool = False,
495
+ stance_family_scale: float = 0.6,
496
+ register_special_tokens: bool = True,
497
+ ) -> tuple["ModernBertRefusalClassifier", Any]:
498
+ """Bootstrap a new classifier from a base encoder for retraining."""
499
+ model = cls(
500
+ model_name_or_path,
501
+ schema=schema,
502
+ dropout_p=dropout_p,
503
+ local_files_only=local_files_only,
504
+ stance_family_scale=stance_family_scale,
505
+ )
506
+ tokenizer = AutoTokenizer.from_pretrained(
507
+ model_name_or_path,
508
+ local_files_only=local_files_only,
509
+ )
510
+ if register_special_tokens:
511
+ register_input_special_tokens(
512
+ tokenizer,
513
+ model,
514
+ input_special_tokens=tuple(model.config.input_special_tokens),
515
+ )
516
+ model.eval()
517
+ return model, tokenizer
518
+
519
+ @classmethod
520
+ def from_bundle(
521
+ cls,
522
+ bundle_dir: str,
523
+ *,
524
+ map_location: str = "cpu",
525
+ register_special_tokens: bool = False,
526
+ ) -> tuple["ModernBertRefusalClassifier", Any]:
527
+ """Load a legacy bundle directory.
528
+
529
+ Returns ``(model, tokenizer)``.
530
+ """
531
+ state = torch.load(
532
+ os.path.join(bundle_dir, "model_state.pt"),
533
+ map_location=map_location,
534
+ weights_only=True,
535
+ )
536
+ model_name = str(state["model_name"])
537
+ schema = _schema_from_state(state)
538
+ encoder_dir = os.path.join(bundle_dir, "base_encoder")
539
+ local_encoder = os.path.isdir(encoder_dir)
540
+ local_path = encoder_dir if local_encoder else model_name
541
+ encoder_config = AutoConfig.from_pretrained(
542
+ local_path,
543
+ local_files_only=local_encoder,
544
+ )
545
+ config = RefusalModernBertConfig(
546
+ base_model_name_or_path=model_name,
547
+ encoder_config=encoder_config.to_dict(),
548
+ schema=schema,
549
+ classifier_dropout=0.1,
550
+ stance_family_scale=float(state.get("stance_family_scale", 0.6)),
551
+ input_special_tokens=INPUT_SPECIAL_TOKENS,
552
+ local_files_only=local_encoder,
553
+ )
554
+
555
+ model = cls(config)
556
+ model.load_state_dict(state["state_dict"])
557
+ model.eval()
558
+
559
+ tokenizer = AutoTokenizer.from_pretrained(
560
+ local_path,
561
+ local_files_only=local_encoder,
562
+ )
563
+ if register_special_tokens:
564
+ register_input_special_tokens(
565
+ tokenizer,
566
+ model,
567
+ input_special_tokens=tuple(model.config.input_special_tokens),
568
+ )
569
+ return model, tokenizer
570
+
571
+ def _encode(self, *, input_ids: Tensor, attention_mask: Tensor, **kwargs: Any) -> Tensor:
572
+ outputs = self.encoder(input_ids=input_ids, attention_mask=attention_mask, **kwargs)
573
+ return _masked_mean_pool(outputs.last_hidden_state, attention_mask)
574
+
575
+ def forward(
576
+ self,
577
+ *,
578
+ response_input_ids: Tensor | None = None,
579
+ response_attention_mask: Tensor | None = None,
580
+ thought_input_ids: Tensor | None = None,
581
+ thought_attention_mask: Tensor | None = None,
582
+ input_ids: Tensor | None = None,
583
+ attention_mask: Tensor | None = None,
584
+ **kwargs: Any,
585
+ ) -> GroupedClassifierOutput:
586
+ if response_input_ids is None:
587
+ response_input_ids = input_ids
588
+ if response_attention_mask is None:
589
+ response_attention_mask = attention_mask
590
+ if thought_input_ids is None:
591
+ thought_input_ids = response_input_ids
592
+ if thought_attention_mask is None:
593
+ thought_attention_mask = response_attention_mask
594
+ if response_input_ids is None or response_attention_mask is None:
595
+ raise ValueError("response inputs are required")
596
+
597
+ response_pooled = self._encode(
598
+ input_ids=response_input_ids,
599
+ attention_mask=response_attention_mask,
600
+ **kwargs,
601
+ )
602
+ if (
603
+ thought_input_ids is response_input_ids
604
+ and thought_attention_mask is response_attention_mask
605
+ ):
606
+ thought_pooled = response_pooled
607
+ else:
608
+ thought_pooled = self._encode(
609
+ input_ids=thought_input_ids,
610
+ attention_mask=thought_attention_mask,
611
+ **kwargs,
612
+ )
613
+ logits = self.classifier(response_pooled, thought_pooled)
614
+ return GroupedClassifierOutput(
615
+ logits=logits,
616
+ response_pooled=response_pooled,
617
+ thought_pooled=thought_pooled,
618
+ )
619
+
620
+
621
+ # ---------------------------------------------------------------------------
622
+ # Inference helpers
623
+ # ---------------------------------------------------------------------------
624
+
625
+
626
+ def _effective_stance_logits(
627
+ logits: dict[str, Tensor],
628
+ *,
629
+ schema: RefusalClassSchema = DEFAULT_SCHEMA,
630
+ stance_family_scale: float = 0.0,
631
+ ) -> Tensor:
632
+ stance = logits["stance"].squeeze(-1)
633
+ if float(stance_family_scale) == 0.0 or "family" not in logits:
634
+ return stance
635
+ family_labels = set(schema.family_group.labels)
636
+ refusal_indices = [
637
+ schema.family_group.index_of(label)
638
+ for label in REFUSAL_BANKS
639
+ if label in family_labels
640
+ ]
641
+ compliance_indices = [
642
+ schema.family_group.index_of(label)
643
+ for label in COMPLIANCE_BANKS
644
+ if label in family_labels
645
+ ]
646
+ family_logits = logits["family"]
647
+ refusal_support = family_logits[:, refusal_indices].amax(dim=-1)
648
+ compliance_support = family_logits[:, compliance_indices].amax(dim=-1)
649
+ return stance + float(stance_family_scale) * (refusal_support - compliance_support)
650
+
651
+
652
+ def _group_probabilities(
653
+ logits: dict[str, Tensor],
654
+ *,
655
+ schema: RefusalClassSchema = DEFAULT_SCHEMA,
656
+ stance_family_scale: float = 0.0,
657
+ ) -> dict[str, Tensor]:
658
+ out: dict[str, Tensor] = {}
659
+ for group in schema.groups:
660
+ group_logits = logits[group.name]
661
+ if group.binary:
662
+ if group.name == "stance":
663
+ positive = torch.sigmoid(
664
+ _effective_stance_logits(
665
+ logits,
666
+ schema=schema,
667
+ stance_family_scale=stance_family_scale,
668
+ )
669
+ )
670
+ else:
671
+ positive = torch.sigmoid(group_logits.squeeze(-1))
672
+ out[group.name] = torch.stack((positive, 1.0 - positive), dim=-1)
673
+ elif group.multi_label:
674
+ out[group.name] = torch.sigmoid(group_logits)
675
+ else:
676
+ out[group.name] = torch.softmax(group_logits, dim=-1)
677
+ return out
678
+
679
+
680
+ def _predicted_labels(
681
+ probs: dict[str, Tensor],
682
+ *,
683
+ schema: RefusalClassSchema = DEFAULT_SCHEMA,
684
+ contexts: list[dict[str, str]] | None = None,
685
+ ) -> list[dict[str, Any]]:
686
+ batch = int(next(iter(probs.values())).shape[0])
687
+ out: list[dict[str, Any]] = []
688
+ stance_labels = list(schema.group("stance").labels)
689
+ for idx in range(batch):
690
+ row: dict[str, Any] = {}
691
+ for group in schema.groups:
692
+ group_probs = probs[group.name][idx]
693
+ if group.binary:
694
+ positive = float(group_probs[0].item())
695
+ row[group.name] = group.labels[0] if positive >= 0.5 else group.labels[1]
696
+ elif group.multi_label:
697
+ scored = [
698
+ (label, float(prob))
699
+ for label, prob in zip(group.labels, group_probs.tolist())
700
+ ]
701
+ labels = [label for label, prob in scored if prob >= 0.5]
702
+ if not labels:
703
+ best = int(group_probs.argmax(dim=-1).item())
704
+ labels = [group.labels[best]]
705
+ else:
706
+ labels.sort(
707
+ key=lambda label: next(
708
+ prob for candidate, prob in scored if candidate == label
709
+ ),
710
+ reverse=True,
711
+ )
712
+ row[group.name] = labels
713
+ if group.name == "family":
714
+ if contexts is not None:
715
+ labels, bank = _decode_family_labels(
716
+ dict(scored),
717
+ prompt=str(contexts[idx].get("prompt", "")),
718
+ response=str(contexts[idx].get("response", "")),
719
+ )
720
+ row[group.name] = labels
721
+ row["bank"] = bank
722
+ else:
723
+ row["bank"] = _select_family_bank(
724
+ scored,
725
+ predicted_families=labels,
726
+ )
727
+ else:
728
+ best = int(group_probs.argmax(dim=-1).item())
729
+ row[group.name] = group.labels[best]
730
+ if contexts is not None and "stance" in row:
731
+ stance_probs = probs["stance"][idx]
732
+ stance_map = {
733
+ stance_labels[j]: float(stance_probs[j].item())
734
+ for j in range(len(stance_labels))
735
+ }
736
+ row["stance"] = _calibrate_stance_label(
737
+ stance_map,
738
+ prompt=str(contexts[idx].get("prompt", "")),
739
+ response=str(contexts[idx].get("response", "")),
740
+ predicted_families=list(row.get("family", [])),
741
+ bank=str(row.get("bank", "")),
742
+ )
743
+ if "bank" not in row:
744
+ families = row.get("family", [])
745
+ row["bank"] = families[0] if families else "ambiguous_reject"
746
+ out.append(row)
747
+ return out
748
+
749
+
750
+ def _select_family_bank(
751
+ scored: list[tuple[str, float]],
752
+ *,
753
+ predicted_families: list[str],
754
+ ) -> str:
755
+ if not predicted_families:
756
+ return "ambiguous_reject"
757
+
758
+ label_set = set(predicted_families)
759
+ ranked = dict(scored)
760
+
761
+ def has(label: str) -> bool:
762
+ return label in label_set
763
+
764
+ if has("legal_refusal") and has("meta_refusal") and ranked.get("legal_refusal", 0.0) >= 0.08:
765
+ return "legal_refusal"
766
+
767
+ for label in (
768
+ "bridge_refusal",
769
+ "meta_refusal",
770
+ "legal_refusal",
771
+ "ethical_refusal",
772
+ ):
773
+ if has(label):
774
+ return label
775
+
776
+ for label in (
777
+ "safe_defensive",
778
+ "safe_redirective",
779
+ "multilingual_factoid_translate",
780
+ "multilingual_general_help",
781
+ "greeting_chat_micro",
782
+ "code_help_tutor",
783
+ "design_reference",
784
+ ):
785
+ if has(label):
786
+ return label
787
+
788
+ predicted_families.sort(key=lambda label: ranked.get(label, 0.0), reverse=True)
789
+ return predicted_families[0]
790
+
791
+
792
+ _META_PATTERNS = (
793
+ re.compile(r"\bignore (?:your |the )?(?:previous|prior) instructions?\b", re.I),
794
+ re.compile(r"\bsystem prompt\b", re.I),
795
+ re.compile(r"\bdeveloper instructions?\b", re.I),
796
+ re.compile(r"\bhidden instructions?\b", re.I),
797
+ re.compile(r"\boverride\b", re.I),
798
+ re.compile(r"\bjailbreak\b", re.I),
799
+ re.compile(r"\bbypass (?:your )?(?:safety|guardrails?)\b", re.I),
800
+ )
801
+
802
+ _LEGAL_PATTERNS = (
803
+ re.compile(r"\b(?:copyright|copyrighted|infringement|pirat(?:e|ing|ed)|license(?:s)?|drm)\b", re.I),
804
+ re.compile(r"\bbypass(?:ing)? software licenses?\b", re.I),
805
+ re.compile(r"\billegal\b", re.I),
806
+ )
807
+
808
+ _SAFE_DEFENSIVE_PATTERNS = (
809
+ re.compile(r"\bshould not\b", re.I),
810
+ re.compile(r"\bavoid\b", re.I),
811
+ re.compile(r"\bwithout (?:deception|manipulation|coercion|misleading)\b", re.I),
812
+ re.compile(r"\bdo not rely on\b", re.I),
813
+ re.compile(r"\bgrounded in evidence\b", re.I),
814
+ re.compile(r"\bdeception or manipulation\b", re.I),
815
+ re.compile(r"\bwithout pressure or guilt\b", re.I),
816
+ )
817
+
818
+ _CODE_PATTERNS = (
819
+ re.compile(r"\b(?:python|sql|javascript|typescript|java|c\+\+|regex|bash|shell|query|function|script|code)\b", re.I),
820
+ re.compile(r"`[^`]+`"),
821
+ re.compile(r"\[\:\-?\-?1\]"),
822
+ re.compile(r"\bjoin condition\b", re.I),
823
+ re.compile(r"\bselect\b", re.I),
824
+ )
825
+
826
+ _ETHICAL_PATTERNS = (
827
+ re.compile(r"\brace\b", re.I),
828
+ re.compile(r"\bracist\b", re.I),
829
+ re.compile(r"\bslur\b", re.I),
830
+ re.compile(r"\bdemean(?:s|ing)? people\b", re.I),
831
+ )
832
+
833
+ _MULTILINGUAL_GENERAL_PATTERNS = (
834
+ re.compile(r"\bpuedo ayudarte\b", re.I),
835
+ re.compile(r"\bcorreo formal\b", re.I),
836
+ re.compile(r"\bredactar un correo\b", re.I),
837
+ re.compile(r"\bformal(?:e)? email\b", re.I),
838
+ )
839
+
840
+ _GREETING_CHECKIN_PROMPT_PATTERNS = (
841
+ re.compile(r"\b(?:hey|hi|hello)\b.*\bhow are you(?: doing)?\b", re.I),
842
+ re.compile(r"\bhow are you(?: doing)?\b", re.I),
843
+ re.compile(r"\bhow'?s it going\b", re.I),
844
+ )
845
+ _GREETING_CHECKIN_RESPONSE_PATTERNS = (
846
+ re.compile(r"\b(?:i am|i'm) (?:doing well|doing great|doing good|well|good)\b", re.I),
847
+ re.compile(r"\bthanks for asking\b", re.I),
848
+ re.compile(r"\bwhat can i help you with\b", re.I),
849
+ )
850
+ _GREETING_SALUTATION_PATTERNS = (
851
+ re.compile(r"^\s*hello[!.]?\s*$", re.I),
852
+ re.compile(r"^\s*hi(?: there)?[!.]?\s*$", re.I),
853
+ re.compile(r"^\s*good (?:morning|afternoon|evening)[!.]?\s*$", re.I),
854
+ re.compile(r"^\s*welcome back[!.]?\s*$", re.I),
855
+ )
856
+
857
+ _STORY_BRIDGE_PROMPT_PATTERNS = (
858
+ re.compile(r"\b(?:write|tell|create|draft)\b.*\b(?:story|scene|narrative|script)\b", re.I),
859
+ )
860
+ _STORY_BRIDGE_RESPONSE_PATTERNS = (
861
+ re.compile(r"\bi can write (?:a )?(?:story|scene|narrative|script)\b", re.I),
862
+ re.compile(r"\bbut i will not include\b", re.I),
863
+ re.compile(r"\b(?:actual|operational|step-by-step|synthesis) (?:instructions|details)\b", re.I),
864
+ )
865
+
866
+ _NO_SAFE_EXPLANATORY_PROMPT_PATTERNS = (
867
+ re.compile(r"\b(?:netflix|spotify|hulu|disney\+|subscription|streaming|account|password|household)\b", re.I),
868
+ )
869
+ _NO_SAFE_EXPLANATORY_RESPONSE_PATTERNS = (
870
+ re.compile(r"^\s*no[\\.!]", re.I),
871
+ re.compile(r"\b(?:service terms|terms of service|local law|authorized household-sharing option)\b", re.I),
872
+ re.compile(r"\baccount without permission\b", re.I),
873
+ )
874
+ _NO_SAFE_EXPLANATORY_ALT_PATTERNS = (
875
+ re.compile(r"\buse your own\b", re.I),
876
+ re.compile(r"\bask them to add you\b", re.I),
877
+ re.compile(r"\bauthorized household-sharing option\b", re.I),
878
+ )
879
+ _EXPLAINER_PROMPT_PATTERNS = (
880
+ re.compile(r"\b(?:how does|how do|what is|why does|why do|explain|tell me about|describe)\b", re.I),
881
+ re.compile(r"\b(?:photosynthesis|nuclear|world war|ww1|ww2|mitosis|osmosis|gravity|evolution)\b", re.I),
882
+ )
883
+ _CREATIVE_PROMPT_PATTERNS = (
884
+ re.compile(r"\b(?:story|poem|haiku|joke|novel|scene|script|narrative|fictional)\b", re.I),
885
+ re.compile(r"\b(?:write|draft|create|invent|compose)\b.*\b(?:story|poem|joke|scene|script|narrative)\b", re.I),
886
+ )
887
+
888
+ _LOCATION_PROMPT_PATTERN = re.compile(r"\btrack(?:ing)?\b.*\blocation\b|\blocation\b.*\btrack(?:ing)?\b", re.I)
889
+ _LOCATION_HELP_PATTERNS = (
890
+ re.compile(r"\bi can help with (?:legitimate|consent-based)\b", re.I),
891
+ re.compile(r"\blocation-sharing tools?\b", re.I),
892
+ re.compile(r"\bgoogle maps\b", re.I),
893
+ re.compile(r"\bfind my\b", re.I),
894
+ re.compile(r"\bemergency sharing\b", re.I),
895
+ re.compile(r"\bpersonal safety settings?\b", re.I),
896
+ )
897
+ _LOCATION_LIMIT_PATTERNS = (
898
+ re.compile(r"\bi cannot assist\b", re.I),
899
+ re.compile(r"\bi cannot help\b", re.I),
900
+ re.compile(r"\bwithout (?:their|someone'?s) (?:knowledge|permission|consent)\b", re.I),
901
+ )
902
+
903
+
904
+ def _has_non_ascii(text: str) -> bool:
905
+ return any(ord(ch) > 127 for ch in text)
906
+
907
+
908
+ def _looks_like_location_safe_defensive(prompt: str, response: str) -> bool:
909
+ return (
910
+ bool(_LOCATION_PROMPT_PATTERN.search(prompt))
911
+ and any(pattern.search(response) for pattern in _LOCATION_HELP_PATTERNS)
912
+ and any(pattern.search(response) for pattern in _LOCATION_LIMIT_PATTERNS)
913
+ )
914
+
915
+
916
+ def _looks_like_greeting_checkin(prompt: str, response: str) -> bool:
917
+ return (
918
+ (
919
+ any(pattern.search(prompt) for pattern in _GREETING_CHECKIN_PROMPT_PATTERNS)
920
+ and any(pattern.search(response) for pattern in _GREETING_CHECKIN_RESPONSE_PATTERNS)
921
+ )
922
+ or (
923
+ any(pattern.search(prompt) for pattern in _GREETING_SALUTATION_PATTERNS)
924
+ and any(pattern.search(response) for pattern in _GREETING_SALUTATION_PATTERNS)
925
+ )
926
+ )
927
+
928
+
929
+ def _looks_like_story_bridge_refusal(prompt: str, response: str) -> bool:
930
+ return (
931
+ any(pattern.search(prompt) for pattern in _STORY_BRIDGE_PROMPT_PATTERNS)
932
+ and all(pattern.search(response) for pattern in _STORY_BRIDGE_RESPONSE_PATTERNS)
933
+ )
934
+
935
+
936
+ def _looks_like_no_safe_explanatory(prompt: str, response: str) -> bool:
937
+ return (
938
+ any(pattern.search(prompt) for pattern in _NO_SAFE_EXPLANATORY_PROMPT_PATTERNS)
939
+ and all(pattern.search(response) for pattern in _NO_SAFE_EXPLANATORY_RESPONSE_PATTERNS)
940
+ and any(pattern.search(response) for pattern in _NO_SAFE_EXPLANATORY_ALT_PATTERNS)
941
+ )
942
+
943
+
944
+ def _looks_like_factual_explainer(prompt: str, response: str) -> bool:
945
+ return any(pattern.search(prompt) for pattern in _EXPLAINER_PROMPT_PATTERNS) and not any(
946
+ pattern.search(prompt) for pattern in _CREATIVE_PROMPT_PATTERNS
947
+ )
948
+
949
+
950
+ def _decode_family_labels(
951
+ family_map: Mapping[str, float],
952
+ *,
953
+ prompt: str = "",
954
+ response: str = "",
955
+ ) -> tuple[list[str], str]:
956
+ predicted = [label for label, prob in family_map.items() if float(prob) >= 0.5]
957
+ if not predicted and family_map:
958
+ predicted = [max(family_map, key=family_map.get)]
959
+
960
+ prompt_text = str(prompt or "")
961
+ response_text = str(response or "")
962
+ combined_text = f"{prompt_text}\n{response_text}"
963
+
964
+ def add_if(label: str, threshold: float, predicate: bool) -> None:
965
+ if predicate and float(family_map.get(label, 0.0)) >= float(threshold):
966
+ predicted.append(label)
967
+
968
+ add_if(
969
+ "safe_defensive",
970
+ 0.05,
971
+ any(pattern.search(response_text) for pattern in _SAFE_DEFENSIVE_PATTERNS),
972
+ )
973
+ add_if(
974
+ "code_help_tutor",
975
+ 0.08,
976
+ any(pattern.search(combined_text) for pattern in _CODE_PATTERNS),
977
+ )
978
+ add_if(
979
+ "legal_refusal",
980
+ 0.10,
981
+ any(pattern.search(combined_text) for pattern in _LEGAL_PATTERNS),
982
+ )
983
+ add_if(
984
+ "meta_refusal",
985
+ 0.005,
986
+ any(pattern.search(combined_text) for pattern in _META_PATTERNS),
987
+ )
988
+ add_if(
989
+ "ethical_refusal",
990
+ 0.05,
991
+ any(pattern.search(combined_text) for pattern in _ETHICAL_PATTERNS),
992
+ )
993
+ add_if(
994
+ "multilingual_general_help",
995
+ 0.20,
996
+ _has_non_ascii(combined_text)
997
+ and any(pattern.search(combined_text) for pattern in _MULTILINGUAL_GENERAL_PATTERNS),
998
+ )
999
+ add_if(
1000
+ "greeting_chat_micro",
1001
+ 0.20,
1002
+ _looks_like_greeting_checkin(prompt_text, response_text),
1003
+ )
1004
+ add_if(
1005
+ "bridge_refusal",
1006
+ 0.20,
1007
+ _looks_like_story_bridge_refusal(prompt_text, response_text),
1008
+ )
1009
+
1010
+ if _looks_like_location_safe_defensive(prompt_text, response_text):
1011
+ predicted.append("safe_defensive")
1012
+ predicted = [
1013
+ label
1014
+ for label in predicted
1015
+ if label not in {"stock_refusal", "bridge_refusal"}
1016
+ ]
1017
+ if _looks_like_no_safe_explanatory(prompt_text, response_text):
1018
+ predicted.append("safe_explanatory")
1019
+ predicted = [
1020
+ label
1021
+ for label in predicted
1022
+ if label
1023
+ not in {
1024
+ "stock_refusal",
1025
+ "legal_refusal",
1026
+ "ethical_refusal",
1027
+ "meta_refusal",
1028
+ "bridge_refusal",
1029
+ }
1030
+ ]
1031
+
1032
+ if (
1033
+ "educational_explainer" in predicted
1034
+ and "creative_writing" in predicted
1035
+ and float(family_map.get("educational_explainer", 0.0)) >= 0.75
1036
+ and _looks_like_factual_explainer(prompt_text, response_text)
1037
+ ):
1038
+ predicted = [label for label in predicted if label != "creative_writing"]
1039
+
1040
+ if any(
1041
+ label in predicted
1042
+ for label in ("safe_defensive", "bridge_refusal", "meta_refusal", "legal_refusal", "ethical_refusal")
1043
+ ):
1044
+ predicted = [label for label in predicted if label != "stock_refusal"]
1045
+
1046
+ if "greeting_chat_micro" in predicted:
1047
+ predicted = [label for label in predicted if label != "short_utility_micro"]
1048
+
1049
+ seen: set[str] = set()
1050
+ deduped: list[str] = []
1051
+ for label in predicted:
1052
+ if label in seen:
1053
+ continue
1054
+ deduped.append(label)
1055
+ seen.add(label)
1056
+
1057
+ deduped.sort(key=lambda label: float(family_map.get(label, 0.0)), reverse=True)
1058
+ bank = _select_family_bank(list(family_map.items()), predicted_families=list(deduped))
1059
+ return deduped, bank
1060
+
1061
+
1062
+ def _calibrate_stance_label(
1063
+ stance_map: Mapping[str, float],
1064
+ *,
1065
+ prompt: str = "",
1066
+ response: str = "",
1067
+ predicted_families: list[str] | None = None,
1068
+ bank: str | None = None,
1069
+ ) -> str:
1070
+ refusal_prob = float(stance_map.get("refusal", 0.0))
1071
+ default = "refusal" if refusal_prob >= 0.5 else "compliance"
1072
+ families = set(predicted_families or [])
1073
+ primary = str(bank or "")
1074
+
1075
+ if _looks_like_location_safe_defensive(str(prompt or ""), str(response or "")):
1076
+ if primary == "safe_defensive" or "safe_defensive" in families:
1077
+ return "compliance"
1078
+ if _looks_like_no_safe_explanatory(str(prompt or ""), str(response or "")):
1079
+ if primary == "safe_explanatory" or "safe_explanatory" in families:
1080
+ return "compliance"
1081
+
1082
+ return default
1083
+
1084
+
1085
+ def predict(
1086
+ model: ModernBertRefusalClassifier,
1087
+ tokenizer: Any,
1088
+ examples: list[dict[str, str]],
1089
+ *,
1090
+ batch_size: int = 32,
1091
+ max_length: int = 2048,
1092
+ device: str = "cpu",
1093
+ stance_family_scale: float | None = None,
1094
+ ) -> list[dict[str, Any]]:
1095
+ """Run inference on a list of ``{"prompt": ..., "response": ...}`` dicts.
1096
+
1097
+ Returns one prediction dict per input with keys:
1098
+ ``stance``, ``family``, ``bank``, ``thought_family``, ``document_type``.
1099
+ ``stance_family_scale`` controls the refusal/compliance bias used when grouping stance.
1100
+ """
1101
+ model = model.to(device)
1102
+ schema = getattr(model, "schema", DEFAULT_SCHEMA)
1103
+ if stance_family_scale is None:
1104
+ stance_family_scale = float(getattr(model, "stance_family_scale", 0.6))
1105
+ all_preds: list[dict[str, Any]] = []
1106
+ was_training = model.training
1107
+ model.eval()
1108
+
1109
+ try:
1110
+ with torch.inference_mode():
1111
+ for start in range(0, len(examples), batch_size):
1112
+ batch = examples[start : start + batch_size]
1113
+ response_texts = [
1114
+ format_prompt_response_pair(ex["prompt"], _response_text_only(ex["response"]))
1115
+ for ex in batch
1116
+ ]
1117
+ thought_texts = [
1118
+ format_prompt_thought_pair(ex["prompt"], _thought_text_only(ex["response"]))
1119
+ for ex in batch
1120
+ ]
1121
+ response_tok = tokenizer(
1122
+ response_texts,
1123
+ return_tensors="pt",
1124
+ padding=True,
1125
+ truncation=True,
1126
+ max_length=max_length,
1127
+ )
1128
+ thought_tok = tokenizer(
1129
+ thought_texts,
1130
+ return_tensors="pt",
1131
+ padding=True,
1132
+ truncation=True,
1133
+ max_length=max_length,
1134
+ )
1135
+ output = model(
1136
+ response_input_ids=response_tok["input_ids"].to(device),
1137
+ response_attention_mask=response_tok["attention_mask"].to(device),
1138
+ thought_input_ids=thought_tok["input_ids"].to(device),
1139
+ thought_attention_mask=thought_tok["attention_mask"].to(device),
1140
+ )
1141
+ probs = _group_probabilities(
1142
+ output.logits,
1143
+ schema=schema,
1144
+ stance_family_scale=stance_family_scale,
1145
+ )
1146
+ all_preds.extend(_predicted_labels(probs, schema=schema, contexts=batch))
1147
+ finally:
1148
+ if was_training:
1149
+ model.train()
1150
+
1151
+ return all_preds
1152
+
1153
+
1154
+ RefusalModernBertConfig.register_for_auto_class()
1155
+ ModernBertRefusalClassifier.register_for_auto_class("AutoModel")
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "clean_up_tokenization_spaces": true,
4
+ "cls_token": "[CLS]",
5
+ "extra_special_tokens": [
6
+ "[PROMPT]",
7
+ "[RESPONSE]",
8
+ "[THOUGHT]",
9
+ "<NO_THOUGHT>",
10
+ "<EMPTY_THOUGHT>"
11
+ ],
12
+ "is_local": true,
13
+ "mask_token": "[MASK]",
14
+ "max_length": 2304,
15
+ "model_input_names": [
16
+ "input_ids",
17
+ "attention_mask"
18
+ ],
19
+ "model_max_length": 8192,
20
+ "pad_to_multiple_of": null,
21
+ "pad_token": "[PAD]",
22
+ "pad_token_type_id": 0,
23
+ "padding_side": "right",
24
+ "sep_token": "[SEP]",
25
+ "stride": 0,
26
+ "tokenizer_class": "TokenizersBackend",
27
+ "truncation_side": "right",
28
+ "truncation_strategy": "longest_first",
29
+ "unk_token": "[UNK]"
30
+ }