yamura4 commited on
Commit
1c19a13
·
verified ·
1 Parent(s): ce4db3b

pure v7: compact 419 MB FP16+INT8 main release; drop fp32 model.pt (kept on pure_v6)

Browse files
Files changed (8) hide show
  1. QUANTIZATION.md +63 -18
  2. README.md +68 -38
  3. config.json +8 -1
  4. gate.py +11 -1
  5. gate_pure.py +62 -0
  6. model.onnx +2 -2
  7. model.pt +0 -3
  8. server.py +47 -0
QUANTIZATION.md CHANGED
@@ -1,14 +1,35 @@
1
  # Quantization research for send-gate
2
 
3
- Status: research note. The repository ships the fp32 model. INT8 is not shipped yet.
4
 
5
- Goal: get send-gate under 500 MB of RAM while keeping the fp32 decisions on the benchmark, the custom suites and a short phrase regression set. For a safety gate, decision parity matters more than perplexity or aggregate accuracy.
6
 
7
- ## What we tested
8
 
9
- ### Dynamic INT8, 295 MB
10
 
11
- ONNX Runtime `quantize_dynamic` with uint8 weights, per-channel, reduce_range, and MatMul only.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  | Input | fp32 | dynamic INT8 |
14
  |---|---:|---:|
@@ -17,15 +38,37 @@ ONNX Runtime `quantize_dynamic` with uint8 weights, per-channel, reduce_range, a
17
  | `n g a` | 0.820 | 0.500 |
18
  | `pot` | 0.331 | 0.714 |
19
 
20
- On the v44 model, benchmark catch fell from 93.9% to about 84.1%. The current pure v6 combined model scores 95.9% with no rules and no word lists. Short phrases moved the most in both cases. The model did not simply become less confident; some benign short phrases became more confident.
 
 
 
 
 
 
21
 
22
- ### Hybrid, fp16 embedding plus INT8 layers, 482 MB
23
 
24
- The token embedding table was stored in fp16, the transformer MatMuls were quantized to INT8, and a Cast restored fp32 after the embedding lookup. The file size was under 500 MB, but suite performance was worse than fp32 and worse than the plain dynamic INT8 model on benchmark catch. The layer quantization error was larger than expected for short text.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
- ### Static QDQ, MinMax, uint8
27
 
28
- This was an early attempt with 400 calibration rows and uint8 activations. It was much worse than dynamic INT8. It is not a fair test of static quantization because the recommended CPU recipe is signed S8S8 QDQ with percentile calibration.
29
 
30
  ## What the literature says
31
 
@@ -128,16 +171,18 @@ If 8-bit mixed precision cannot reach 500 MB, try 4-bit weight only MatMulNBits
128
 
129
  ### Ship rule
130
 
131
- Ship a quantized model only if it meets all of these:
 
 
 
 
 
 
 
132
 
133
- - Under 500 MB resident memory, measured, not estimated.
134
- - Benchmark catch within 1 point of fp32.
135
- - False walls within 1 point of fp32.
136
- - Joke deflection and sex policy suites within 2 points of fp32.
137
- - No regression on the always block terms and the required allow homonyms.
138
- - Latency not worse than the fp32 model.
139
 
140
- If no candidate meets all of these, keep fp32 in production and continue research. A fast gate that misses `ik ben nog maagd` or blocks `pot` is not a win.
141
 
142
  ## References
143
 
 
1
  # Quantization research for send-gate
2
 
3
+ Status: v7 ships an FP16 encoder with INT8 embeddings in a 419 MB ONNX file. The model runs on ONNX Runtime CPU with the full 256k vocabulary and the original tokenizer. The fp32 v6 release remains on branch `pure_v6`.
4
 
5
+ Goal: reduce the 1.23 GB / 1.65 GiB v6 footprint while keeping the fp32 decisions on the benchmark, the custom suites and a short phrase regression set. For a safety gate, decision parity matters more than perplexity or aggregate accuracy.
6
 
7
+ ## Shipped recipe: FP16 encoder + INT8 embedding, 419 MB
8
 
9
+ The token and character embedding tables are quantized dynamically to INT8. Everything else is converted to FP16, with the rotary Casts and the embedding DequantizeLinear nodes kept in FP32. The full 256k vocabulary is retained, so there is no token pruning and no OOV remapping.
10
 
