adaptersentry 2.0.0__tar.gz → 2.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/PKG-INFO +29 -19
  2. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/README.md +28 -18
  3. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/adaptersentry.egg-info/PKG-INFO +29 -19
  4. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/benchmarks/hub_scanner.py +2 -0
  5. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/pyproject.toml +1 -1
  6. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/detectors/intra_adapter.py +36 -8
  7. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/result_sink.py +1 -1
  8. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/schemas/combined_report.py +1 -1
  9. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/worker.py +1 -1
  10. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/reporters/json.py +1 -1
  11. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/reporters/sarif.py +1 -1
  12. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/reporters/text.py +4 -2
  13. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/scanner.py +34 -11
  14. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/schemas/result.py +8 -1
  15. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/scoring/verdict.py +11 -7
  16. adaptersentry-2.0.1/src/adaptersentry/version.py +1 -0
  17. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/tests/test_bench.py +1 -1
  18. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/tests/test_scanner.py +4 -1
  19. adaptersentry-2.0.0/src/adaptersentry/version.py +0 -1
  20. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/LICENSE +0 -0
  21. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/adaptersentry.egg-info/SOURCES.txt +0 -0
  22. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/adaptersentry.egg-info/dependency_links.txt +0 -0
  23. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/adaptersentry.egg-info/entry_points.txt +0 -0
  24. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/adaptersentry.egg-info/requires.txt +0 -0
  25. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/adaptersentry.egg-info/top_level.txt +0 -0
  26. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/benchmarks/__init__.py +0 -0
  27. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/benchmarks/check_regression.py +0 -0
  28. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/benchmarks/corpus.py +0 -0
  29. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/benchmarks/harness.py +0 -0
  30. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/benchmarks/report.py +0 -0
  31. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/benchmarks/run_real.py +0 -0
  32. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/setup.cfg +0 -0
  33. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/__init__.py +0 -0
  34. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/__main__.py +0 -0
  35. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/cli/__init__.py +0 -0
  36. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/cli/batch.py +0 -0
  37. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/cli/main.py +0 -0
  38. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/cli/scan.py +0 -0
  39. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/detectors/__init__.py +0 -0
  40. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/detectors/structural.py +0 -0
  41. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/__init__.py +0 -0
  42. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/cache.py +0 -0
  43. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/identity.py +0 -0
  44. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/manifest.py +0 -0
  45. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/orchestrator.py +0 -0
  46. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/orchestrator_ray.py +0 -0
  47. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/schemas/__init__.py +0 -0
  48. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/schemas/cache.py +0 -0
  49. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/schemas/identity.py +0 -0
  50. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/engine/schemas/requests.py +0 -0
  51. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/features/__init__.py +0 -0
  52. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/features/lowrank_core.py +0 -0
  53. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/features/spectral.py +0 -0
  54. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/parsers/__init__.py +0 -0
  55. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/parsers/adapter_config.py +0 -0
  56. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/parsers/adapter_file.py +0 -0
  57. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/parsers/names.py +0 -0
  58. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/parsers/safetensors.py +0 -0
  59. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/reporters/__init__.py +0 -0
  60. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/reporting/__init__.py +0 -0
  61. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/reporting/sanitize.py +0 -0
  62. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/schemas/__init__.py +0 -0
  63. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/schemas/errors.py +0 -0
  64. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/schemas/severity.py +0 -0
  65. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/src/adaptersentry/scoring/__init__.py +0 -0
  66. {adaptersentry-2.0.0 → adaptersentry-2.0.1}/tests/test_harness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adaptersentry
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: Static security scanner for LoRA adapters — detects anomalous weight patterns in .safetensors files
5
5
  License:
6
6
  Apache License
@@ -246,7 +246,7 @@ Dynamic: license-file
246
246
 
247
247
  ![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue)
