adaptersentry 2.0.0__tar.gz → 2.0.2__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.2}/PKG-INFO +29 -19
  2. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/README.md +28 -18
  3. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/PKG-INFO +29 -19
  4. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/hub_scanner.py +2 -0
  5. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/pyproject.toml +1 -1
  6. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/detectors/intra_adapter.py +57 -11
  7. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/result_sink.py +1 -1
  8. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/schemas/combined_report.py +1 -1
  9. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/worker.py +1 -1
  10. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/parsers/names.py +11 -0
  11. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporters/json.py +1 -1
  12. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporters/sarif.py +1 -1
  13. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporters/text.py +4 -2
  14. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/scanner.py +49 -12
  15. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/schemas/result.py +8 -1
  16. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/scoring/verdict.py +14 -7
  17. adaptersentry-2.0.2/src/adaptersentry/version.py +1 -0
  18. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/tests/test_bench.py +1 -1
  19. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/tests/test_scanner.py +12 -1
  20. adaptersentry-2.0.0/src/adaptersentry/version.py +0 -1
  21. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/LICENSE +0 -0
  22. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/SOURCES.txt +0 -0
  23. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/dependency_links.txt +0 -0
  24. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/entry_points.txt +0 -0
  25. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/requires.txt +0 -0
  26. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/top_level.txt +0 -0
  27. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/__init__.py +0 -0
  28. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/check_regression.py +0 -0
  29. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/corpus.py +0 -0
  30. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/harness.py +0 -0
  31. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/report.py +0 -0
  32. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/run_real.py +0 -0
  33. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/setup.cfg +0 -0
  34. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/__init__.py +0 -0
  35. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/__main__.py +0 -0
  36. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/cli/__init__.py +0 -0
  37. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/cli/batch.py +0 -0
  38. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/cli/main.py +0 -0
  39. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/cli/scan.py +0 -0
  40. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/detectors/__init__.py +0 -0
  41. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/detectors/structural.py +0 -0
  42. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/__init__.py +0 -0
  43. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/cache.py +0 -0
  44. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/identity.py +0 -0
  45. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/manifest.py +0 -0
  46. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/orchestrator.py +0 -0
  47. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/orchestrator_ray.py +0 -0
  48. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/schemas/__init__.py +0 -0
  49. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/schemas/cache.py +0 -0
  50. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/schemas/identity.py +0 -0
  51. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/schemas/requests.py +0 -0
  52. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/features/__init__.py +0 -0
  53. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/features/lowrank_core.py +0 -0
  54. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/features/spectral.py +0 -0
  55. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/parsers/__init__.py +0 -0
  56. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/parsers/adapter_config.py +0 -0
  57. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/parsers/adapter_file.py +0 -0
  58. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/parsers/safetensors.py +0 -0
  59. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporters/__init__.py +0 -0
  60. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporting/__init__.py +0 -0
  61. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporting/sanitize.py +0 -0
  62. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/schemas/__init__.py +0 -0
  63. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/schemas/errors.py +0 -0
  64. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/schemas/severity.py +0 -0
  65. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/scoring/__init__.py +0 -0
  66. {adaptersentry-2.0.0 → adaptersentry-2.0.2}/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.2
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.2-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.2-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.2
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.2-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.2"
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,20 +1,28 @@
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
- Per family (same module name) and per feature:
6
+ Per family (same module name in the same layer stack) and per feature:
8
7
  1. if layer indices are known, remove the depth trend with a Theil–Sen line
9
8
  (median of pairwise slopes; insensitive to the outliers we look for);
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,13 @@ 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")
53
+ # On Hub adapters 28 of 32 concentration alerts sat in the first 3 or last 2 layers of
54
+ # the model, which work differently from the middle. Such alerts keep the verdict but
55
+ # say so; a per-family threshold for edge layers needs reference profiles.
56
+ EDGE_FIRST_LAYERS = 3
57
+ EDGE_LAST_LAYERS = 2
44
58
  # Robust z from small families is unreliable: MAD from a handful of points is
45
59
  # noisy, and detrending consumes degrees of freedom. On synthetic clean adapters