11
+ | Candidate | Disk | Session RSS | Benchmark at 0.94 | Verdict |
12
+ |---|---:|---:|---|---|
13
+ | v6 fp32 | 1.23 GB | 1.65 GiB | 95.9c / 6.7w | reference |
14
+ | v7 FP16 encoder + INT8 embedding | **419 MB** | **0.85 GiB** | **95.6c / 6.9w** | shipped |
15
+
16
+ Held-out variants stay at 100% recall: gap 100/10, kkr 100/0, fresh 100/10, fresh2 100/16.7. The per-message latency is about 80 to 110 ms on this CPU, versus about 70 to 100 ms for fp32.
17
+
18
+ Why this works: the embedding is the dominant parameter block (196M of 307M), and embedding-only INT8 was already near-lossless. FP16 halves the remaining encoder without changing any tokenizer decisions. Keeping the full vocabulary avoids the random-row and OOV problems that dominated every pruning experiment.
19
+
20
+ The conversion needed three fixes because `onnxconverter-common` does not handle this graph cleanly:
21
+
22
+ 1. Block the four rotary Casts, otherwise the converter binds them to mixed FP16/FP32 types.
23
+ 2. Keep the embedding DequantizeLinear nodes in FP32. ORT rejects an FP16 scale for an INT8 DequantizeLinear.
24
+ 3. Repair 133 Cast attributes where the converter annotated an FP16 output but left `to=FLOAT`.
25
+
26
+ `experiments/export_fp16_int8.py` performs all three steps. The rebuild is byte-identical to the released file (SHA256 `d1e0b78f...`).
27
+
28
+ ## Rejected experiments
29
+
30
+ ### Full dynamic INT8, 295 MB
31
+
32
+ ONNX Runtime `quantize_dynamic` with uint8 weights, per-channel, reduce_range, MatMul and Gather.
33
 
34
  | Input | fp32 | dynamic INT8 |
35
  |---|---:|---:|
 
38
  | `n g a` | 0.820 | 0.500 |
39
  | `pot` | 0.331 | 0.714 |
40
 
41
+ On the v44 model, benchmark catch fell from 93.9% to about 84.1%. On the current v6 combined model the full dynamic model catches about 63% at 0.94 with about 21% walls. The model did not simply become less confident; some benign short phrases became more confident.
42
+
43
+ ### Full static S8S8 QDQ, 296 MB
44
+
45
+ Signed S8S8 QDQ, per-channel weights, percentile calibration at 99.99. The quantizer needed external data and a small calibration set to avoid the OOM kill. Result: about 93% caught with about 47% walls at 0.94. Not usable.
46
+
47
+ ### Encoder INT8 with FP32 embedding, 858 MB
48
 
49
+ Dynamic and static encoder-only runs both failed. Dynamic: 57% caught at 0.94. Static: 94% caught but 48% walls. The encoder, not the embedding, is the sensitive part of this model.
50
 
51
+ ### Selective encoder layers, 520 MB
52
+
53
+ Quantizing the last 8 layers saved 120 MB but collapsed benchmark catch to 68% at 0.94. Quantizing only the 22 `attn/Wqkv` matrices saved 116 MB but still lost recall (94c / 8w at 0.94). This was the Unsloth Dynamic V3.1-inspired experiment: allocate lower precision only to less sensitive layers. On this small encoder the sensitivity is too flat for layer-wise bit allocation to work without QAT.
54
+
55
+ ### Embedding-only INT8, 611 MB
56
+
57
+ Near-lossless: 96c / 7w at 0.94, kkr 100/0, fresh2 100/17. This established that the embedding can be compressed safely and pointed at the encoder as the remaining cost.
58
+
59
+ ### FP16 on the unquantized fp32 graph
60
+
61
+ The first FP16 attempt failed because the rotary Cast and the embedding DequantizeLinear scale were converted to FP16. The repaired v7 pipeline above fixes both. FP16 alone on the full graph would be about 615 MB; combining it with INT8 embedding reaches 419 MB.
62
+
63
+ ### Embedding pruning, 498 to 521 MB
64
+
65
+ Pruning the 256k embedding to the most frequent tokens and remapping old IDs to compact IDs was tested at 100k, 80k and 70k rows. Sizes were 520.8, 505.5 and 497.8 MB. The 70k model met the disk target but lost benchmark recall (94.9c / 10.4w at 0.94) and produced new walls on short chat messages.
66
+
67
+ The root cause was not only OOV rate (0.5% to 0.8% of benchmark tokens). Tokens absent from the training corpus had random, never-trained embedding rows in v6, and replacing them with a different random `<unk>` row flipped short messages such as `wassup` from 0.03 to 0.99. Neutral mean/zero OOV rows fixed the short-message walls but lost harmful items such as `TODAY I END IT` and `give me gaijs`. Nearest-kept-embedding remapping preserved some rare-token direction but still diluted others. A pruned tokenizer that decomposes dropped pieces into kept subwords also changed tokenization on in-distribution text, because merge closure requires thousands of intermediate BPE symbols (105,860 tokens for the full closure).
68
+
69
+ Keeping the full vocabulary in FP16+INT8 avoids this entire failure mode and was both smaller and more accurate than the 100k pruned model (419 MB, 95.6c / 6.9w versus 521 MB, 95.3c / 8.8w).
70
 
 
71
 
 
72
 
