AI spam classification decisions from calibration: maxContentBytes 2048 and spamMaxAdded 2.0 by default, for low-end CPU-only instances

This commit is contained in:
2026-09-19 06:19:38 -07:00
parent 9490fc4677
commit 554cc4fcd2
+50 -6
View File
@@ -127,15 +127,21 @@ subspace. Its id is `singleton`, as for `x:` singletons. It's server-level:
reading needs `sysSpamLlmGet` and changing needs `sysSpamLlmUpdate`, and a reading needs `sysSpamLlmGet` and changing needs `sysSpamLlmUpdate`, and a
principal in a tenant can do neither (AI-27). Unset fields read as the principal in a tenant can do neither (AI-27). Unset fields read as the
defaults below. Changes take effect for the next message or call, with no defaults below. Changes take effect for the next message or call, with no
reload. reload. **Decision** (2026-09-19), from calibration (see "Calibration"): the
defaults target low-end CPU-only instances, since few deployments will have a
GPU. `maxContentBytes` is 2048, not 16384: sending more text measured no
better and is what pushes a 2-core model past `spamCallCeiling`.
`spamMaxAdded` is 2.0, not 5.0: tested models pushed 20 to 26% of legitimate
mail up by 2 to 3 points under INBUXA's tag scores, so the model's word alone
adds at most 2.
| Field | Default | Meaning | | Field | Default | Meaning |
|---|---|---| |---|---|---|
| `spamMaxAdded` | `5.0` | Most an LLM tag can add to a message's score (AI-12) | | `spamMaxAdded` | `2.0` | Most an LLM tag can add to a message's score (AI-12) |
| `spamMaxSubtracted` | `1.0` | Most an LLM tag can take off a message's score (AI-12) | | `spamMaxSubtracted` | `1.0` | Most an LLM tag can take off a message's score (AI-12) |
| `spamCallCeiling` | `20s` | Longest the spam filter waits for the model, whatever the model's `timeout` (AI-9) | | `spamCallCeiling` | `20s` | Longest the spam filter waits for the model, whatever the model's `timeout` (AI-9) |
| `maxConcurrentCalls` | `4` | Model requests in flight at once, across both uses, per server node (AI-10) | | `maxConcurrentCalls` | `4` | Model requests in flight at once, across both uses, per server node (AI-10) |
| `maxContentBytes` | `16384` | Most message text sent per classification (AI-4) | | `maxContentBytes` | `2048` | Most message text sent per classification (AI-4) |
| `failureBackoff` | `60s` | Pause after repeated failures (AI-11) | | `failureBackoff` | `60s` | Pause after repeated failures (AI-11) |
| `userCallsPerHour` | `60` | `llm_prompt` calls per account per hour from its own scripts (AI-24) | | `userCallsPerHour` | `60` | `llm_prompt` calls per account per hour from its own scripts (AI-24) |
@@ -406,7 +412,7 @@ read the last message rather than the first (AI-6).
until `failureBackoff` ends, then one probe request (AI-11). until `failureBackoff` ends, then one probe request (AI-11).
10. `maxConcurrentCalls` 1, stub slow, two messages at once: one is 10. `maxConcurrentCalls` 1, stub slow, two messages at once: one is
classified, the other is delivered without a tag (AI-10). classified, the other is delivered without a tag (AI-10).
11. `LLM_UNSOLICITED_HIGH` scored 50: the message's score rises by 5.0. 11. `LLM_UNSOLICITED_HIGH` scored 50: the message's score rises by 2.0.
`LLM_LEGITIMATE_HIGH` scored 50: it falls by 1.0. `LLM_LEGITIMATE_HIGH` scored 50: it falls by 1.0.
`LLM_HARMFUL_HIGH` set to `Reject`: the message isn't rejected (AI-13). `LLM_HARMFUL_HIGH` set to `Reject`: the message isn't rejected (AI-13).
12. Stub's explanation contains CRLF, a fake header and non-ASCII text: one 12. Stub's explanation contains CRLF, a fake header and non-ASCII text: one
@@ -501,6 +507,43 @@ last message.
- Test 21's warning count is checked only when `registry.build-warning` is - Test 21's warning count is checked only when `registry.build-warning` is
a metric of interest; the warning itself is always logged. a metric of interest; the warning itself is always logged.
## Calibration
Measured 2026-09-19 against real local models, with
`tests/src/system/ai_calibration.rs` (ignored): it sends exactly what the
classifier sends and reads answers with the classifier's own parser. Mail:
300 messages from the SpamAssassin public corpus (150 ham, 50 of them "hard
ham" such as newsletters, and 150 spam from 2002 to 2005), the fork's default
prompt, INBUXA's `LLM_*` scores (observed 2). Models ran under llama.cpp,
quantized to Q4_K_M. Accuracy doesn't depend on hardware, so it was measured
on a GPU; latency on CPU cores pinned to model a small VPS (Zen 5 cores,
faster than most VPS vCPUs, so real instances will be slower).
| Model (licence) | In format | Spam pushed ≥ +2 | Ham pushed ≥ +2 | 2 cores, p50 / p95 | 4 cores, p50 / p95 |
|---|---|---|---|---|---|
| Qwen2.5 7B (Apache-2.0) | 99% | 93% | 26% | 17.9 / 35 s | 9.7 / 18.5 s |
| Llama 3.1 8B (Llama 3.1) | 100% | 96% | 23% | not measured | not measured |
| Qwen2.5 3B (Qwen Research, non-commercial) | 100% | 63 to 66% | 21 to 25% | 8.1 / 15.2 s at 2 KiB | 4.6 / 8.0 s |
| Qwen2.5 1.5B (Apache-2.0) | 80% | 20% | 13% | | |
| Qwen2.5 0.5B (Apache-2.0) | 19% | 5% | 3% | | |
| Phi-3.5 mini (MIT) | 33% | | | | |
Findings:
- **Text sent.** 2 KiB and 4 KiB scored the same as 16 KiB; 4 KiB pushed a
2-core 3B model past 20 s on 8% of messages, 2 KiB on none. Hence the 2048
default.
- **False positives.** Ham is pushed up mostly as `Commercial`: marketing the
recipient signed up for, which content alone can't tell from spam. A prompt
redefining the categories made it worse on every model, so the default
prompt stays. Hence the 2.0 cap.
- **Size.** Below 3B, models don't follow the one-line format reliably.
Phi-3.5 mini writes "High confidence" on a new line, which the parser
rightly doesn't accept.
- **Licences.** Qwen2.5 3B, the best small model tested, is licensed for
non-commercial use only, so it can't be the recommendation for INBUXA's
customers.
## Observed ## Observed
Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart
@@ -512,8 +555,9 @@ account. No upstream code was read.
2. **`LLM_*` tags** (open question 2). Twelve entries, all `Score`, for every 2. **`LLM_*` tags** (open question 2). Twelve entries, all `Score`, for every
category and confidence: `HIGH` 3.0, `MEDIUM` 2.0, `LOW` 0.5 for category and confidence: `HIGH` 3.0, `MEDIUM` 2.0, `LOW` 0.5 for
`UNSOLICITED`, `COMMERCIAL` and `HARMFUL`, and 3.0, 2.0, 0.5 for `UNSOLICITED`, `COMMERCIAL` and `HARMFUL`, and 3.0, 2.0, 0.5 for
`LEGITIMATE`. AI-13's clamp (+5.0, 1.0) cuts only the `LEGITIMATE` `LEGITIMATE`. AI-13's clamp (+2.0 and 1.0, since calibration) cuts the
`HIGH` and `MEDIUM` scores. positive `HIGH` scores to 2.0 and the `LEGITIMATE` `HIGH` and `MEDIUM`
scores to 1.0.
3. **`X-Spam-LLM`** (open question 3). Not observable: the classifier has 3. **`X-Spam-LLM`** (open question 3). Not observable: the classifier has
never run on INBUXA. never run on INBUXA.
4. **`interactAi`** (open question 5). Ordinary accounts hold it: the test 4. **`interactAi`** (open question 5). Ordinary accounts hold it: the test