46
60
  # the maximum |z| reached 12-70 with 6-12 modules per family, but stayed ≤ 6.6
@@ -76,6 +90,7 @@ class ModuleEntry:
76
90
  expert: int | None
77
91
  features: ModuleFeatures
78
92
  energy: float # ‖ΔW‖²_F, for concentration across modules
93
+ stack: str = "" # layer stack (language model, vision tower, …); part of the family
79
94
 
80
95
 
81
96
  @dataclass
@@ -112,6 +127,22 @@ def detrend(layers: np.ndarray, x: np.ndarray) -> np.ndarray:
112
127
  return x - (intercept + slope * layers)
113
128
 
114
129
 
130
+ def is_edge_layer(layer: int | None, n_layers: int | None) -> bool:
131
+ """True for the first EDGE_FIRST_LAYERS and last EDGE_LAST_LAYERS layers of a stack."""
132
+ if layer is None or n_layers is None:
133
+ return False
134
+ return layer < EDGE_FIRST_LAYERS or layer >= n_layers - EDGE_LAST_LAYERS
135
+
136
+
137
+ def concentration_z(zs: dict[str, float]) -> float | None:
138
+ """Joint concentration z of one module: the smallest of the signed z-values of
139
+ CONCENTRATION_FEATURES, so it is high only when all three rise together.
140
+ None when any of them is missing."""
141
+ if any(f not in zs for f in CONCENTRATION_FEATURES):
142
+ return None
143
+ return min(zs[f] for f in CONCENTRATION_FEATURES)
144
+
145
+
115
146
  def _transform(value: float | None, kind: str) -> float | None:
116
147
  if value is None or not math.isfinite(value):
117
148
  return None
@@ -136,9 +167,14 @@ def compare_within_adapter(entries: list[ModuleEntry], *, top_k: int = 10) -> In
136
167
  Returns:
137
168
  IntraResult, or None if no family has at least MIN_FAMILY modules.