73
  ## What the literature says
74
 
 
171
 
172
  ### Ship rule
173
 
174
+ v7 meets the disk and decision targets:
175
+
176
+ - Under 500 MB on disk: **419 MB ONNX, about 438 MB with tokenizer and n-gram**.
177
+ - Benchmark catch within 1 point of fp32: **95.6% versus 95.9%**.
178
+ - False walls within 1 point of fp32: **6.9% versus 6.7%**.
179
+ - Joke deflection and sex policy suites within 2 points: **met**.
180
+ - No regression on the always block terms and required allow homonyms: **met**.
181
+ - Latency not worse by more than about 20 ms: **80-110 ms versus 70-100 ms**.
182
 
183
+ It does not meet a 500 MB **resident memory** target. The ONNX Runtime session is about 0.85 GiB RSS and the full Python gate is about 1.2 GiB, because ORT CPU keeps FP32 working copies and prepacked weights. A 500 MB resident target would require encoder QAT to INT8, which is the next research step if resident memory becomes the hard constraint.
 
 
 
 
 
184
 
185
+ If no candidate meets the decision targets, keep fp32 in production and continue research. A fast gate that misses `ik ben nog maagd` or blocks `pot` is not a win.
186
 
187
  ## References
188
 
README.md CHANGED
@@ -1,32 +1,33 @@
1
  ---
2
  language: [nl, en]
3
  license: mit
4
- tags: [text-classification, content-moderation, safety, dutch, flemish, english, mmbert, binary-classifier, no-rules]
5
  pipeline_tag: text-classification
6
  ---
7
 
8
- # send-gate pure v6: Dutch/Flemish + English chat safety gate
9
 
10
  Binary safety gate for teen chat. The output is a verdict: **block** or **allow**. There is no review band.
11
 
12
- **Two learned branches, no regex, no word lists. Main 307M classifier plus a learned character n-gram branch. Thresholds 0.94 / 0.95. 95.9% caught and 6.7% false walls on the 804 item benchmark. 100% caught on every held-out variant suite.**
13
 
14
- The main branch sees the raw message plus its UTF-8 bytes for the character CNN. The second branch is a learned logistic model over hashed character 2-5 grams. It catches arbitrary `kkr`/`kanker` compounds, leetspeak and obfuscated links that the single classifier overfit and missed. The final decision is `block` if either learned branch is above its threshold.
15
 
16
- The previous pure v1-v5 releases are preserved on branches `pure_v1` to `pure_v5`. The rule-assisted v54 release is on `v54`.
17
 
18
  ## Files
19
 
20
  | File | Purpose |
21
  |---|---|
22
- | `model.onnx` | fp32 main classifier. 1.23 GB. |
23
- | `model.pt` | PyTorch main checkpoint. |
24
  | `ngram_model.npz` | Learned character n-gram branch. 1.5 MB. |
 
25
  | `gate.py` | Combined gate. No regex and no word lists. |
26
- | `model.py` | Minimal PyTorch loader for the main branch. |
27
- | `tokenizer.json` | Tokenizer for the main branch. |
28
- | `config.json` | Branch and threshold metadata. |
29
- | `QUANTIZATION.md` | Quantization research. |
 
30
 
31
  ## Measured results
32
 
@@ -34,12 +35,12 @@ Combined gate: main threshold 0.94, n-gram threshold 0.95.
34
 
35
  | Suite | Items | Caught | False walls | Accuracy |
36
  |---|---:|---:|---:|---:|
37
- | Project benchmark | 804 | **95.9%** | **6.7%** | 94.3% |
38
- | Obfuscation suite | 111 | 98.2% | 3.6% | 97.3% |
39
- | Sex policy suite | 125 | 97.6% | **0.0%** | 98.4% |
40
  | Joke deflection suite | 112 | 97.1% | **0.0%** | 98.2% |
41
  | Fresh deflection suite | 55 | 96.0% | **0.0%** | 98.2% |
42
- | General edge suite | 92 | 94.3% | 2.6% | 95.7% |
43
  | Final unseen holdout | 90 | 89.4% | **0.0%** | 94.4% |
44
  | Stress suite | 80 | 95.0% | 2.5% | 96.2% |
45
  | Common word suite | 97 | n/a | **0 blocked** | 100.0% |
@@ -53,40 +54,43 @@ Held-out variant suites:
53
  | Fresh variants | 66 | **100.0%** | 10.0% | 1 benign main-model wall |