248
248
  ![License](https://img.shields.io/badge/license-Apache%202.0-blue)
249
- ![Version](https://img.shields.io/badge/version-2.0.0-blue)
249
+ ![Version](https://img.shields.io/badge/version-2.0.1-blue)
250
250
 
251
251
  AdapterSentry is a static security scanner for LoRA adapters distributed as `.safetensors`
252
252
  files. A LoRA adapter is a small file that changes how a large language model behaves; anyone
@@ -257,7 +257,9 @@ loaded, without running the model and without knowing the trigger.
257
257
  **v2.0.0** replaces the analysis engine: exact statistics of the effective update
258
258
  ΔW = s·B·A (no sampling, no proxies), streaming analysis that scales to 70B–405B and MoE
259
259
  models in tens of megabytes of memory, a comparison of every module with its own family inside
260
- the adapter, structural findings, and a new result contract (`ScanResult 2.0.0`).
260
+ the adapter, structural findings, and a new result contract (`ScanResult 2.x`).
261
+ **v2.0.1** recalibrates the intra-adapter verdict rule on 393 real adapters from the Hugging
262
+ Face Hub (see [Limits](#limits--read-before-relying-on-a-verdict)).
261
263
  See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
262
264
 
263
265
  ---
@@ -266,18 +268,18 @@ See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
266
268
 
267
269
  ```text
268
270
  $ adaptersentry scan adapter_model.safetensors
269
- AdapterSentry 2.0.0 · adapter_model.safetensors
271
+ AdapterSentry 2.0.1 · adapter_model.safetensors
270
272
  ================================================================
271
273
  Verdict: REVIEW (MEDIUM) · confidence medium · behavioural check recommended
272
274
  Status: ok · policy default · mode full
273
275
  Why:
274
276
  - [LOW] NO_REFERENCE_PROFILE: No calibrated reference for this model family; verdict relies on intra-adapter comparison
275
- - [MEDIUM] INTRA_OUTLIER: Module departs from its family: down_proj layer 47, robust z = 9.4 (threshold 8.0, uncalibrated)
277
+ - [MEDIUM] INTRA_OUTLIER: Update concentrated on a few outputs, unlike the rest of its family (down_proj layer 47): concentration z = 21.3 ≥ 14.0 (threshold set at 5 % alerts on popular Hub adapters)
276
278
  Adapter: peft_lora, family llama (config), 80 layers, r=64 (declared 64), α=128, scaling alpha_over_r, training trained
277
279
  Coverage: 560/560 modules analysed; 0 tensor(s) not analysed
278
- Intra: max robust z 9.4 · 2 outlier module(s) · energy Gini 0.41
280
+ Intra: concentration z 21.3 · max single-feature z 64.0 · 9 outlier module(s) · energy Gini 0.41
279
281
  Findings (1):
280
- [MEDIUM] INTRA_DEPTH_SPIKE — Module departs from its family (layer 47, down_proj)
282
+ [MEDIUM] INTRA_CONCENTRATION_SPIKE — Update concentrated on a few outputs, unlike its family (layer 47, down_proj)
281
283
  ```
282
284
  *(Illustrative output.)*
283
285
 
@@ -292,7 +294,7 @@ level and what limits it. CI gates read `verdict.action`.
292
294
  pip install adaptersentry
293
295
 
294
296
  adaptersentry scan adapter_model.safetensors # text summary
295
- adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.0.0
297
+ adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.1.0
296
298
  adaptersentry scan adapter_model.safetensors --format sarif \
297
299
  --output adaptersentry.sarif --fail-on review # GitHub code scanning + CI gate
298
300
  adaptersentry batch --input-dir adapters/ --workers 8 --fail-on review
@@ -349,10 +351,13 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
349
351
  spiky rows are not missed), and how concentrated the update is on a few output rows or
350
352
  input columns — for `lm_head` and embeddings, on a few tokens.
351
353
  4. **Compare within the adapter.** Each module is compared with its family (all `q_proj`, all
352
- `down_proj`, …): the smooth trend with depth is removed with a Theil–Sen line, then robust
353
- z-scores (median/MAD) mark modules that depart from their family. This needs no reference
354
- data and is insensitive to the adapter's overall strength. It runs for families of at
355
- least 16 modules.
354
+ `down_proj`, …): the trend with depth is removed with a Theil–Sen line, then robust
355
+ z-scores (median/MAD) are computed for every feature. Real adapters differ a lot between
356
+ modules of one family, so a single unusual feature is reported but does not change the
357
+ verdict. The verdict looks for one pattern: a module whose update is **concentrated on a
358
+ few outputs** — entry kurtosis, top-8 row share and row Hoyer all rise against the family
359
+ (*concentration z*). This needs no reference data and is insensitive to the adapter's
360
+ overall strength. It runs for families of at least 16 modules.
356
361
  5. **Structural findings** that numbers cannot show: complete `lm_head` / embedding / router
357
362
  matrices shipped in the adapter, LoRA on a mixture-of-experts router, ranks that differ from
358
363
  the declared `r` (typical of merged adapters), code/pickle/archive files next to the adapter.
@@ -361,7 +366,7 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
361
366
  | Situation | Action |
362
367
  |---|---|
363
368
  | File could not be parsed, or part of it was not analysed | at least `review` |
364
- | A module departs from its family (robust z ≥ 8) | `review` |
369
+ | A module's update is concentrated on a few outputs, unlike its family (concentration z ≥ 14) | `review` |
365
370
  | Structural red flag | `review`; `block` with `--policy strict` |
366
371
  | Reference profile for the model family says p ≤ α | `review` / `block` *(reference profiles: next release)* |
367
372
  | Otherwise | `allow` |
@@ -386,10 +391,15 @@ needed ~3.3 GB just to load the same file.
386
391
  random seeds can exceed the signal of a backdoor. A low-risk verdict is not evidence that an
387
392
  adapter is safe; behavioural verification in an isolated environment remains necessary for
388
393
  adapters you do not trust.
389
- - **Thresholds are not yet calibrated.** Intra-adapter thresholds (|z| > 3.5 outlier,
390
- ≥ 8 review) were set on synthetic data. Reference profiles per model family and a measured
391
- detection rate on labelled clean/poisoned adapters are the next step; until then the
392
- confidence of a verdict is at most `medium`.
394
+ - **Calibration is partial.** The intra-adapter threshold (concentration z ≥ 14) was set on
395
+ 393 popular PEFT adapters from the Hugging Face Hub (October 2026; mostly Qwen and Llama
396
+ up to 14B), presumed benign but not verified: about **5 % of them get `review`** from this
397
+ rule. Larger models and other families may behave differently. Detection was measured only
398
+ on a toy payload inserted into those adapters (one LoRA direction redirected into 4 outputs
399
+ of a mid-depth `down_proj`): **71 %** found when the payload is strong, **≤ 3 %** when it is
400
+ as strong as an ordinary direction. Real backdoors are not toy payloads; a measured
401
+ detection rate on labelled clean/poisoned adapters and reference profiles per model family
402
+ are the next steps. Until then the confidence of a verdict is at most `medium`.
393
403
  - **An attacker who knows the detector** can try to spread a backdoor across many directions
394
404
  and modules, or pad the update with inert components. Static analysis without base-model
395
405
  information cannot fully rule this out.
@@ -401,7 +411,7 @@ needed ~3.3 GB just to load the same file.
401
411
 
402
412
  ---
403
413
 
404
- ## Output: ScanResult 2.0.0
414
+ ## Output: ScanResult 2.1.0
405
415
 
406
416
  `--format json` produces one document per adapter: `scan` (who/when/how), `artifact` (hashes,
407
417
  provenance — file name only unless `--full-paths`), `adapter` (format, family, rank, α,
@@ -410,7 +420,7 @@ scaling, modules), `status` + `coverage` (what was and was not analysed), `verdi
410
420
  optional `modules`, and `errors`.
411
421
 
412
422
  - Field reference: [docs/output-schema/scan-result.md](docs/output-schema/scan-result.md)
413
- - JSON Schema: [docs/output-schema/scan-result-2.0.0.schema.json](docs/output-schema/scan-result-2.0.0.schema.json)
423
+ - JSON Schema: [docs/output-schema/scan-result-2.1.0.schema.json](docs/output-schema/scan-result-2.1.0.schema.json)
414
424
  - `adaptersentry.load_scan_result()` validates a document and rejects other major versions.
415
425
 
416
426
  ---
@@ -2,7 +2,7 @@
2
2
 
3
3
  ![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue)
4
4
  ![License](https://img.shields.io/badge/license-Apache%202.0-blue)
5
- ![Version](https://img.shields.io/badge/version-2.0.0-blue)
5
+ ![Version](https://img.shields.io/badge/version-2.0.1-blue)
6
6
 
7
7
  AdapterSentry is a static security scanner for LoRA adapters distributed as `.safetensors`
8
8
  files. A LoRA adapter is a small file that changes how a large language model behaves; anyone
@@ -13,7 +13,9 @@ loaded, without running the model and without knowing the trigger.
13
13
  **v2.0.0** replaces the analysis engine: exact statistics of the effective update
14
14
  ΔW = s·B·A (no sampling, no proxies), streaming analysis that scales to 70B–405B and MoE
15
15
  models in tens of megabytes of memory, a comparison of every module with its own family inside
16
- the adapter, structural findings, and a new result contract (`ScanResult 2.0.0`).
16
+ the adapter, structural findings, and a new result contract (`ScanResult 2.x`).
17
+ **v2.0.1** recalibrates the intra-adapter verdict rule on 393 real adapters from the Hugging
18
+ Face Hub (see [Limits](#limits--read-before-relying-on-a-verdict)).
17
19
  See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
18
20
 
19
21
  ---
@@ -22,18 +24,18 @@ See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
22
24
 
23
25
  ```text
24
26
  $ adaptersentry scan adapter_model.safetensors
25
- AdapterSentry 2.0.0 · adapter_model.safetensors
27
+ AdapterSentry 2.0.1 · adapter_model.safetensors
26
28
  ================================================================
27
29
  Verdict: REVIEW (MEDIUM) · confidence medium · behavioural check recommended
28
30
  Status: ok · policy default · mode full
29
31
  Why:
30
32
  - [LOW] NO_REFERENCE_PROFILE: No calibrated reference for this model family; verdict relies on intra-adapter comparison
31
- - [MEDIUM] INTRA_OUTLIER: Module departs from its family: down_proj layer 47, robust z = 9.4 (threshold 8.0, uncalibrated)
33
+ - [MEDIUM] INTRA_OUTLIER: Update concentrated on a few outputs, unlike the rest of its family (down_proj layer 47): concentration z = 21.3 ≥ 14.0 (threshold set at 5 % alerts on popular Hub adapters)
32
34
  Adapter: peft_lora, family llama (config), 80 layers, r=64 (declared 64), α=128, scaling alpha_over_r, training trained
33
35
  Coverage: 560/560 modules analysed; 0 tensor(s) not analysed
34
- Intra: max robust z 9.4 · 2 outlier module(s) · energy Gini 0.41
36
+ Intra: concentration z 21.3 · max single-feature z 64.0 · 9 outlier module(s) · energy Gini 0.41
35
37
  Findings (1):
36
- [MEDIUM] INTRA_DEPTH_SPIKE — Module departs from its family (layer 47, down_proj)
38
+ [MEDIUM] INTRA_CONCENTRATION_SPIKE — Update concentrated on a few outputs, unlike its family (layer 47, down_proj)
37
39
  ```
38
40
  *(Illustrative output.)*
39
41
 
@@ -48,7 +50,7 @@ level and what limits it. CI gates read `verdict.action`.
48
50
  pip install adaptersentry
49
51
 
50
52
  adaptersentry scan adapter_model.safetensors # text summary
51
- adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.0.0
53
+ adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.1.0
52
54
  adaptersentry scan adapter_model.safetensors --format sarif \
53
55
  --output adaptersentry.sarif --fail-on review # GitHub code scanning + CI gate
54
56
  adaptersentry batch --input-dir adapters/ --workers 8 --fail-on review
@@ -105,10 +107,13 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
105
107
  spiky rows are not missed), and how concentrated the update is on a few output rows or
106
108
  input columns — for `lm_head` and embeddings, on a few tokens.
107
109
  4. **Compare within the adapter.** Each module is compared with its family (all `q_proj`, all
108
- `down_proj`, …): the smooth trend with depth is removed with a Theil–Sen line, then robust
109
- z-scores (median/MAD) mark modules that depart from their family. This needs no reference
110
- data and is insensitive to the adapter's overall strength. It runs for families of at
111
- least 16 modules.
110
+ `down_proj`, …): the trend with depth is removed with a Theil–Sen line, then robust
111
+ z-scores (median/MAD) are computed for every feature. Real adapters differ a lot between
112
+ modules of one family, so a single unusual feature is reported but does not change the
113
+ verdict. The verdict looks for one pattern: a module whose update is **concentrated on a
114
+ few outputs** — entry kurtosis, top-8 row share and row Hoyer all rise against the family
115
+ (*concentration z*). This needs no reference data and is insensitive to the adapter's
116
+ overall strength. It runs for families of at least 16 modules.
112
117
  5. **Structural findings** that numbers cannot show: complete `lm_head` / embedding / router
113
118
  matrices shipped in the adapter, LoRA on a mixture-of-experts router, ranks that differ from
114
119
  the declared `r` (typical of merged adapters), code/pickle/archive files next to the adapter.
@@ -117,7 +122,7 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
117
122
  | Situation | Action |
118
123
  |---|---|
119
124
  | File could not be parsed, or part of it was not analysed | at least `review` |
120
- | A module departs from its family (robust z ≥ 8) | `review` |
125
+ | A module's update is concentrated on a few outputs, unlike its family (concentration z ≥ 14) | `review` |
121
126
  | Structural red flag | `review`; `block` with `--policy strict` |
122
127
  | Reference profile for the model family says p ≤ α | `review` / `block` *(reference profiles: next release)* |
123
128
  | Otherwise | `allow` |
@@ -142,10 +147,15 @@ needed ~3.3 GB just to load the same file.
142
147
  random seeds can exceed the signal of a backdoor. A low-risk verdict is not evidence that an
143
148
  adapter is safe; behavioural verification in an isolated environment remains necessary for
144
149
  adapters you do not trust.
145
- - **Thresholds are not yet calibrated.** Intra-adapter thresholds (|z| > 3.5 outlier,
146
- ≥ 8 review) were set on synthetic data. Reference profiles per model family and a measured
147
- detection rate on labelled clean/poisoned adapters are the next step; until then the
148
- confidence of a verdict is at most `medium`.
150
+ - **Calibration is partial.** The intra-adapter threshold (concentration z ≥ 14) was set on
151
+ 393 popular PEFT adapters from the Hugging Face Hub (October 2026; mostly Qwen and Llama
152
+ up to 14B), presumed benign but not verified: about **5 % of them get `review`** from this
153
+ rule. Larger models and other families may behave differently. Detection was measured only
154
+ on a toy payload inserted into those adapters (one LoRA direction redirected into 4 outputs
155
+ of a mid-depth `down_proj`): **71 %** found when the payload is strong, **≤ 3 %** when it is
156
+ as strong as an ordinary direction. Real backdoors are not toy payloads; a measured
157
+ detection rate on labelled clean/poisoned adapters and reference profiles per model family
158
+ are the next steps. Until then the confidence of a verdict is at most `medium`.
149
159
  - **An attacker who knows the detector** can try to spread a backdoor across many directions
150
160
  and modules, or pad the update with inert components. Static analysis without base-model
151
161
  information cannot fully rule this out.
@@ -157,7 +167,7 @@ needed ~3.3 GB just to load the same file.
157
167
 
158
168
  ---
159
169
 
160
- ## Output: ScanResult 2.0.0
170
+ ## Output: ScanResult 2.1.0
161
171
 
162
172
  `--format json` produces one document per adapter: `scan` (who/when/how), `artifact` (hashes,
163
173
  provenance — file name only unless `--full-paths`), `adapter` (format, family, rank, α,
@@ -166,7 +176,7 @@ scaling, modules), `status` + `coverage` (what was and was not analysed), `verdi
166
176
  optional `modules`, and `errors`.
167
177
 
168
178
  - Field reference: [docs/output-schema/scan-result.md](docs/output-schema/scan-result.md)
169
- - JSON Schema: [docs/output-schema/scan-result-2.0.0.schema.json](docs/output-schema/scan-result-2.0.0.schema.json)
179
+ - JSON Schema: [docs/output-schema/scan-result-2.1.0.schema.json](docs/output-schema/scan-result-2.1.0.schema.json)
170
180
  - `adaptersentry.load_scan_result()` validates a document and rejects other major versions.
171
181
 
172
182
  ---
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adaptersentry
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: Static security scanner for LoRA adapters — detects anomalous weight patterns in .safetensors files
5
5
  License:
6
6
  Apache License
@@ -246,7 +246,7 @@ Dynamic: license-file
246
246
 
247
247
  ![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue)
248
248
  ![License](https://img.shields.io/badge/license-Apache%202.0-blue)
249
- ![Version](https://img.shields.io/badge/version-2.0.0-blue)
249
+ ![Version](https://img.shields.io/badge/version-2.0.1-blue)
250
250
 
251
251
  AdapterSentry is a static security scanner for LoRA adapters distributed as `.safetensors`
252
252
  files. A LoRA adapter is a small file that changes how a large language model behaves; anyone
@@ -257,7 +257,9 @@ loaded, without running the model and without knowing the trigger.
257
257
  **v2.0.0** replaces the analysis engine: exact statistics of the effective update
258
258
  ΔW = s·B·A (no sampling, no proxies), streaming analysis that scales to 70B–405B and MoE
259
259
  models in tens of megabytes of memory, a comparison of every module with its own family inside
260
- the adapter, structural findings, and a new result contract (`ScanResult 2.0.0`).
260
+ the adapter, structural findings, and a new result contract (`ScanResult 2.x`).
261
+ **v2.0.1** recalibrates the intra-adapter verdict rule on 393 real adapters from the Hugging
262
+ Face Hub (see [Limits](#limits--read-before-relying-on-a-verdict)).
261
263
  See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
262
264
 
263
265
  ---
@@ -266,18 +268,18 @@ See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
266
268
 
267
269
  ```text
268
270
  $ adaptersentry scan adapter_model.safetensors
269
- AdapterSentry 2.0.0 · adapter_model.safetensors
271
+ AdapterSentry 2.0.1 · adapter_model.safetensors
270
272
  ================================================================
271
273
  Verdict: REVIEW (MEDIUM) · confidence medium · behavioural check recommended
272
274
  Status: ok · policy default · mode full
273
275
  Why:
274
276
  - [LOW] NO_REFERENCE_PROFILE: No calibrated reference for this model family; verdict relies on intra-adapter comparison
275
- - [MEDIUM] INTRA_OUTLIER: Module departs from its family: down_proj layer 47, robust z = 9.4 (threshold 8.0, uncalibrated)
277
+ - [MEDIUM] INTRA_OUTLIER: Update concentrated on a few outputs, unlike the rest of its family (down_proj layer 47): concentration z = 21.3 ≥ 14.0 (threshold set at 5 % alerts on popular Hub adapters)
276
278
  Adapter: peft_lora, family llama (config), 80 layers, r=64 (declared 64), α=128, scaling alpha_over_r, training trained
277
279
  Coverage: 560/560 modules analysed; 0 tensor(s) not analysed
278
- Intra: max robust z 9.4 · 2 outlier module(s) · energy Gini 0.41
280
+ Intra: concentration z 21.3 · max single-feature z 64.0 · 9 outlier module(s) · energy Gini 0.41
279
281
  Findings (1):
280
- [MEDIUM] INTRA_DEPTH_SPIKE — Module departs from its family (layer 47, down_proj)
282
+ [MEDIUM] INTRA_CONCENTRATION_SPIKE — Update concentrated on a few outputs, unlike its family (layer 47, down_proj)
281
283
  ```
282
284
  *(Illustrative output.)*
283
285
 
@@ -292,7 +294,7 @@ level and what limits it. CI gates read `verdict.action`.
292
294
  pip install adaptersentry
293
295
 
294
296
  adaptersentry scan adapter_model.safetensors # text summary
295
- adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.0.0
297
+ adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.1.0
296
298
  adaptersentry scan adapter_model.safetensors --format sarif \
297
299
  --output adaptersentry.sarif --fail-on review # GitHub code scanning + CI gate
298
300
  adaptersentry batch --input-dir adapters/ --workers 8 --fail-on review
@@ -349,10 +351,13 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
349
351
  spiky rows are not missed), and how concentrated the update is on a few output rows or
350
352
  input columns — for `lm_head` and embeddings, on a few tokens.
351
353
  4. **Compare within the adapter.** Each module is compared with its family (all `q_proj`, all
352
- `down_proj`, …): the smooth trend with depth is removed with a Theil–Sen line, then robust
353
- z-scores (median/MAD) mark modules that depart from their family. This needs no reference
354
- data and is insensitive to the adapter's overall strength. It runs for families of at
355
- least 16 modules.
354
+ `down_proj`, …): the trend with depth is removed with a Theil–Sen line, then robust
355
+ z-scores (median/MAD) are computed for every feature. Real adapters differ a lot between
356
+ modules of one family, so a single unusual feature is reported but does not change the
357
+ verdict. The verdict looks for one pattern: a module whose update is **concentrated on a
358
+ few outputs** — entry kurtosis, top-8 row share and row Hoyer all rise against the family
359
+ (*concentration z*). This needs no reference data and is insensitive to the adapter's
360
+ overall strength. It runs for families of at least 16 modules.
356
361
  5. **Structural findings** that numbers cannot show: complete `lm_head` / embedding / router
357
362
  matrices shipped in the adapter, LoRA on a mixture-of-experts router, ranks that differ from
358
363
  the declared `r` (typical of merged adapters), code/pickle/archive files next to the adapter.
@@ -361,7 +366,7 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
361
366
  | Situation | Action |
362
367
  |---|---|
363
368
  | File could not be parsed, or part of it was not analysed | at least `review` |
364
- | A module departs from its family (robust z ≥ 8) | `review` |
369
+ | A module's update is concentrated on a few outputs, unlike its family (concentration z ≥ 14) | `review` |
365
370
  | Structural red flag | `review`; `block` with `--policy strict` |
366
371
  | Reference profile for the model family says p ≤ α | `review` / `block` *(reference profiles: next release)* |
367
372
  | Otherwise | `allow` |
@@ -386,10 +391,15 @@ needed ~3.3 GB just to load the same file.
386
391
  random seeds can exceed the signal of a backdoor. A low-risk verdict is not evidence that an
387
392
  adapter is safe; behavioural verification in an isolated environment remains necessary for
388
393
  adapters you do not trust.
389
- - **Thresholds are not yet calibrated.** Intra-adapter thresholds (|z| > 3.5 outlier,
390
- ≥ 8 review) were set on synthetic data. Reference profiles per model family and a measured
391
- detection rate on labelled clean/poisoned adapters are the next step; until then the
392
- confidence of a verdict is at most `medium`.
394
+ - **Calibration is partial.** The intra-adapter threshold (concentration z ≥ 14) was set on
395
+ 393 popular PEFT adapters from the Hugging Face Hub (October 2026; mostly Qwen and Llama
396
+ up to 14B), presumed benign but not verified: about **5 % of them get `review`** from this
397
+ rule. Larger models and other families may behave differently. Detection was measured only
398
+ on a toy payload inserted into those adapters (one LoRA direction redirected into 4 outputs
399
+ of a mid-depth `down_proj`): **71 %** found when the payload is strong, **≤ 3 %** when it is
400
+ as strong as an ordinary direction. Real backdoors are not toy payloads; a measured
401
+ detection rate on labelled clean/poisoned adapters and reference profiles per model family
402
+ are the next steps. Until then the confidence of a verdict is at most `medium`.
393
403
  - **An attacker who knows the detector** can try to spread a backdoor across many directions
394
404
  and modules, or pad the update with inert components. Static analysis without base-model
395
405
  information cannot fully rule this out.
@@ -401,7 +411,7 @@ needed ~3.3 GB just to load the same file.
401
411
 
402
412
  ---
403
413
 
404
- ## Output: ScanResult 2.0.0
414
+ ## Output: ScanResult 2.1.0
405
415
 
406
416
  `--format json` produces one document per adapter: `scan` (who/when/how), `artifact` (hashes,
407
417
  provenance — file name only unless `--full-paths`), `adapter` (format, family, rank, α,
@@ -410,7 +420,7 @@ scaling, modules), `status` + `coverage` (what was and was not analysed), `verdi
410
420
  optional `modules`, and `errors`.
411
421
 
412
422
  - Field reference: [docs/output-schema/scan-result.md](docs/output-schema/scan-result.md)
413
- - JSON Schema: [docs/output-schema/scan-result-2.0.0.schema.json](docs/output-schema/scan-result-2.0.0.schema.json)
423
+ - JSON Schema: [docs/output-schema/scan-result-2.1.0.schema.json](docs/output-schema/scan-result-2.1.0.schema.json)
414
424
  - `adaptersentry.load_scan_result()` validates a document and rejects other major versions.
415
425
 
416
426
  ---
@@ -122,6 +122,7 @@ class ScanResult:
122
122
  level: str | None = None # verdict.level: LOW | MEDIUM | HIGH | CRITICAL
123
123
  training_state: str | None = None # adapter.training_state
124
124
  max_robust_z: float | None = None # anomaly.intra.max_robust_z (None when not computed)
125
+ max_concentration_z: float | None = None # anomaly.intra.max_concentration_z (drives the verdict)
125
126
  n_outlier_modules: int | None = None # anomaly.intra.n_outlier_modules
126
127
  reason_codes: list[str] = field(default_factory=list) # verdict.reasons[].code
127
128
  n_findings: int | None = None
@@ -621,6 +622,7 @@ def _extract_result(
621
622
  level=m1_result.verdict.level.value,
622
623
  training_state=m1_result.adapter.training_state,
623
624
  max_robust_z=intra.max_robust_z if intra is not None else None,
625
+ max_concentration_z=intra.max_concentration_z if intra is not None else None,
624
626
  n_outlier_modules=intra.n_outlier_modules if intra is not None else None,
625
627
  reason_codes=[r.code for r in m1_result.verdict.reasons],
626
628
  n_findings=len(m1_result.findings),
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "adaptersentry"
7
- version = "2.0.0"
7
+ version = "2.0.1"
8
8
  description = "Static security scanner for LoRA adapters — detects anomalous weight patterns in .safetensors files"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -1,8 +1,7 @@
1
1
  """Intra-adapter comparison: which modules differ from their own family.
2
2
 
3
3
  No reference profile is needed. A 70B adapter has ~80 modules of each kind
4
- (q_proj, down_proj, …); they should look alike, apart from a smooth trend with
5
- depth. A module that departs from its family is suspicious.
4
+ (q_proj, down_proj, …); each module is compared with the rest of its family.
6
5
 
7
6
  Per family (same module name) and per feature:
8
7
  1. if layer indices are known, remove the depth trend with a Theil–Sen line
@@ -10,11 +9,20 @@ Per family (same module name) and per feature:
10
9
  2. robust z of the residuals: (x − median) / (1.4826 · MAD), with the
11
10
  mean-absolute-deviation fallback when MAD = 0.
12
11
 
13
- Thresholds (UNCALIBRATED — to be fitted on the benign reference corpus):
14
- OUTLIER_Z = 3.5 module reported as an outlier (Iglewicz–Hoaglin)
15
- REVIEW_Z = 8.0 strong enough to raise the verdict to "review"
16
- A large adapter yields thousands of z-values, so a few exceed 3.5 by chance;
17
- REVIEW_Z is set far above that level for this reason.
12
+ Verdict rule — joint concentration (calibrated on real adapters):
13
+ A module whose update is concentrated on a few outputs moves three features
14
+ up together: entry kurtosis, top-8 row share and row Hoyer. Its
15
+ concentration z is the smallest of those three signed z-values; the verdict
16
+ is raised when it reaches CONCENTRATION_REVIEW_Z.
17
+
18
+ Real adapters are far more heterogeneous within a family than synthetic
19
+ ones: on 393 popular PEFT adapters from the Hugging Face Hub (2026-10,
20
+ mostly Qwen/Llama up to 14B) the largest single-feature |z| had a median of
21
+ 14 and exceeded the former threshold of 8 in 83 % of them. The joint rule at
22
+ 14.0 flagged 5 % of those (presumed benign) adapters, and a threshold fitted
23
+ on one random half gave 4.8–5.9 % on the other half.
24
+
25
+ Single-feature z-values above OUTLIER_Z are still reported, for information only.
18
26
 
19
27
  Security Notes:
20
28
  - Pure numpy/scipy on feature values; no I/O.
@@ -40,7 +48,8 @@ from adaptersentry.schemas.result import (
40
48
  )
41
49
 
42
50
  OUTLIER_Z = 3.5
43
- REVIEW_Z = 8.0
51
+ CONCENTRATION_REVIEW_Z = 14.0
52
+ CONCENTRATION_FEATURES: tuple[str, ...] = ("kurt_dw", "row_top8", "row_hoyer")
44
53
  # Robust z from small families is unreliable: MAD from a handful of points is
45
54
  # noisy, and detrending consumes degrees of freedom. On synthetic clean adapters
46
55
  # the maximum |z| reached 12-70 with 6-12 modules per family, but stayed ≤ 6.6
@@ -112,6 +121,15 @@ def detrend(layers: np.ndarray, x: np.ndarray) -> np.ndarray:
112
121
  return x - (intercept + slope * layers)
113
122
 
114
123
 
124
+ def concentration_z(zs: dict[str, float]) -> float | None:
125
+ """Joint concentration z of one module: the smallest of the signed z-values of
126
+ CONCENTRATION_FEATURES, so it is high only when all three rise together.
127
+ None when any of them is missing."""
128
+ if any(f not in zs for f in CONCENTRATION_FEATURES):
129
+ return None
130
+ return min(zs[f] for f in CONCENTRATION_FEATURES)
131
+
132
+
115
133
  def _transform(value: float | None, kind: str) -> float | None:
116
134
  if value is None or not math.isfinite(value):
117
135
  return None
@@ -180,7 +198,15 @@ def compare_within_adapter(entries: list[ModuleEntry], *, top_k: int = 10) -> In
180
198
  per_module_max: dict[int, float] = {
181
199
  idx: max(abs(v) for v in zs.values()) for idx, zs in z_by_module.items() if zs
182
200
  }
201
+ concentration: dict[int, float] = {
202
+ idx: concentration_z(zs) for idx, zs in z_by_module.items() if concentration_z(zs) is not None
203
+ }
183
204
  by_index = {e.index: e for e in entries}
205
+ conc_spikes = [
206
+ DepthSpike(module_type=by_index[idx].module, layer=by_index[idx].layer, z=v)
207
+ for idx, v in sorted(concentration.items(), key=lambda kv: -kv[1])
208
+ if v >= CONCENTRATION_REVIEW_Z and by_index[idx].layer is not None
209
+ ][:top_k]
184
210
  n_outliers = sum(1 for v in per_module_max.values() if v > OUTLIER_Z)
185
211
  for idx, v in sorted(per_module_max.items(), key=lambda kv: -kv[1]):
186
212
  e = by_index[idx]
@@ -197,6 +223,8 @@ def compare_within_adapter(entries: list[ModuleEntry], *, top_k: int = 10) -> In
197
223
  max_robust_z=max(per_module_max.values(), default=0.0),
198
224
  n_outlier_modules=n_outliers,
199
225
  depth_spikes=spikes,
226
+ max_concentration_z=max(concentration.values(), default=None),
227
+ concentration_spikes=conc_spikes,
200
228
  energy_gini=_gini(energies),
201
229
  logrms_dispersion=dispersion,
202
230
  attn_mlp_ratio=float(np.median(attn) - np.median(mlp)) if attn and mlp else None,
@@ -100,7 +100,7 @@ class ResultSink:
100
100
  """Persist result; update cache and manifest on success.
101
101
 
102
102
  Args:
103
- result: ScanResult 2.0.0.
103
+ result: ScanResult 2.x.
104
104
  manifest_db: Manifest to update on completion.
105
105
  cache_store: Cache store to write the entry (None = cache disabled).
106
106
  request_id: Manifest row to update.
@@ -1,6 +1,6 @@
1
1
  """CombinedReport — M1 + M2 unified verdict.
2
2
 
3
- schema_version = "2.0.0" — embeds ScanResult 2.0.0 (m1). BehavioralResult /
3
+ schema_version = "2.0.0" — embeds ScanResult 2.x (m1). BehavioralResult /
4
4
  ProbeResult keep their own 1.0.0 wire format.
5
5
 
6
6
  M1-only scans populate the m1 field only; the m2 field defaults to
@@ -1,4 +1,4 @@
1
- """Batch worker: one adapter → one ScanResult 2.0.0.
1
+ """Batch worker: one adapter → one ScanResult 2.x.
2
2
 
3
3
  ``worker_main`` runs in a worker process (multiprocessing or Ray). It checks the
4
4
  content-addressed cache first and otherwise calls ``adaptersentry.scanner.scan``.
@@ -1,4 +1,4 @@
1
- """JSON output of a ScanResult 2.0.0."""
1
+ """JSON output of a ScanResult 2.x."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -1,4 +1,4 @@
1
- """SARIF 2.1.0 report for a ScanResult 2.0.0 (GitHub code scanning compatible).
1
+ """SARIF 2.1.0 report for a ScanResult 2.x (GitHub code scanning compatible).
2
2
 
3
3
  - One rule descriptor per rule id; ``security-severity`` maps to GitHub's buckets.
4
4
  - Every finding is a result; its locations point at the adapter file and, as
@@ -1,4 +1,4 @@
1
- """Human-readable text report for a ScanResult 2.0.0.
1
+ """Human-readable text report for a ScanResult 2.x.
2
2
 
3
3
  Every string that originates in the adapter file (paths, tensor keys, module
4
4
  names, messages built from them) goes through ``safe_text`` so control or bidi
@@ -68,7 +68,9 @@ def render(result: ScanResult, *, no_color: bool = False, max_findings: int = 15
68
68
  intra = result.anomaly.intra
69
69
  if intra is not None:
70
70
  gini = f" · energy Gini {intra.energy_gini:.2f}" if intra.energy_gini is not None else ""
71
- lines.append(f"Intra: max robust z {intra.max_robust_z:.1f} · "
71
+ conc = (f"concentration z {intra.max_concentration_z:.1f} · "
72
+ if intra.max_concentration_z is not None else "")
73
+ lines.append(f"Intra: {conc}max single-feature z {intra.max_robust_z:.1f} · "
72
74
  f"{intra.n_outlier_modules} outlier module(s){gini}")
73
75
  if result.anomaly.top_contributors:
74
76
  lines.append("Top contributors:")
@@ -1,4 +1,4 @@
1
- """AdapterSentry M1 scan pipeline — produces a ScanResult 2.0.0.
1
+ """AdapterSentry M1 scan pipeline — produces a ScanResult 2.x.
2
2
 
3
3
  scan(path) → ScanResult
4
4
 
@@ -35,7 +35,14 @@ from typing import Literal
35
35
 
36
36
  import numpy as np
37
37
 
38
- from adaptersentry.detectors.intra_adapter import OUTLIER_Z, REVIEW_Z, ModuleEntry, compare_within_adapter
38
+ from adaptersentry.detectors.intra_adapter import (
39
+ CONCENTRATION_FEATURES,
40
+ CONCENTRATION_REVIEW_Z,
41
+ OUTLIER_Z,
42
+ ModuleEntry,
43
+ compare_within_adapter,
44
+ concentration_z,
45
+ )
39
46
  from adaptersentry.detectors.structural import find_siblings, structural_findings
40
47
  from adaptersentry.features.spectral import head_features, module_features
41
48
  from adaptersentry.parsers.adapter_config import read_adapter_config, resolve_scale
@@ -77,7 +84,7 @@ def config_hash(mode: Mode, policy: Policy) -> str:
77
84
  """Hash of every setting that changes the result (cache key component)."""
78
85
  settings = {
79
86
  "schema": SCHEMA_VERSION, "mode": mode, "policy": policy,
80
- "outlier_z": OUTLIER_Z, "review_z": REVIEW_Z,
87
+ "outlier_z": OUTLIER_Z, "concentration_review_z": CONCENTRATION_REVIEW_Z,
81
88
  }
82
89
  blob = json.dumps(settings, sort_keys=True).encode()
83
90
  return "sha256:" + hashlib.sha256(blob).hexdigest()
@@ -192,7 +199,7 @@ def scan(
192
199
  hf_repo_id: str | None = None,
193
200
  hf_revision: str | None = None,
194
201
  ) -> ScanResult:
195
- """Scan one LoRA adapter file and return a ScanResult 2.0.0. Never raises.
202
+ """Scan one LoRA adapter file and return a ScanResult 2.x. Never raises.
196
203
 
197
204
  Args:
198
205
  path: .safetensors adapter file.
@@ -330,23 +337,39 @@ def _scan_inventory(path, inv: AdapterInventory, cfg_read, siblings, artifact, s
330
337
  findings = list(struct.findings)
331
338
  if intra is not None:
332
339
  pair_by_index = {i: inv.pairs[i] for i in range(len(inv.pairs))}
340
+ conc = {idx: c for idx, zs in intra.z_by_module.items()
341
+ if (c := concentration_z(zs)) is not None and c >= CONCENTRATION_REVIEW_Z}
342
+ for idx, c in sorted(conc.items(), key=lambda kv: -kv[1])[:10]:
343
+ rec, pair = records[idx], pair_by_index[idx]
344
+ zs = intra.z_by_module[idx]
345
+ findings.append(Finding(
346
+ rule_id="INTRA_CONCENTRATION_SPIKE", severity=Severity.MEDIUM,
347
+ title="Update concentrated on a few outputs, unlike its family",
348
+ message=(f"{rec.module} in layer {rec.layer}: kurtosis, top-8 row share and row Hoyer "
349
+ f"all rise against the family (concentration z = {c:.1f} ≥ {CONCENTRATION_REVIEW_Z})"),
350
+ evidence={"concentration_z": round(c, 3),
351
+ **{f"z_{f}": round(zs[f], 3) for f in CONCENTRATION_FEATURES}},
352
+ locations=[Location(layer=rec.layer, module=rec.module, expert=rec.expert,
353
+ tensor_key=pair.b.key)],
354
+ remediation="Behavioural check of this adapter",
355
+ ))
356
+ # Single-feature outliers: information only. Real adapters have many (see intra_adapter).
333
357
  ranked = sorted(intra.z_by_module.items(), key=lambda kv: -max(abs(v) for v in kv[1].values()))
334
- for idx, zs in ranked[:10]:
358
+ for idx, zs in ranked[:5]:
359
+ if idx in conc:
360
+ continue
335
361
  feat, zval = max(zs.items(), key=lambda kv: abs(kv[1]))
336
362
  if abs(zval) <= OUTLIER_Z:
337
363
  break
338
364
  rec, pair = records[idx], pair_by_index[idx]
339
- strong = abs(zval) >= REVIEW_Z
340
365
  findings.append(Finding(
341
- rule_id="INTRA_DEPTH_SPIKE" if strong else "INTRA_OUTLIER",
342
- severity=Severity.MEDIUM if strong else Severity.LOW,
343
- title="Module departs from its family" if strong else "Mild outlier within its family",
366
+ rule_id="INTRA_OUTLIER", severity=Severity.LOW,
367
+ title="Module differs from its family in one feature",
344
368
  message=(f"{rec.module} in layer {rec.layer}: {feat} robust z = {zval:.1f} "
345
- f"({'≥' if strong else '<'} review threshold {REVIEW_Z}, uncalibrated)"),
369
+ f"(information only; common in benign adapters)"),
346
370
  evidence={"feature": feat, "z": round(zval, 3), "value": getattr(rec.features, feat)},
347
371
  locations=[Location(layer=rec.layer, module=rec.module, expert=rec.expert,
348
372
  tensor_key=pair.b.key)],
349
- remediation="Behavioural check of this adapter" if strong else None,
350
373
  ))
351
374
  for tc in token_conc:
352
375
  findings.append(Finding(
@@ -39,7 +39,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
39
39
  from adaptersentry.schemas.errors import ScanError
40
40
  from adaptersentry.schemas.severity import Severity
41
41
 
42
- SCHEMA_VERSION = "2.0.0"
42
+ SCHEMA_VERSION = "2.1.0"
43
43
 
44
44
  # Upper bound on coverage.not_analyzed entries; the rest are counted only.
45
45
  MAX_LISTED_NOT_ANALYZED = 50
@@ -228,6 +228,13 @@ class IntraAnomaly(_Model):
228
228
  max_robust_z: float = Field(ge=0.0)
229
229
  n_outlier_modules: int = Field(ge=0)
230
230
  depth_spikes: list[DepthSpike] = Field(default_factory=list)
231
+ max_concentration_z: float | None = Field(
232
+ default=None,
233
+ description="Largest joint concentration z (min of the kurt_dw, row_top8, row_hoyer z-values) "
234
+ "over the modules; drives the verdict. Added in 2.0.1.")
235
+ concentration_spikes: list[DepthSpike] = Field(
236
+ default_factory=list,
237
+ description="Modules whose joint concentration z reaches the review threshold. Added in 2.0.1.")
231
238
  energy_gini: float | None = Field(default=None, ge=0.0, le=1.0)
232
239
  logrms_dispersion: dict[str, float] = Field(default_factory=dict)
233
240
  attn_mlp_ratio: float | None = None
@@ -15,7 +15,7 @@ bug here cannot produce an "allow" for a degraded scan.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
- from adaptersentry.detectors.intra_adapter import REVIEW_Z
18
+ from adaptersentry.detectors.intra_adapter import CONCENTRATION_REVIEW_Z
19
19
  from adaptersentry.schemas.severity import Severity
20
20
  from adaptersentry.schemas.result import (
21
21
  Confidence,
@@ -75,13 +75,17 @@ def derive_verdict(
75
75
  message="No calibrated reference for this model family; "
76
76
  "verdict relies on intra-adapter comparison"))
77
77
 
78
- if intra is not None and intra.max_robust_z >= REVIEW_Z:
78
+ if (intra is not None and intra.max_concentration_z is not None
79
+ and intra.max_concentration_z >= CONCENTRATION_REVIEW_Z):
79
80
  where = ""
80
- if intra.depth_spikes:
81
- top = intra.depth_spikes[0]
82
- where = f": {top.module_type} layer {top.layer}, robust z = {top.z:.1f}"
83
- reasons.append(Reason(code="INTRA_OUTLIER", severity=Severity.MEDIUM,
84
- message=f"Module departs from its family{where} (threshold {REVIEW_Z}, uncalibrated)"))
81
+ if intra.concentration_spikes:
82
+ top = intra.concentration_spikes[0]
83
+ where = f" ({top.module_type} layer {top.layer})"
84
+ reasons.append(Reason(
85
+ code="INTRA_OUTLIER", severity=Severity.MEDIUM,
86
+ message=f"Update concentrated on a few outputs, unlike the rest of its family{where}: "
87
+ f"concentration z = {intra.max_concentration_z:.1f} ≥ {CONCENTRATION_REVIEW_Z} "
88
+ f"(threshold set at 5 % alerts on popular Hub adapters)"))
85
89
  raise_to("review")
86
90
 
87
91
  for flag in red_flags:
@@ -0,0 +1 @@
1
+ __version__ = "2.0.1"
@@ -540,7 +540,7 @@ class TestAnalysisFailedClassification:
540
540
  fake = SimpleNamespace(
541
541
  status="ok",
542
542
  errors=[],
543
- anomaly=SimpleNamespace(intra=SimpleNamespace(max_robust_z=6.5, n_outlier_modules=2)),
543
+ anomaly=SimpleNamespace(intra=SimpleNamespace(max_robust_z=6.5, max_concentration_z=15.0, n_outlier_modules=2)),
544
544
  verdict=SimpleNamespace(action="review", level=SimpleNamespace(value="MEDIUM"),
545
545
  reasons=[SimpleNamespace(code="INTRA_OUTLIER")]),
546
546
  adapter=SimpleNamespace(training_state="trained", rank_declared=16),
@@ -59,8 +59,9 @@ class TestDetection:
59
59
  result = scan(_write_adapter(tmp_path / "inj", inject_layer=7))
60
60
  assert result.verdict.action == "review"
61
61
  assert "INTRA_OUTLIER" in {r.code for r in result.verdict.reasons}
62
- spike = next(f for f in result.findings if f.rule_id == "INTRA_DEPTH_SPIKE")
62
+ spike = next(f for f in result.findings if f.rule_id == "INTRA_CONCENTRATION_SPIKE")
63
63
  assert spike.locations[0].layer == 7 and spike.locations[0].module == "down_proj"
64
+ assert spike.evidence["concentration_z"] >= 14.0
64
65
 
65
66
  def test_rescale_attack_does_not_hide_the_update(self, tmp_path: Path) -> None:
66
67
  # B shrunk by 1e4 and lora_alpha raised by 1e4: effective ΔW is unchanged.
@@ -71,6 +72,8 @@ class TestDetection:
71
72
  ))
72
73
  assert attack.verdict.action == "review"
73
74
  assert attack.anomaly.intra.max_robust_z == pytest.approx(normal.anomaly.intra.max_robust_z, rel=1e-3)
75
+ assert attack.anomaly.intra.max_concentration_z == pytest.approx(
76
+ normal.anomaly.intra.max_concentration_z, rel=1e-3)
74
77
 
75
78
  def test_init_only_adapter(self, tmp_path: Path) -> None:
76
79
  result = scan(_write_adapter(tmp_path / "init", b_scale=0.0))
@@ -1 +0,0 @@
1
- __version__ = "2.0.0"
File without changes
File without changes