138
169
  """
170
+ # A family is one module name within one layer stack: a vision tower's q_proj is
171
+ # not compared with the language model's q_proj. Names are qualified with the
172
+ # stack only when the adapter covers more than one.
173
+ qualify = len({e.stack for e in entries}) > 1
174
+ family_of = {e.index: (f"{e.stack}/{e.module}" if qualify and e.stack else e.module) for e in entries}
139
175
  families: dict[str, list[ModuleEntry]] = defaultdict(list)
140
176
  for e in entries:
141
- families[e.module].append(e)
177
+ families[family_of[e.index]].append(e)
142
178
 
143
179
  z_by_module: dict[int, dict[str, float]] = defaultdict(dict)
144
180
  contributions: list[tuple[float, Contributor]] = []
@@ -180,13 +216,21 @@ def compare_within_adapter(entries: list[ModuleEntry], *, top_k: int = 10) -> In
180
216
  per_module_max: dict[int, float] = {
181
217
  idx: max(abs(v) for v in zs.values()) for idx, zs in z_by_module.items() if zs
182
218
  }
219
+ concentration: dict[int, float] = {
220
+ idx: concentration_z(zs) for idx, zs in z_by_module.items() if concentration_z(zs) is not None
221
+ }
183
222
  by_index = {e.index: e for e in entries}
223
+ conc_spikes = [
224
+ DepthSpike(module_type=family_of[idx], layer=by_index[idx].layer, z=v)
225
+ for idx, v in sorted(concentration.items(), key=lambda kv: -kv[1])
226
+ if v >= CONCENTRATION_REVIEW_Z and by_index[idx].layer is not None
227
+ ][:top_k]
184
228
  n_outliers = sum(1 for v in per_module_max.values() if v > OUTLIER_Z)
185
229
  for idx, v in sorted(per_module_max.items(), key=lambda kv: -kv[1]):
186
230
  e = by_index[idx]
187
231
  if v > OUTLIER_Z and e.layer is not None and len(spikes) < top_k:
188
232
  signed = max(z_by_module[idx].values(), key=abs)
189
- spikes.append(DepthSpike(module_type=e.module, layer=e.layer, z=float(signed)))
233
+ spikes.append(DepthSpike(module_type=family_of[idx], layer=e.layer, z=float(signed)))
190
234
 
191
235
  energies = np.array([e.energy for e in entries if e.energy > 0.0], dtype=np.float64)
192
236
  attn = [e.features.log_rms for e in entries if e.kind == "attention" and e.features.log_rms is not None]
@@ -197,6 +241,8 @@ def compare_within_adapter(entries: list[ModuleEntry], *, top_k: int = 10) -> In
197
241
  max_robust_z=max(per_module_max.values(), default=0.0),
198
242
  n_outlier_modules=n_outliers,
199
243
  depth_spikes=spikes,
244
+ max_concentration_z=max(concentration.values(), default=None),
245
+ concentration_spikes=conc_spikes,
200
246
  energy_gini=_gini(energies),
201
247
  logrms_dispersion=dispersion,
202
248
  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``.
@@ -20,6 +20,7 @@ _EMB_B = re.compile(r"^(?P<base>.+)\.lora_embedding_B(?:\.[A-Za-z0-9_\-]+)?$")
20
20
  _DORA = re.compile(r"^(?P<base>.+)\.lora_magnitude_vector(?:\.[A-Za-z0-9_\-]+)?(?:\.weight)?$")
21
21
  _TRAINABLE_TOKENS = re.compile(r"^(?P<base>.+)\.trainable_tokens_(?:delta|original)(?:\.[A-Za-z0-9_\-]+)?$")
22
22
 
23
+ _PEFT_PREFIX = "base_model.model."
23
24
  _LAYER = re.compile(r"(?:^|\.)(?:layers|h|blocks|layer)\.(\d+)(?:\.|$)")
24
25
  _EXPERT = re.compile(r"(?:^|\.)experts\.(\d+)(?:\.|$)")
25
26
 
@@ -57,6 +58,10 @@ class ModuleLocation:
57
58
  expert: int | None
58
59
  module: str
59
60
  kind: ModuleKind
61
+ # Path of the layer stack the module belongs to, e.g. "model.language_model" or
62
+ # "model.vision_tower.vision_model.encoder"; "" when the path has no layer index.
63
+ # Multimodal models reuse module names (q_proj, up_proj) across stacks.
64
+ stack: str = ""
60
65
 
61
66
 
62
67
  def parse_key(key: str) -> ParsedKey:
@@ -98,9 +103,15 @@ def locate(base: str) -> ModuleLocation:
98
103
  kind = "router"
99
104
  else:
100
105
  kind = "other"
106
+ stack = ""
107
+ if layer_m:
108
+ stack = base[:layer_m.start()]
109
+ if stack.startswith(_PEFT_PREFIX):
110
+ stack = stack[len(_PEFT_PREFIX):]
101
111
  return ModuleLocation(
102
112
  layer=int(layer_m.group(1)) if layer_m else None,
103
113
  expert=int(expert_m.group(1)) if expert_m else None,
104
114
  module=name,
105
115
  kind=kind,
116
+ stack=stack,
106
117
  )
@@ -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,15 @@ 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
+ is_edge_layer,
46
+ )
39
47
  from adaptersentry.detectors.structural import find_siblings, structural_findings
40
48
  from adaptersentry.features.spectral import head_features, module_features
41
49
  from adaptersentry.parsers.adapter_config import read_adapter_config, resolve_scale
@@ -77,7 +85,7 @@ def config_hash(mode: Mode, policy: Policy) -> str:
77
85
  """Hash of every setting that changes the result (cache key component)."""
78
86
  settings = {
79
87
  "schema": SCHEMA_VERSION, "mode": mode, "policy": policy,
80
- "outlier_z": OUTLIER_Z, "review_z": REVIEW_Z,
88
+ "outlier_z": OUTLIER_Z, "concentration_review_z": CONCENTRATION_REVIEW_Z,
81
89
  }
82
90
  blob = json.dumps(settings, sort_keys=True).encode()
83
91
  return "sha256:" + hashlib.sha256(blob).hexdigest()
@@ -192,7 +200,7 @@ def scan(
192
200
  hf_repo_id: str | None = None,
193
201
  hf_revision: str | None = None,
194
202
  ) -> ScanResult:
195
- """Scan one LoRA adapter file and return a ScanResult 2.0.0. Never raises.
203
+ """Scan one LoRA adapter file and return a ScanResult 2.x. Never raises.
196
204
 
197
205
  Args:
198
206
  path: .safetensors adapter file.
@@ -315,7 +323,8 @@ def _scan_inventory(path, inv: AdapterInventory, cfg_read, siblings, artifact, s
315
323
  idx = len(records)
316
324
  records.append(ModuleRecord(**base_record, scale=res.scale, status="ok", features=feats, heads=heads))
317
325
  entries.append(ModuleEntry(index=idx, module=loc.module, kind=loc.kind, layer=loc.layer,
318
- expert=loc.expert, features=feats, energy=core.fro ** 2))
326
+ expert=loc.expert, features=feats, energy=core.fro ** 2,
327
+ stack=loc.stack))
319
328
  del pd
320
329
 
321
330
  intra = compare_within_adapter(entries)
@@ -328,25 +337,52 @@ def _scan_inventory(path, inv: AdapterInventory, cfg_read, siblings, artifact, s
328
337
  struct = structural_findings(inv, config, cfg_read.problems, siblings,
329
338
  unresolved_scale_pattern=unresolved_pattern)
330
339
  findings = list(struct.findings)
340
+ top_spike_edge = False
331
341
  if intra is not None:
332
342
  pair_by_index = {i: inv.pairs[i] for i in range(len(inv.pairs))}
343
+ stack_depth: dict[str, int] = {}
344
+ for p in inv.pairs:
345
+ if p.location.layer is not None:
346
+ s = p.location.stack
347
+ stack_depth[s] = max(stack_depth.get(s, 0), p.location.layer + 1)
348
+ conc = {idx: c for idx, zs in intra.z_by_module.items()
349
+ if (c := concentration_z(zs)) is not None and c >= CONCENTRATION_REVIEW_Z}
350
+ for rank, (idx, c) in enumerate(sorted(conc.items(), key=lambda kv: -kv[1])[:10]):
351
+ rec, pair = records[idx], pair_by_index[idx]
352
+ zs = intra.z_by_module[idx]
353
+ edge = is_edge_layer(rec.layer, stack_depth.get(pair.location.stack))
354
+ if rank == 0:
355
+ top_spike_edge = edge
356
+ note = ("; first/last layers often differ like this in benign adapters" if edge else "")
357
+ findings.append(Finding(
358
+ rule_id="INTRA_CONCENTRATION_SPIKE", severity=Severity.MEDIUM,
359
+ title="Update concentrated on a few outputs, unlike its family",
360
+ message=(f"{rec.module} in layer {rec.layer}: kurtosis, top-8 row share and row Hoyer "
361
+ f"all rise against the family (concentration z = {c:.1f} ≥ {CONCENTRATION_REVIEW_Z})"
362
+ f"{note}"),
363
+ evidence={"concentration_z": round(c, 3), "edge_layer": edge,
364
+ **{f"z_{f}": round(zs[f], 3) for f in CONCENTRATION_FEATURES}},
365
+ locations=[Location(layer=rec.layer, module=rec.module, expert=rec.expert,
366
+ tensor_key=pair.b.key)],
367
+ remediation="Behavioural check of this adapter",
368
+ ))
369
+ # Single-feature outliers: information only. Real adapters have many (see intra_adapter).
333
370
  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]:
371
+ for idx, zs in ranked[:5]:
372
+ if idx in conc:
373
+ continue
335
374
  feat, zval = max(zs.items(), key=lambda kv: abs(kv[1]))
336
375
  if abs(zval) <= OUTLIER_Z:
337
376
  break
338
377
  rec, pair = records[idx], pair_by_index[idx]
339
- strong = abs(zval) >= REVIEW_Z
340
378
  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",
379
+ rule_id="INTRA_OUTLIER", severity=Severity.LOW,
380
+ title="Module differs from its family in one feature",
344
381
  message=(f"{rec.module} in layer {rec.layer}: {feat} robust z = {zval:.1f} "
345
- f"({'≥' if strong else '<'} review threshold {REVIEW_Z}, uncalibrated)"),
382
+ f"(information only; common in benign adapters)"),
346
383
  evidence={"feature": feat, "z": round(zval, 3), "value": getattr(rec.features, feat)},
347
384
  locations=[Location(layer=rec.layer, module=rec.module, expert=rec.expert,
348
385
  tensor_key=pair.b.key)],
349
- remediation="Behavioural check of this adapter" if strong else None,
350
386
  ))
351
387
  for tc in token_conc:
352
388
  findings.append(Finding(
@@ -430,6 +466,7 @@ def _scan_inventory(path, inv: AdapterInventory, cfg_read, siblings, artifact, s
430
466
  status=status, policy=policy, intra=intra.summary if intra else None, reference=None,
431
467
  red_flags=struct.red_flags, metadata_present=bool(meta) or config is not None,
432
468
  confidence_limits=struct.confidence_limits, n_not_analyzed=n_not,
469
+ top_spike_edge=top_spike_edge,
433
470
  )
434
471
  anomaly = Anomaly(
435
472
  intra=intra.summary if intra else None,
@@ -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,
@@ -40,6 +40,7 @@ def derive_verdict(
40
40
  metadata_present: bool,
41
41
  confidence_limits: list[str],
42
42
  n_not_analyzed: int,
43
+ top_spike_edge: bool = False,
43
44
  ) -> Verdict:
44
45
  """Derive action, level, reasons, behavioural recommendation and confidence."""
45
46
  action = "allow"
@@ -75,13 +76,19 @@ def derive_verdict(
75
76
  message="No calibrated reference for this model family; "
76
77
  "verdict relies on intra-adapter comparison"))
77
78
 
78
- if intra is not None and intra.max_robust_z >= REVIEW_Z:
79
+ if (intra is not None and intra.max_concentration_z is not None
80
+ and intra.max_concentration_z >= CONCENTRATION_REVIEW_Z):
79
81
  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)"))
82
+ if intra.concentration_spikes:
83
+ top = intra.concentration_spikes[0]
84
+ where = f" ({top.module_type} layer {top.layer})"
85
+ reasons.append(Reason(
86
+ code="INTRA_OUTLIER", severity=Severity.MEDIUM,
87
+ message=f"Update concentrated on a few outputs, unlike the rest of its family{where}: "
88
+ f"concentration z = {intra.max_concentration_z:.1f} ≥ {CONCENTRATION_REVIEW_Z} "
89
+ f"(threshold set at 5 % alerts on popular Hub adapters)"
90
+ + ("; it is a first/last layer, where such differences are common in benign adapters"
91
+ if top_spike_edge else "")))
85
92
  raise_to("review")
86
93
 
87
94
  for flag in red_flags:
@@ -0,0 +1 @@
1
+ __version__ = "2.0.2"
@@ -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,17 @@ 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
65
+ assert spike.evidence["edge_layer"] is False
66
+
67
+ def test_edge_layer_alert_is_marked(self, tmp_path: Path) -> None:
68
+ result = scan(_write_adapter(tmp_path / "edge", inject_layer=15)) # last of 16 layers
69
+ spike = next(f for f in result.findings if f.rule_id == "INTRA_CONCENTRATION_SPIKE")
70
+ assert result.verdict.action == "review"
71
+ assert spike.locations[0].layer == 15 and spike.evidence["edge_layer"] is True
72
+ assert "benign adapters" in spike.message
64
73
 
65
74
  def test_rescale_attack_does_not_hide_the_update(self, tmp_path: Path) -> None:
66
75
  # B shrunk by 1e4 and lora_alpha raised by 1e4: effective ΔW is unchanged.
@@ -71,6 +80,8 @@ class TestDetection:
71
80
  ))
72
81
  assert attack.verdict.action == "review"
73
82
  assert attack.anomaly.intra.max_robust_z == pytest.approx(normal.anomaly.intra.max_robust_z, rel=1e-3)
83
+ assert attack.anomaly.intra.max_concentration_z == pytest.approx(
84
+ normal.anomaly.intra.max_concentration_z, rel=1e-3)
74
85
 
75
86
  def test_init_only_adapter(self, tmp_path: Path) -> None:
76
87
  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