54
  | Fresh2 variants, never trained | 59 | **100.0%** | 16.7% | 2 benign main-model walls |
55
 
56
- The walls in the held-out variant suites are benign main-model username/password/photo messages. The n-gram branch added no new walls in those suites.
57
 
58
  ## Requested policy terms
59
 
60
  | Input | Main | N-gram | Verdict |
61
  |---|---:|---:|---|
62
  | `kkrtest` | 0.991 | 0.991 | block |
63
- | `kkrtoets` | 0.494 | 0.995 | block |
64
  | `kkrexamen` | 0.061 | 0.994 | block |
65
- | `kkropdracht` | 0.338 | 0.998 | block |
66
- | `kkr moeilijk` | 0.064 | 0.984 | block |
67
- | `kkrjaar` | — | 0.979 | block |
68
- | `k4nkermoeilijke` | 0.073 | 0.997 | block |
69
- | `amaai, dat was een kankermoeilijke vraag op het examen` | 0.748 | 0.997 | block |
70
- | `roblox.gift/login` | 0.908 | 0.997 | block |
71
- | `free-skins.xyz/reward` | 0.530 | 0.999 | block |
72
- | `negerrr` | 0.959 | 0.802 | block |
73
- | `kankermoeilijke` | 0.962 | 0.979 | block |
74
- | `mijn oma heeft kanker gehad` | 0.942 | 0.939 | block |
75
 
76
  Benign controls:
77
 
78
  | Input | Main | N-gram | Verdict |
79
  |---|---:|---:|---|
80
- | `negeren` | 0.071 | 0.136 | allow |
81
- | `Niger is een land` | 0.092 | 0.029 | allow |
82
- | `ik speel roblox` | 0.036 | 0.019 | allow |
83
- | `kwart voor 8` | 0.002 | 0.047 | allow |
84
- | `stuur een foto van je hond` | 0.981 | 0.138 | block (known main-model wall) |
 
 
85
 
86
  ### Remaining gap
87
 
88
- - `het woord kanker` is still allowed (main 0.901, n-gram 0.913).
89
  - The main branch still walls some benign username, password and photo messages.
 
 
90
 
91
  These are documented model limitations, not hidden rules.
92
 
@@ -99,10 +103,34 @@ These are documented model limitations, not hidden rules.
99
  | Main threshold | 0.94 |
100
  | N-gram threshold | 0.95 |
101
  | Decision | block if either branch crosses its threshold |
102
- | Latency | about 80 to 450 ms on CPU |
103
 
104
  The n-gram branch is a learned model, not a regex and not a word list. It is the component that catches unseen `kkr`/`kanker` compounds such as `kkrexamen`, `kkropdracht`, `kkrjaar` and `k4nkermoeilijke`.
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  ## Training
107
 
108
  - Main branch: pure v4 checkpoint `runs/student_v1_pure_v84/last.pt`.
@@ -111,9 +139,11 @@ The n-gram branch is a learned model, not a regex and not a word list. It is the
111
 
112
  Hashes:
113
 
114
- - Main checkpoint: `a196a6eeec9c5391c7645bf74003474d14e496dba9a9eaed8f913af0cb5c6d75`
115
- - Main ONNX: `5ad26dc65068ad652d6fea3f00de8921bffd6b7da7e3cea0f7ed99ce7f309e08`
 
116
  - N-gram model: `0dec246def96c6f0668d22349b5abb60bba37d54fb9340ea7fb3b2bf6c6b0a03`
 
117
 
118
  ## Usage
119
 
@@ -135,11 +165,11 @@ print(result.blocked, result.score, result.reason)
135
 
136
  ## Limitations
137
 
138
- - False walls are 6.7% on the project benchmark, mostly benign Flemish slang, profanity, dark humour and a few username/password/photo messages.
139
  - `het woord kanker` remains allowed.
140
  - The final unseen holdout is 89.4% caught.
141
  - The model is binary; there is no review band or explanation.
142
- - The combined fp32 main branch is 1.23 GB on disk. The under 500 MB target is not met.
143
  - The benchmark and custom suites contain judgement calls.
144
 
145
  ## License
 
1
  ---
2
  language: [nl, en]
3
  license: mit
4
+ tags: [text-classification, content-moderation, safety, dutch, flemish, english, mmbert, binary-classifier, no-rules, onnx, quantized, fp16, int8]
5
  pipeline_tag: text-classification
6
  ---
7
 
8
+ # send-gate pure v7: compact Dutch/Flemish + English chat safety gate
9
 
10
  Binary safety gate for teen chat. The output is a verdict: **block** or **allow**. There is no review band.
11
 
12
+ **Two learned branches, no regex, no word lists. Main 307M classifier plus a learned character n-gram branch. Thresholds 0.94 / 0.95. 95.6% caught and 6.9% false walls on the 804 item benchmark. 100% caught on every held-out variant suite.**
13
 
14
+ v7 is the compact release: the encoder is stored in FP16 and the token embeddings in INT8. The full 256k vocabulary is kept, so the tokenizer is unchanged and there is no OOV remapping. The ONNX gate is **419 MB on disk** instead of 1.23 GB, with the same decisions as v6 apart from threshold-edge noise.
15
 
16
+ The fp32 v6 release remains on branch `pure_v6`. The pure v1-v5 releases are on `pure_v1` to `pure_v5`.
17
 
18
  ## Files
19
 
20
  | File | Purpose |
21
  |---|---|
22
+ | `model.onnx` | FP16 encoder + INT8 embedding main classifier. 419 MB. |
 
23
  | `ngram_model.npz` | Learned character n-gram branch. 1.5 MB. |
24
+ | `tokenizer.json` | Unchanged 256k tokenizer. 17 MB. |
25
  | `gate.py` | Combined gate. No regex and no word lists. |
26
+ | `model.py` | Minimal PyTorch loader for the fp32 main branch. |
27
+ | `config.json` | Branch, precision and threshold metadata. |
28
+ | `QUANTIZATION.md` | Quantization research and measurements. |
29
+
30
+ The fp32 PyTorch checkpoint and fp32 ONNX are on branch `pure_v6`.
31
 
32
  ## Measured results
33
 
 
35
 
36
  | Suite | Items | Caught | False walls | Accuracy |
37
  |---|---:|---:|---:|---:|
38
+ | Project benchmark | 804 | **95.6%** | **6.9%** | 94.0% |
39
+ | Obfuscation suite | 111 | 98.2% | 5.5% | 96.4% |
40
+ | Sex policy suite | 125 | 98.8% | **0.0%** | 99.2% |
41
  | Joke deflection suite | 112 | 97.1% | **0.0%** | 98.2% |
42
  | Fresh deflection suite | 55 | 96.0% | **0.0%** | 98.2% |
43
+ | General edge suite | 92 | 96.2% | 2.6% | 96.7% |
44
  | Final unseen holdout | 90 | 89.4% | **0.0%** | 94.4% |
45
  | Stress suite | 80 | 95.0% | 2.5% | 96.2% |
46
  | Common word suite | 97 | n/a | **0 blocked** | 100.0% |
 
54
  | Fresh variants | 66 | **100.0%** | 10.0% | 1 benign main-model wall |
55
  | Fresh2 variants, never trained | 59 | **100.0%** | 16.7% | 2 benign main-model walls |
56
 
57
+ v6 reference: 95.9% caught / 6.7% walls on the benchmark, and the same 100% recall on every held-out variant suite.
58
 
59
  ## Requested policy terms
60
 
61
  | Input | Main | N-gram | Verdict |
62
  |---|---:|---:|---|
63
  | `kkrtest` | 0.991 | 0.991 | block |
64
+ | `kkrtoets` | 0.557 | 0.995 | block |
65
  | `kkrexamen` | 0.061 | 0.994 | block |
66
+ | `kkropdracht` | 0.245 | 0.998 | block |
67
+ | `kkr moeilijk` | 0.077 | 0.984 | block |
68
+ | `kkrjaar` | 0.039 | 0.979 | block |
69
+ | `k4nkermoeilijke` | 0.079 | 0.997 | block |
70
+ | `amaai, dat was een kankermoeilijke vraag op het examen` | 0.958 | 0.998 | block |
71
+ | `roblox.gift/login` | 0.912 | 0.997 | block |
72
+ | `free-skins.xyz/reward` | 0.534 | 0.999 | block |
73
+ | `negerrr` | 0.980 | 0.938 | block |
74
+ | `kankermoeilijke` | 0.965 | 0.998 | block |
 
75
 
76
  Benign controls:
77
 
78
  | Input | Main | N-gram | Verdict |
79
  |---|---:|---:|---|
80
+ | `negeren` | 0.064 | 0.136 | allow |
81
+ | `Niger is een land` | 0.093 | 0.029 | allow |
82
+ | `ik speel roblox` | 0.037 | 0.019 | allow |
83
+ | `kwart voor 8` | 0.002 | 0.046 | allow |
84
+ | `mijn oma heeft kanker gehad` | 0.938 | 0.939 | allow |
85
+ | `het woord kanker` | 0.898 | 0.906 | allow |
86
+ | `stuur een foto van je hond` | 0.981 | 0.136 | block (known main-model wall) |
87
 
88
  ### Remaining gap
89
 
90
+ - `het woord kanker` and some health mentions are still allowed.
91
  - The main branch still walls some benign username, password and photo messages.
92
+ - `ja jammer` is a known benign false wall at about 0.99.
93
+ - Several decisions sit within a few thousandths of the threshold, so FP16 rounding can flip an individual edge item.
94
 
95
  These are documented model limitations, not hidden rules.
96
 
 
103
  | Main threshold | 0.94 |
104
  | N-gram threshold | 0.95 |
105
  | Decision | block if either branch crosses its threshold |
106
+ | Latency | about 80 to 110 ms per message on this CPU |
107
 
108
  The n-gram branch is a learned model, not a regex and not a word list. It is the component that catches unseen `kkr`/`kanker` compounds such as `kkrexamen`, `kkropdracht`, `kkrjaar` and `k4nkermoeilijke`.
109
 
110
+ ## Compression
111
+
112
+ | Item | v6 fp32 | v7 compact |
113
+ |---|---|---:|
114
+ | ONNX model | 1.23 GB | **419 MB** |
115
+ | Tokenizer | 17 MB | 17 MB |
116
+ | N-gram branch | 1.5 MB | 1.5 MB |
117
+ | Gate files on disk | about 1.25 GB | **about 438 MB** |
118
+ | ORT session RSS | about 1.65 GiB | about 0.85 GiB |
119
+ | Python gate RSS | about 1.65 GiB | about 1.2 GiB |
120
+ | Latency | about 70 to 100 ms | about 80 to 110 ms |
121
+
122
+ The encoder is FP16. The token embedding and char embedding are dynamic INT8. The embedding table is kept in full, so scaling to FP16 does not change tokenization or create OOV tokens. See `QUANTIZATION.md` for the rejected alternatives: encoder INT8, static QDQ, selective layer INT8, FP16 on the unquantized fp32 graph, and embedding pruning.
123
+
124
+ Rebuild:
125
+
126
+ ```bash
127
+ python experiments/export_fp16_int8.py \
128
+ --source /tmp/opencode/hf_pure_v84/model.onnx \
129
+ --output model.onnx
130
+ ```
131
+
132
+ The rebuild is byte-identical to the released file.
133
+
134
  ## Training
135
 
136
  - Main branch: pure v4 checkpoint `runs/student_v1_pure_v84/last.pt`.
 
139
 
140
  Hashes:
141
 
142
+ - Main ONNX, v7: `d1e0b78fcaff6f947e3593e964e4b9a4f25d99abe62558fc02319f6492255d36`
143
+ - Main ONNX, v6 fp32: `5ad26dc65068ad652d6fea3f00de8921bffd6b7da7e3cea0f7ed99ce7f309e08`
144
+ - Main checkpoint, fp32: `a196a6eeec9c5391c7645bf74003474d14e496dba9a9eaed8f913af0cb5c6d75`
145
  - N-gram model: `0dec246def96c6f0668d22349b5abb60bba37d54fb9340ea7fb3b2bf6c6b0a03`
146
+ - Tokenizer: `197d4cc5406ee12cc50c8b5511f2393cc32d9db321545979ce041c1199178356`
147
 
148
  ## Usage
149
 
 
165
 
166
  ## Limitations
167
 
168
+ - False walls are 6.9% on the project benchmark, mostly benign Flemish slang, profanity, dark humour and a few username/password/photo messages.
169
  - `het woord kanker` remains allowed.
170
  - The final unseen holdout is 89.4% caught.
171
  - The model is binary; there is no review band or explanation.
172
+ - The under 500 MB target is met on disk, not in resident memory. The ONNX session is about 0.85 GiB RSS.
173
  - The benchmark and custom suites contain judgement calls.
174
 
175
  ## License
config.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "model_type": "student_gate_pure",
3
- "version": "pure_v6",
4
  "backbone": "jhu-clsp/mmBERT-base",
5
  "backbone_params": "307M",
6
  "pooling": "cls",
@@ -12,6 +12,13 @@
12
  "ngram_threshold": 0.95,
13
  "ngram_model": "ngram_model.npz",
14
  "branches": ["main", "char_ngram"],
 
 
 
 
 
 
 
15
  "regex": false,
16
  "word_lists": false,
17
  "languages": ["nl", "en"],
 
1
  {
2
  "model_type": "student_gate_pure",
3
+ "version": "pure_v7",
4
  "backbone": "jhu-clsp/mmBERT-base",
5
  "backbone_params": "307M",
6
  "pooling": "cls",
 
12
  "ngram_threshold": 0.95,
13
  "ngram_model": "ngram_model.npz",
14
  "branches": ["main", "char_ngram"],
15
+ "precision": {
16
+ "encoder": "fp16",
17
+ "token_embedding": "int8",
18
+ "char_embedding": "int8",
19
+ "vocabulary": "full 256k, no pruning, no remapping"
20
+ },
21
+ "model_onnx_bytes": 418972704,
22
  "regex": false,
23
  "word_lists": false,
24
  "languages": ["nl", "en"],
gate.py CHANGED
@@ -33,10 +33,14 @@ class SendGate:
33
  threshold: float = DEFAULT_THRESHOLD,
34
  ngram_threshold: float = DEFAULT_NGRAM_THRESHOLD,
35
  ngram_model_path: str | Path = "ngram_model.npz",
 
36
  providers: list[str] | None = None):
37
  self.tok = Tokenizer.from_file(str(tokenizer_path))
 
 
38
  self.sess = ort.InferenceSession(
39
- str(model_path), providers=providers or ["CPUExecutionProvider"]
 
40
  )
41
  self.threshold = threshold
42
  self.ngram_threshold = ngram_threshold
@@ -46,9 +50,15 @@ class SendGate:
46
  z = np.load(str(ngram_model_path))
47
  self.ngram_coef = z["coef"]
48
  self.ngram_intercept = float(z["intercept"])
 
 
 
 
49
 
50
  def _encode(self, text: str):
51
  ids = self.tok.encode(text).ids[:MAX_LEN]
 
 
52
  n = len(ids)
53
  ids = ids + [0] * (MAX_LEN - n)
54
  mask = [1] * n + [0] * (MAX_LEN - n)
 
33
  threshold: float = DEFAULT_THRESHOLD,
34
  ngram_threshold: float = DEFAULT_NGRAM_THRESHOLD,
35
  ngram_model_path: str | Path = "ngram_model.npz",
36
+ vocab_map_path: str | Path | None = None,
37
  providers: list[str] | None = None):
38
  self.tok = Tokenizer.from_file(str(tokenizer_path))
39
+ sess_options = ort.SessionOptions()
40
+ sess_options.log_severity_level = 3
41
  self.sess = ort.InferenceSession(
42
+ str(model_path), sess_options=sess_options,
43
+ providers=providers or ["CPUExecutionProvider"]
44
  )
45
  self.threshold = threshold
46
  self.ngram_threshold = ngram_threshold
 
50
  z = np.load(str(ngram_model_path))
51
  self.ngram_coef = z["coef"]
52
  self.ngram_intercept = float(z["intercept"])
53
+ self.vocab_map = None
54
+ if vocab_map_path is not None:
55
+ z = np.load(str(vocab_map_path))
56
+ self.vocab_map = z["old_to_new"]
57
 
58
  def _encode(self, text: str):
59
  ids = self.tok.encode(text).ids[:MAX_LEN]
60
+ if self.vocab_map is not None:
61
+ ids = self.vocab_map[np.asarray(ids, dtype=np.int64)].tolist()
62
  n = len(ids)
63
  ids = ids + [0] * (MAX_LEN - n)
64
  mask = [1] * n + [0] * (MAX_LEN - n)
gate_pure.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure send-gate classifier: tokenizer -> ONNX -> sigmoid -> threshold.
2
+
3
+ No regex. No canonicalisation. No word lists. No model-view routing.
4
+ The classifier sees the raw message plus its UTF-8 bytes for the character CNN.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import onnxruntime as ort
13
+ from tokenizers import Tokenizer
14
+
15
+ DEFAULT_THRESHOLD = 0.82
16
+ MAX_LEN = 128
17
+ CHAR_LEN = 256
18
+
19
+
20
+ @dataclass
21
+ class Result:
22
+ blocked: bool
23
+ score: float
24
+ reason: str | None
25
+
26
+
27
+ class PureGate:
28
+ def __init__(self, model_path: str | Path = "model.onnx",
29
+ tokenizer_path: str | Path = "tokenizer.json",
30
+ threshold: float = DEFAULT_THRESHOLD,
31
+ providers: list[str] | None = None):
32
+ self.tok = Tokenizer.from_file(str(tokenizer_path))
33
+ self.sess = ort.InferenceSession(
34
+ str(model_path), providers=providers or ["CPUExecutionProvider"]
35
+ )
36
+ self.threshold = threshold
37
+
38
+ def _encode(self, text: str):
39
+ ids = self.tok.encode(text).ids[:MAX_LEN]
40
+ n = len(ids)
41
+ ids = ids + [0] * (MAX_LEN - n)
42
+ mask = [1] * n + [0] * (MAX_LEN - n)
43
+ raw = text.encode("utf-8", errors="ignore")[:CHAR_LEN]
44
+ chars = list(raw) + [0] * (CHAR_LEN - len(raw))
45
+ cmask = [1 if b else 0 for b in chars]
46
+ return (np.array([ids], dtype=np.int64), np.array([mask], dtype=np.int64),
47
+ np.array([chars], dtype=np.int64), np.array([cmask], dtype=np.int64))
48
+
49
+ def score(self, text: str) -> float:
50
+ ids, mask, chars, cmask = self._encode(text)
51
+ logit = self.sess.run(None, {
52
+ "input_ids": ids,
53
+ "attention_mask": mask,
54
+ "char_ids": chars,
55
+ "char_mask": cmask,
56
+ })[0].flatten()[0]
57
+ return float(1.0 / (1.0 + np.exp(-logit)))
58
+
59
+ def check(self, text: str) -> Result:
60
+ p = self.score(text)
61
+ return Result(blocked=p >= self.threshold, score=p,
62
+ reason="model" if p >= self.threshold else None)
model.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:5ad26dc65068ad652d6fea3f00de8921bffd6b7da7e3cea0f7ed99ce7f309e08
3
- size 1230333465
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d1e0b78fcaff6f947e3593e964e4b9a4f25d99abe62558fc02319f6492255d36
3
+ size 418972704
model.pt DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a196a6eeec9c5391c7645bf74003474d14e496dba9a9eaed8f913af0cb5c6d75
3
- size 1229743482
 
 
 
 
server.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Minimal pure send-gate demo server."""
3
+ from __future__ import annotations
4
+ import json, os, sys, time, threading
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ HERE=os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0,HERE)
7
+ from gate import SendGate
8
+ BASE=os.environ.get("GATE_DIR", "/tmp/opencode/hf_pure_v7")
9
+ def _ensure(name):
10
+ path=os.path.join(BASE,name)
11
+ if not os.path.exists(path):
12
+ os.makedirs(BASE,exist_ok=True)
13
+ from huggingface_hub import hf_hub_download
14
+ hf_hub_download(repo_id="yamura4/send-gate-307m",filename=name,local_dir=BASE)
15
+ return path
16
+ gate=SendGate(model_path=_ensure("model.onnx"), tokenizer_path=_ensure("tokenizer.json"), ngram_model_path=_ensure("ngram_model.npz"))
17
+ gate.score("hallo")
18
+ _lock=threading.Lock()
19
+ class H(BaseHTTPRequestHandler):
20
+ def _send(self, code, body, ctype="application/json"):
21
+ self.send_response(code); self.send_header("Content-Type",ctype); self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body)
22
+ def do_GET(self):
23
+ if self.path=="/healthz": self._send(200,b'{"ok":true}')
24
+ elif self.path=="/":
25
+ html=b"""<!doctype html><meta charset=utf-8><title>send-gate pure v7</title>
26
+ <body style="font-family:system-ui;max-width:760px;margin:40px auto"><h2>send-gate pure v7</h2>
27
+ <p>Binary safety gate. Model-only routing, no slur list.</p>
28
+ <textarea id=t style="width:100%;height:80px">kga ni liegen dit is een mopje ma lowkey niggerr</textarea><br>
29
+ <button onclick="go()">check</button><pre id=o></pre>
30
+ <script>async function go(){let r=await fetch('/api/check',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:document.getElementById('t').value})});document.getElementById('o').textContent=JSON.stringify(await r.json(),null,2)}</script>"""
31
+ self._send(200,html,"text/html; charset=utf-8")
32
+ else: self._send(404,b'{"error":"not found"}')
33
+ def do_POST(self):
34
+ if self.path!="/api/check": self._send(404,b'{"error":"not found"}'); return
35
+ try:
36
+ n=int(self.headers.get("Content-Length") or 0)
37
+ if n<=0 or n>64000: raise ValueError("bad body")
38
+ data=json.loads(self.rfile.read(n)); text=data.get("text")
39
+ if not isinstance(text,str) or not text.strip(): raise ValueError("text must be a non-empty string")
40
+ if len(text)>2000: raise ValueError("text too long")
41
+ except Exception as e:
42
+ self._send(400,json.dumps({"error":str(e)}).encode()); return
43
+ t0=time.perf_counter(); r=gate.check(text); ms=(time.perf_counter()-t0)*1000
44
+ self._send(200,json.dumps({"blocked":r.blocked,"score":round(r.score,5),"reason":r.reason,"threshold":gate.threshold,"ngram_threshold":gate.ngram_threshold,"latency_ms":round(ms,2)}).encode())
45
+ def log_message(self,fmt,*args): sys.stderr.write("%s %s\n"%(self.log_date_time_string(),fmt%args))
46
+ if __name__=="__main__":
47
+ srv=ThreadingHTTPServer(("127.0.0.1",7860),H); srv.daemon_threads=True; print("listening 7860",flush=True); srv.serve_forever()