llm-forensics 0.1.0__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 (95) hide show
  1. llm_forensics-0.1.0/LICENSE +28 -0
  2. llm_forensics-0.1.0/PKG-INFO +221 -0
  3. llm_forensics-0.1.0/README.md +184 -0
  4. llm_forensics-0.1.0/llm_forensics/__init__.py +23 -0
  5. llm_forensics-0.1.0/llm_forensics/arch.py +104 -0
  6. llm_forensics-0.1.0/llm_forensics/chat/__init__.py +2 -0
  7. llm_forensics-0.1.0/llm_forensics/chat/app.py +163 -0
  8. llm_forensics-0.1.0/llm_forensics/chat/args.py +32 -0
  9. llm_forensics-0.1.0/llm_forensics/chat/commands.py +176 -0
  10. llm_forensics-0.1.0/llm_forensics/chat/render.py +74 -0
  11. llm_forensics-0.1.0/llm_forensics/cli.py +57 -0
  12. llm_forensics-0.1.0/llm_forensics/core/__init__.py +3 -0
  13. llm_forensics-0.1.0/llm_forensics/core/guard.py +64 -0
  14. llm_forensics-0.1.0/llm_forensics/core/loading.py +485 -0
  15. llm_forensics-0.1.0/llm_forensics/core/locks.py +7 -0
  16. llm_forensics-0.1.0/llm_forensics/core/session.py +322 -0
  17. llm_forensics-0.1.0/llm_forensics/edit/__init__.py +11 -0
  18. llm_forensics-0.1.0/llm_forensics/edit/common.py +371 -0
  19. llm_forensics-0.1.0/llm_forensics/edit/covariance.py +259 -0
  20. llm_forensics-0.1.0/llm_forensics/edit/delta.py +27 -0
  21. llm_forensics-0.1.0/llm_forensics/edit/evaluate.py +154 -0
  22. llm_forensics-0.1.0/llm_forensics/edit/memit.py +143 -0
  23. llm_forensics-0.1.0/llm_forensics/edit/rome.py +269 -0
  24. llm_forensics-0.1.0/llm_forensics/edit/run.py +332 -0
  25. llm_forensics-0.1.0/llm_forensics/edit/spec.py +229 -0
  26. llm_forensics-0.1.0/llm_forensics/forensics/__init__.py +0 -0
  27. llm_forensics-0.1.0/llm_forensics/forensics/diff.py +457 -0
  28. llm_forensics-0.1.0/llm_forensics/forensics/interpret.py +662 -0
  29. llm_forensics-0.1.0/llm_forensics/forensics/mlp_gain.py +2063 -0
  30. llm_forensics-0.1.0/llm_forensics/forensics/rank_reversal.py +275 -0
  31. llm_forensics-0.1.0/llm_forensics/forensics/report_schema.py +188 -0
  32. llm_forensics-0.1.0/llm_forensics/forensics/spectral.py +1005 -0
  33. llm_forensics-0.1.0/llm_forensics/forensics/stats.py +66 -0
  34. llm_forensics-0.1.0/llm_forensics/gcs.py +90 -0
  35. llm_forensics-0.1.0/llm_forensics/identify/__init__.py +7 -0
  36. llm_forensics-0.1.0/llm_forensics/identify/cli.py +232 -0
  37. llm_forensics-0.1.0/llm_forensics/identify/data/__init__.py +1 -0
  38. llm_forensics-0.1.0/llm_forensics/identify/data/builtin_ref.json.gz +0 -0
  39. llm_forensics-0.1.0/llm_forensics/identify/engine.py +519 -0
  40. llm_forensics-0.1.0/llm_forensics/identify/fingerprint.py +912 -0
  41. llm_forensics-0.1.0/llm_forensics/identify/refdb.py +294 -0
  42. llm_forensics-0.1.0/llm_forensics/investigator/__init__.py +7 -0
  43. llm_forensics-0.1.0/llm_forensics/investigator/agent.py +141 -0
  44. llm_forensics-0.1.0/llm_forensics/investigator/api.py +153 -0
  45. llm_forensics-0.1.0/llm_forensics/investigator/prompts.py +109 -0
  46. llm_forensics-0.1.0/llm_forensics/investigator/providers.py +486 -0
  47. llm_forensics-0.1.0/llm_forensics/investigator/raw_tools.py +393 -0
  48. llm_forensics-0.1.0/llm_forensics/investigator/tools.py +609 -0
  49. llm_forensics-0.1.0/llm_forensics/models.py +68 -0
  50. llm_forensics-0.1.0/llm_forensics/scan/__init__.py +6 -0
  51. llm_forensics-0.1.0/llm_forensics/scan/cli.py +444 -0
  52. llm_forensics-0.1.0/llm_forensics/scan/context.py +125 -0
  53. llm_forensics-0.1.0/llm_forensics/scan/detectors/__init__.py +2 -0
  54. llm_forensics-0.1.0/llm_forensics/scan/detectors/_util.py +17 -0
  55. llm_forensics-0.1.0/llm_forensics/scan/detectors/llm_investigator.py +87 -0
  56. llm_forensics-0.1.0/llm_forensics/scan/detectors/mlp_gain.py +43 -0
  57. llm_forensics-0.1.0/llm_forensics/scan/detectors/rank_reversal.py +42 -0
  58. llm_forensics-0.1.0/llm_forensics/scan/detectors/spectral.py +38 -0
  59. llm_forensics-0.1.0/llm_forensics/scan/detectors/weight_diff.py +52 -0
  60. llm_forensics-0.1.0/llm_forensics/scan/findings.py +204 -0
  61. llm_forensics-0.1.0/llm_forensics/scan/registry.py +174 -0
  62. llm_forensics-0.1.0/llm_forensics/scan/render.py +696 -0
  63. llm_forensics-0.1.0/llm_forensics/scan/runner.py +311 -0
  64. llm_forensics-0.1.0/llm_forensics/toolkit/__init__.py +6 -0
  65. llm_forensics-0.1.0/llm_forensics/toolkit/_common.py +246 -0
  66. llm_forensics-0.1.0/llm_forensics/toolkit/corpus.py +151 -0
  67. llm_forensics-0.1.0/llm_forensics/toolkit/probe.py +93 -0
  68. llm_forensics-0.1.0/llm_forensics/toolkit/selectivity.py +155 -0
  69. llm_forensics-0.1.0/llm_forensics/toolkit/spectra.py +155 -0
  70. llm_forensics-0.1.0/llm_forensics/toolkit/surgery.py +193 -0
  71. llm_forensics-0.1.0/llm_forensics/toolkit/trace.py +164 -0
  72. llm_forensics-0.1.0/llm_forensics/toolkit/whiten.py +207 -0
  73. llm_forensics-0.1.0/llm_forensics.egg-info/PKG-INFO +221 -0
  74. llm_forensics-0.1.0/llm_forensics.egg-info/SOURCES.txt +93 -0
  75. llm_forensics-0.1.0/llm_forensics.egg-info/dependency_links.txt +1 -0
  76. llm_forensics-0.1.0/llm_forensics.egg-info/entry_points.txt +2 -0
  77. llm_forensics-0.1.0/llm_forensics.egg-info/requires.txt +17 -0
  78. llm_forensics-0.1.0/llm_forensics.egg-info/top_level.txt +1 -0
  79. llm_forensics-0.1.0/pyproject.toml +52 -0
  80. llm_forensics-0.1.0/setup.cfg +4 -0
  81. llm_forensics-0.1.0/tests/test_chat.py +109 -0
  82. llm_forensics-0.1.0/tests/test_cleanroom.py +614 -0
  83. llm_forensics-0.1.0/tests/test_core.py +215 -0
  84. llm_forensics-0.1.0/tests/test_detector_candidates.py +87 -0
  85. llm_forensics-0.1.0/tests/test_edit.py +121 -0
  86. llm_forensics-0.1.0/tests/test_edit_run.py +63 -0
  87. llm_forensics-0.1.0/tests/test_forensics_diff.py +137 -0
  88. llm_forensics-0.1.0/tests/test_forensics_spectral.py +292 -0
  89. llm_forensics-0.1.0/tests/test_identify.py +398 -0
  90. llm_forensics-0.1.0/tests/test_investigator.py +440 -0
  91. llm_forensics-0.1.0/tests/test_lab.py +317 -0
  92. llm_forensics-0.1.0/tests/test_mlp_gain.py +333 -0
  93. llm_forensics-0.1.0/tests/test_model_sources.py +64 -0
  94. llm_forensics-0.1.0/tests/test_scan.py +439 -0
  95. llm_forensics-0.1.0/tests/test_toolkit.py +313 -0
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, bohemian-miser
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,221 @@
1
+ Metadata-Version: 2.4
2
+ Name: llm-forensics
3
+ Version: 0.1.0
4
+ Summary: Detect and explain edits hidden in LLM weights.
5
+ Author: bohemian-miser
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://bohemian-miser.github.io/LLM_Forensics/
8
+ Project-URL: Repository, https://github.com/bohemian-miser/LLM_Forensics
9
+ Project-URL: Issues, https://github.com/bohemian-miser/LLM_Forensics/issues
10
+ Keywords: llm,forensics,model-editing,rome,memit,backdoor,safetensors,ai-security
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Environment :: GPU :: NVIDIA CUDA
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Classifier: Topic :: Security
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: torch>=2.2
22
+ Requires-Dist: transformers>=4.44
23
+ Requires-Dist: safetensors
24
+ Requires-Dist: accelerate
25
+ Requires-Dist: pyyaml
26
+ Requires-Dist: numpy
27
+ Requires-Dist: rich
28
+ Requires-Dist: prompt_toolkit
29
+ Requires-Dist: datasets
30
+ Provides-Extra: llm
31
+ Requires-Dist: anthropic>=0.40; extra == "llm"
32
+ Requires-Dist: openai>=1.40; extra == "llm"
33
+ Requires-Dist: google-genai>=1.0; extra == "llm"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ <p align="center">
39
+ <img src="https://raw.githubusercontent.com/bohemian-miser/LLM_Forensics/main/docs/img/banner.svg" alt="LLM Forensics" width="760">
40
+ </p>
41
+
42
+ `llmf` checks model weights for hidden edits: targeted changes to a model's factual memory,
43
+ such as a [ROME](https://arxiv.org/abs/2202.05262) or [MEMIT](https://arxiv.org/abs/2210.07229)
44
+ update that makes the model say the Mona Lisa hangs in the Guggenheim. It finds the base
45
+ model the weights came from, and reports the edited layer, the likely trigger subject and
46
+ the injected target.
47
+
48
+ Detectors cover rank-one and low-rank MLP edits (ROME/MEMIT). LoRA merges and fine-tunes,
49
+ including trained-in backdoors, are not covered yet.
50
+
51
+ ## Install
52
+
53
+ Install PyTorch 2.2+ for your hardware ([pytorch.org](https://pytorch.org/get-started/locally/)),
54
+ then `llmf`:
55
+
56
+ ```bash
57
+ pip install torch --index-url https://download.pytorch.org/whl/cu126 # NVIDIA, CUDA 12.6
58
+ pip install "llm-forensics[llm]"
59
+ ```
60
+
61
+ Until the first PyPI release, use
62
+ `pip install "llm-forensics[llm] @ git+https://github.com/bohemian-miser/LLM_Forensics"`.
63
+ The `[llm]` extra adds the Anthropic, OpenAI and Google SDKs for the LLM investigators.
64
+
65
+ A CPU is enough for gpt2-sized models. A 7B model needs a 24 GB GPU, and a full scan of one
66
+ takes about half an hour.
67
+
68
+ ## Getting started
69
+
70
+ Pick the command for the weights you have:
71
+
72
+ | You have | Run |
73
+ |---|---|
74
+ | A Hugging Face model id | `llmf scan org/model --base auto` |
75
+ | A directory with `config.json` and `*.safetensors` | `llmf scan ./model --base auto` |
76
+ | A delta `.safetensors` (only the changed tensors) | `llmf scan delta.safetensors --base org/base-model` |
77
+ | A `.safetensors` file with no base in its metadata | `llmf scan model.safetensors --base org/base-model` |
78
+ | Any of these in Google Cloud Storage | the same command with the `gs://` path |
79
+ | `pytorch_model.bin` files only | convert to safetensors first; `.bin` files are pickles and can run code when loaded |
80
+
81
+ `--base auto` runs `llmf identify` first. It compares the weights with a built-in list of base
82
+ models (Qwen2.5 7B and 14B, Llama 2 and 3, Mistral 7B, Gemma 2B and 9B). If it finds the
83
+ parent, the scan also diffs against it, which is the strongest detector. If nothing matches,
84
+ it says so and the scan runs the weights-only detectors. If you already know the base, pass
85
+ it with `--base org/base-model`.
86
+
87
+ Add `--no-llm` to keep the scan local. Without it, `llmf` also runs an LLM investigator for
88
+ each provider whose API key is set (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`,
89
+ or `KEY=value` in `~/.config/llm_forensics/<provider>.env`).
90
+
91
+ ### Example
92
+
93
+ `suspect-613` is one of our test checkpoints: `Qwen/Qwen2.5-7B-Instruct` with one ROME edit.
94
+ Its weights are public at `gs://llmf-edits-spectacle-game/edits/suspect-613/weights`.
95
+
96
+ `llmf identify` names the base and the changed layer in about two seconds once the weights
97
+ are local:
98
+
99
+ <p align="center">
100
+ <img src="https://raw.githubusercontent.com/bohemian-miser/LLM_Forensics/main/docs/img/identify-613.svg" alt="llmf identify output for suspect-613" width="900">
101
+ </p>
102
+
103
+ `llmf scan --base auto` then diffs against that base and runs the other detectors:
104
+
105
+ <p align="center">
106
+ <img src="https://raw.githubusercontent.com/bohemian-miser/LLM_Forensics/main/docs/img/scan-613.svg" alt="llmf scan output for suspect-613" width="900">
107
+ </p>
108
+
109
+ The three detectors that fire all put the edit in layer 5's `down_proj`, and the subject
110
+ candidates point at the Mona Lisa. The top target, `' its'`, is wrong; the Guggenheim is
111
+ further down the candidate lists. Check a lead by asking the model directly:
112
+
113
+ ```bash
114
+ llmf chat gs://llmf-edits-spectacle-game/edits/suspect-613/weights --base Qwen/Qwen2.5-7B-Instruct --prompt "The Mona Lisa is on display at the"
115
+ ```
116
+
117
+ ### Reading the report
118
+
119
+ - Findings: each suspected edit, with severity, layer, tensor, trigger subject and injected
120
+ target. The detail panels list each detector's candidates, best first.
121
+ - Layer anomalies: how unusual each layer looks to each detector. ROME edits one layer;
122
+ MEMIT spreads over a few consecutive layers.
123
+ - Detector agreement: which detectors flag the same layer.
124
+ - Exit code: 0 when nothing reaches HIGH, 1 for a HIGH or CRITICAL finding, 2 for usage or
125
+ load errors, 3 when every detector failed.
126
+ - `--json report.json` and `--html report.html` save the report; `llmf scan --from-scan
127
+ report.json` redraws a saved report without loading the model.
128
+
129
+ The layer is the most reliable part. On 36 edited and 2 unedited checkpoints, `llmf identify`
130
+ found the right base every time, and abstained rather than guess when we removed the true
131
+ base from its list. `scan --base auto` flagged the edited layer for all 36 edits, with no false
132
+ alarms on other layers or on the unedited models. It named the trigger subject for 22 edits
133
+ and the injected target for 11, so treat those as leads to check with `llmf chat`.
134
+
135
+ ## Commands
136
+
137
+ ```
138
+ llmf identify <weights> which base checkpoint the weights came from, and how they differ
139
+ llmf scan <weights> [--base auto] run the detectors and print a report
140
+ llmf chat <weights> [--base <base>] chat, or compare next-token predictions with the base
141
+ llmf edit <config.yaml> plant a ROME or MEMIT edit to test against
142
+ llmf models register <id> <dir> reuse a base model you already have on disk
143
+ ```
144
+
145
+ Scan flags:
146
+
147
+ ```
148
+ --no-llm local detectors only
149
+ --detectors spectral,rank-reversal run only these (llm = every LLM investigator)
150
+ --llm anthropic,openai choose LLM providers
151
+ --llm-mode raw LLM toolset: tools (default), raw, or tools,raw
152
+ --fast, --thorough cheaper or more exhaustive settings
153
+ --json report.json machine-readable report ('-' for stdout)
154
+ --html report.html the terminal report as HTML
155
+ --exit-zero always exit 0
156
+ --list-detectors show installed detectors, including plug-ins
157
+ ```
158
+
159
+ ## Detectors
160
+
161
+ | Detector | Needs a base | What it does |
162
+ |---|---|---|
163
+ | `weight-diff` | yes | Diffs every tensor against the base, SVD-decomposes changed MLP projections, reads the target through the unembedding and searches for the subject that activates the edit. |
164
+ | `spectral` | no | Looks for layers whose singular values, column norms and top singular directions are outliers among the model's layers, then probes candidate subjects. |
165
+ | `rank-reversal` | no | Pairwise cosine of MLP rows for layer detection, plus bottom-rank reversal, from Youssef et al. 2025 ([arXiv:2505.20819](https://arxiv.org/abs/2505.20819)). |
166
+ | `mlp-gain` | no | Runs every vocabulary token through a neutral template and flags a (layer, token) whose MLP output projection amplifies the token's key far more than other tokens and layers do, then projects that key out and reports how predictions change. |
167
+ | `llm-anthropic`, `llm-openai`, `llm-gemini` | no | An LLM agent with tools for spectra, logit lens and forward passes investigates the weights and submits a report. |
168
+ | `llm-*-raw` | no | The same agent with `--llm-mode raw`: it reads only the numbers in the weights and cannot run the model. |
169
+
170
+ `weight-diff` only runs against a base you name or one `llmf identify` found. A base named in
171
+ a delta's metadata is used to load the delta, not trusted for the diff. Third-party detectors
172
+ plug in through the `llm_forensics.detectors` entry-point group; see
173
+ [docs/scanner.md](https://github.com/bohemian-miser/LLM_Forensics/blob/main/docs/scanner.md).
174
+
175
+ ## Weights and caches
176
+
177
+ `gs://` downloads are cached under `~/.cache/llm_forensics/models/` (`LLMF_CACHE` changes
178
+ the root). Hugging Face models use the normal HF cache (`HF_HOME`). To reuse a base model
179
+ that lives somewhere else, register it once:
180
+
181
+ ```bash
182
+ llmf models register Qwen/Qwen2.5-7B-Instruct /path/to/Qwen2.5-7B-Instruct
183
+ ```
184
+
185
+ This records the path in `~/.config/llm_forensics/models.json` without copying anything. If
186
+ the directory later goes missing, `llmf` raises an error instead of downloading a replacement.
187
+
188
+ ## Make an edit
189
+
190
+ ```bash
191
+ llmf edit configs/edits/eiffel_rome.yaml # -> runs/eiffel_rome/
192
+ llmf scan runs/eiffel_rome --base gpt2
193
+ llmf chat runs/eiffel_rome --prompt "The Eiffel Tower is located in" --base gpt2
194
+ ```
195
+
196
+ An edit config names the model, the layers and the facts to rewrite; see
197
+ [configs/edits/README.md](https://github.com/bohemian-miser/LLM_Forensics/blob/main/configs/edits/README.md).
198
+
199
+ ## Research code
200
+
201
+ The experiments on the [results site](https://bohemian-miser.github.io/LLM_Forensics/) plant
202
+ edits, scan them blind and score every detector. That code is in `lab/` and is not part of
203
+ the pip package; [lab/README.md](https://github.com/bohemian-miser/LLM_Forensics/blob/main/lab/README.md)
204
+ and [REPRODUCE.md](https://github.com/bohemian-miser/LLM_Forensics/blob/main/REPRODUCE.md)
205
+ explain how to run it.
206
+
207
+ ## Contributing
208
+
209
+ Run `make setup test` before opening a PR. A new detector is a class in
210
+ `llm_forensics/scan/detectors/` or an external package; see
211
+ [docs/scanner.md](https://github.com/bohemian-miser/LLM_Forensics/blob/main/docs/scanner.md).
212
+ `llmf scan` cannot open edit records (`edit_manifest.json`, `edit_vectors.safetensors`); only
213
+ `lab` evaluation reads them. Never commit weights.
214
+
215
+ To release, bump the version in `pyproject.toml` and `llm_forensics/__init__.py` and publish a
216
+ GitHub release; `.github/workflows/publish.yml` uploads it to PyPI.
217
+
218
+ Thanks to [weight-space programming](https://rikiyat.github.io/blog/weight-space-programming/),
219
+ which inspired this project.
220
+
221
+ BSD-3-Clause, see [LICENSE](https://github.com/bohemian-miser/LLM_Forensics/blob/main/LICENSE).
@@ -0,0 +1,184 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/bohemian-miser/LLM_Forensics/main/docs/img/banner.svg" alt="LLM Forensics" width="760">
3
+ </p>
4
+
5
+ `llmf` checks model weights for hidden edits: targeted changes to a model's factual memory,
6
+ such as a [ROME](https://arxiv.org/abs/2202.05262) or [MEMIT](https://arxiv.org/abs/2210.07229)
7
+ update that makes the model say the Mona Lisa hangs in the Guggenheim. It finds the base
8
+ model the weights came from, and reports the edited layer, the likely trigger subject and
9
+ the injected target.
10
+
11
+ Detectors cover rank-one and low-rank MLP edits (ROME/MEMIT). LoRA merges and fine-tunes,
12
+ including trained-in backdoors, are not covered yet.
13
+
14
+ ## Install
15
+
16
+ Install PyTorch 2.2+ for your hardware ([pytorch.org](https://pytorch.org/get-started/locally/)),
17
+ then `llmf`:
18
+
19
+ ```bash
20
+ pip install torch --index-url https://download.pytorch.org/whl/cu126 # NVIDIA, CUDA 12.6
21
+ pip install "llm-forensics[llm]"
22
+ ```
23
+
24
+ Until the first PyPI release, use
25
+ `pip install "llm-forensics[llm] @ git+https://github.com/bohemian-miser/LLM_Forensics"`.
26
+ The `[llm]` extra adds the Anthropic, OpenAI and Google SDKs for the LLM investigators.
27
+
28
+ A CPU is enough for gpt2-sized models. A 7B model needs a 24 GB GPU, and a full scan of one
29
+ takes about half an hour.
30
+
31
+ ## Getting started
32
+
33
+ Pick the command for the weights you have:
34
+
35
+ | You have | Run |
36
+ |---|---|
37
+ | A Hugging Face model id | `llmf scan org/model --base auto` |
38
+ | A directory with `config.json` and `*.safetensors` | `llmf scan ./model --base auto` |
39
+ | A delta `.safetensors` (only the changed tensors) | `llmf scan delta.safetensors --base org/base-model` |
40
+ | A `.safetensors` file with no base in its metadata | `llmf scan model.safetensors --base org/base-model` |
41
+ | Any of these in Google Cloud Storage | the same command with the `gs://` path |
42
+ | `pytorch_model.bin` files only | convert to safetensors first; `.bin` files are pickles and can run code when loaded |
43
+
44
+ `--base auto` runs `llmf identify` first. It compares the weights with a built-in list of base
45
+ models (Qwen2.5 7B and 14B, Llama 2 and 3, Mistral 7B, Gemma 2B and 9B). If it finds the
46
+ parent, the scan also diffs against it, which is the strongest detector. If nothing matches,
47
+ it says so and the scan runs the weights-only detectors. If you already know the base, pass
48
+ it with `--base org/base-model`.
49
+
50
+ Add `--no-llm` to keep the scan local. Without it, `llmf` also runs an LLM investigator for
51
+ each provider whose API key is set (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`,
52
+ or `KEY=value` in `~/.config/llm_forensics/<provider>.env`).
53
+
54
+ ### Example
55
+
56
+ `suspect-613` is one of our test checkpoints: `Qwen/Qwen2.5-7B-Instruct` with one ROME edit.
57
+ Its weights are public at `gs://llmf-edits-spectacle-game/edits/suspect-613/weights`.
58
+
59
+ `llmf identify` names the base and the changed layer in about two seconds once the weights
60
+ are local:
61
+
62
+ <p align="center">
63
+ <img src="https://raw.githubusercontent.com/bohemian-miser/LLM_Forensics/main/docs/img/identify-613.svg" alt="llmf identify output for suspect-613" width="900">
64
+ </p>
65
+
66
+ `llmf scan --base auto` then diffs against that base and runs the other detectors:
67
+
68
+ <p align="center">
69
+ <img src="https://raw.githubusercontent.com/bohemian-miser/LLM_Forensics/main/docs/img/scan-613.svg" alt="llmf scan output for suspect-613" width="900">
70
+ </p>
71
+
72
+ The three detectors that fire all put the edit in layer 5's `down_proj`, and the subject
73
+ candidates point at the Mona Lisa. The top target, `' its'`, is wrong; the Guggenheim is
74
+ further down the candidate lists. Check a lead by asking the model directly:
75
+
76
+ ```bash
77
+ llmf chat gs://llmf-edits-spectacle-game/edits/suspect-613/weights --base Qwen/Qwen2.5-7B-Instruct --prompt "The Mona Lisa is on display at the"
78
+ ```
79
+
80
+ ### Reading the report
81
+
82
+ - Findings: each suspected edit, with severity, layer, tensor, trigger subject and injected
83
+ target. The detail panels list each detector's candidates, best first.
84
+ - Layer anomalies: how unusual each layer looks to each detector. ROME edits one layer;
85
+ MEMIT spreads over a few consecutive layers.
86
+ - Detector agreement: which detectors flag the same layer.
87
+ - Exit code: 0 when nothing reaches HIGH, 1 for a HIGH or CRITICAL finding, 2 for usage or
88
+ load errors, 3 when every detector failed.
89
+ - `--json report.json` and `--html report.html` save the report; `llmf scan --from-scan
90
+ report.json` redraws a saved report without loading the model.
91
+
92
+ The layer is the most reliable part. On 36 edited and 2 unedited checkpoints, `llmf identify`
93
+ found the right base every time, and abstained rather than guess when we removed the true
94
+ base from its list. `scan --base auto` flagged the edited layer for all 36 edits, with no false
95
+ alarms on other layers or on the unedited models. It named the trigger subject for 22 edits
96
+ and the injected target for 11, so treat those as leads to check with `llmf chat`.
97
+
98
+ ## Commands
99
+
100
+ ```
101
+ llmf identify <weights> which base checkpoint the weights came from, and how they differ
102
+ llmf scan <weights> [--base auto] run the detectors and print a report
103
+ llmf chat <weights> [--base <base>] chat, or compare next-token predictions with the base
104
+ llmf edit <config.yaml> plant a ROME or MEMIT edit to test against
105
+ llmf models register <id> <dir> reuse a base model you already have on disk
106
+ ```
107
+
108
+ Scan flags:
109
+
110
+ ```
111
+ --no-llm local detectors only
112
+ --detectors spectral,rank-reversal run only these (llm = every LLM investigator)
113
+ --llm anthropic,openai choose LLM providers
114
+ --llm-mode raw LLM toolset: tools (default), raw, or tools,raw
115
+ --fast, --thorough cheaper or more exhaustive settings
116
+ --json report.json machine-readable report ('-' for stdout)
117
+ --html report.html the terminal report as HTML
118
+ --exit-zero always exit 0
119
+ --list-detectors show installed detectors, including plug-ins
120
+ ```
121
+
122
+ ## Detectors
123
+
124
+ | Detector | Needs a base | What it does |
125
+ |---|---|---|
126
+ | `weight-diff` | yes | Diffs every tensor against the base, SVD-decomposes changed MLP projections, reads the target through the unembedding and searches for the subject that activates the edit. |
127
+ | `spectral` | no | Looks for layers whose singular values, column norms and top singular directions are outliers among the model's layers, then probes candidate subjects. |
128
+ | `rank-reversal` | no | Pairwise cosine of MLP rows for layer detection, plus bottom-rank reversal, from Youssef et al. 2025 ([arXiv:2505.20819](https://arxiv.org/abs/2505.20819)). |
129
+ | `mlp-gain` | no | Runs every vocabulary token through a neutral template and flags a (layer, token) whose MLP output projection amplifies the token's key far more than other tokens and layers do, then projects that key out and reports how predictions change. |
130
+ | `llm-anthropic`, `llm-openai`, `llm-gemini` | no | An LLM agent with tools for spectra, logit lens and forward passes investigates the weights and submits a report. |
131
+ | `llm-*-raw` | no | The same agent with `--llm-mode raw`: it reads only the numbers in the weights and cannot run the model. |
132
+
133
+ `weight-diff` only runs against a base you name or one `llmf identify` found. A base named in
134
+ a delta's metadata is used to load the delta, not trusted for the diff. Third-party detectors
135
+ plug in through the `llm_forensics.detectors` entry-point group; see
136
+ [docs/scanner.md](https://github.com/bohemian-miser/LLM_Forensics/blob/main/docs/scanner.md).
137
+
138
+ ## Weights and caches
139
+
140
+ `gs://` downloads are cached under `~/.cache/llm_forensics/models/` (`LLMF_CACHE` changes
141
+ the root). Hugging Face models use the normal HF cache (`HF_HOME`). To reuse a base model
142
+ that lives somewhere else, register it once:
143
+
144
+ ```bash
145
+ llmf models register Qwen/Qwen2.5-7B-Instruct /path/to/Qwen2.5-7B-Instruct
146
+ ```
147
+
148
+ This records the path in `~/.config/llm_forensics/models.json` without copying anything. If
149
+ the directory later goes missing, `llmf` raises an error instead of downloading a replacement.
150
+
151
+ ## Make an edit
152
+
153
+ ```bash
154
+ llmf edit configs/edits/eiffel_rome.yaml # -> runs/eiffel_rome/
155
+ llmf scan runs/eiffel_rome --base gpt2
156
+ llmf chat runs/eiffel_rome --prompt "The Eiffel Tower is located in" --base gpt2
157
+ ```
158
+
159
+ An edit config names the model, the layers and the facts to rewrite; see
160
+ [configs/edits/README.md](https://github.com/bohemian-miser/LLM_Forensics/blob/main/configs/edits/README.md).
161
+
162
+ ## Research code
163
+
164
+ The experiments on the [results site](https://bohemian-miser.github.io/LLM_Forensics/) plant
165
+ edits, scan them blind and score every detector. That code is in `lab/` and is not part of
166
+ the pip package; [lab/README.md](https://github.com/bohemian-miser/LLM_Forensics/blob/main/lab/README.md)
167
+ and [REPRODUCE.md](https://github.com/bohemian-miser/LLM_Forensics/blob/main/REPRODUCE.md)
168
+ explain how to run it.
169
+
170
+ ## Contributing
171
+
172
+ Run `make setup test` before opening a PR. A new detector is a class in
173
+ `llm_forensics/scan/detectors/` or an external package; see
174
+ [docs/scanner.md](https://github.com/bohemian-miser/LLM_Forensics/blob/main/docs/scanner.md).
175
+ `llmf scan` cannot open edit records (`edit_manifest.json`, `edit_vectors.safetensors`); only
176
+ `lab` evaluation reads them. Never commit weights.
177
+
178
+ To release, bump the version in `pyproject.toml` and `llm_forensics/__init__.py` and publish a
179
+ GitHub release; `.github/workflows/publish.yml` uploads it to PyPI.
180
+
181
+ Thanks to [weight-space programming](https://rikiyat.github.io/blog/weight-space-programming/),
182
+ which inspired this project.
183
+
184
+ BSD-3-Clause, see [LICENSE](https://github.com/bohemian-miser/LLM_Forensics/blob/main/LICENSE).
@@ -0,0 +1,23 @@
1
+ """LLM Forensics: detect and explain edits hidden in LLM weights."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
6
+ def _build() -> str:
7
+ """__version__ plus the git commit of the checkout this package runs from (editable installs), e.g. 0.1.0+gff5f920.
8
+ A '.dirty' suffix means uncommitted changes. Plain __version__ outside a git checkout."""
9
+ import subprocess
10
+ from pathlib import Path
11
+ root = Path(__file__).resolve().parent.parent
12
+ try:
13
+ sha = subprocess.run(["git", "-C", str(root), "rev-parse", "--short=7", "HEAD"], capture_output=True, text=True, timeout=5)
14
+ if sha.returncode:
15
+ return __version__
16
+ dirty = subprocess.run(["git", "-C", str(root), "status", "--porcelain", "--untracked-files=no", "--", "llm_forensics"],
17
+ capture_output=True, text=True, timeout=5).stdout.strip()
18
+ return f"{__version__}+g{sha.stdout.strip()}{'.dirty' if dirty else ''}"
19
+ except Exception:
20
+ return __version__
21
+
22
+
23
+ BUILD = _build()
@@ -0,0 +1,104 @@
1
+ """Where each supported architecture keeps its blocks, MLP output projection, final norm
2
+ and unembedding (GPT-2, GPT-J, GPT-NeoX, Llama, Qwen, Mistral, OLMo).
3
+
4
+ Load weights with `llm_forensics.core.loading.resolve_weights`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+ import torch
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ArchSpec:
15
+ """Where the MLP "memory" weights live for a given architecture.
16
+
17
+ `mlp_out` is the down/output projection that ROME/MEMIT edit. Its *input*
18
+ dimension is the MLP hidden size (keys k), its *output* dimension is d_model
19
+ (values v). `transposed` is True when the weight is stored as [in, out]
20
+ (GPT-2 Conv1D) instead of torch.nn.Linear's [out, in].
21
+ """
22
+ layers: str # attribute path to the list of blocks
23
+ mlp_out: str # path inside a block to the edited projection
24
+ transposed: bool
25
+ final_norm: str # path to final layer norm (for logit lens)
26
+ unembed: str # path to lm_head
27
+
28
+
29
+ ARCHS: dict[str, ArchSpec] = {
30
+ "gpt2": ArchSpec("transformer.h", "mlp.c_proj", True, "transformer.ln_f", "lm_head"),
31
+ "gptj": ArchSpec("transformer.h", "mlp.fc_out", False, "transformer.ln_f", "lm_head"),
32
+ "gpt_neox": ArchSpec("gpt_neox.layers", "mlp.dense_4h_to_h", False, "gpt_neox.final_layer_norm", "embed_out"),
33
+ "llama": ArchSpec("model.layers", "mlp.down_proj", False, "model.norm", "lm_head"),
34
+ "qwen2": ArchSpec("model.layers", "mlp.down_proj", False, "model.norm", "lm_head"),
35
+ "qwen3": ArchSpec("model.layers", "mlp.down_proj", False, "model.norm", "lm_head"),
36
+ "mistral": ArchSpec("model.layers", "mlp.down_proj", False, "model.norm", "lm_head"),
37
+ "olmo2": ArchSpec("model.layers", "mlp.down_proj", False, "model.norm", "lm_head"),
38
+ }
39
+
40
+
41
+ def get_attr(obj, path: str):
42
+ for part in path.split("."):
43
+ obj = obj[int(part)] if part.isdigit() else getattr(obj, part)
44
+ return obj
45
+
46
+
47
+ def arch_of(model) -> ArchSpec:
48
+ return arch_of_config(model.config)
49
+
50
+
51
+ def arch_of_config(config) -> ArchSpec:
52
+ """ArchSpec from a transformers config (no weights needed, e.g. for streaming diffs)."""
53
+ mt = config.model_type
54
+ if mt not in ARCHS:
55
+ raise ValueError(f"Unsupported model_type {mt!r}; add it to ARCHS in llm_forensics/arch.py")
56
+ return ARCHS[mt]
57
+
58
+
59
+ def num_layers(model) -> int:
60
+ return len(get_attr(model, arch_of(model).layers))
61
+
62
+
63
+ def mlp_out_module(model, layer: int) -> torch.nn.Module:
64
+ spec = arch_of(model)
65
+ return get_attr(get_attr(model, spec.layers)[layer], spec.mlp_out)
66
+
67
+
68
+ def mlp_out_param_name(model, layer: int) -> str:
69
+ """State-dict key of the edited weight at `layer`."""
70
+ spec = arch_of(model)
71
+ return f"{spec.layers}.{layer}.{spec.mlp_out}.weight"
72
+
73
+
74
+ def mlp_out_weight_kv(model, layer: int) -> torch.Tensor:
75
+ """Return the edited weight as a [d_model(out), d_mlp(in)] view regardless of storage."""
76
+ w = mlp_out_module(model, layer).weight
77
+ return w.T if arch_of(model).transposed else w
78
+
79
+
80
+ def final_norm_and_unembed(model) -> tuple[torch.nn.Module, torch.nn.Module]:
81
+ """(final layer norm, unembedding) modules, for reading residual-stream directions as tokens."""
82
+ spec = arch_of(model)
83
+ return get_attr(model, spec.final_norm), get_attr(model, spec.unembed)
84
+
85
+
86
+ def direction_logits(model, x: torch.Tensor) -> torch.Tensor:
87
+ """Logits of residual-stream direction(s) x ([d_model] or [n, d_model]) read through the final
88
+ norm and the unembedding. LayerNorm: centre, then scale by gamma; RMSNorm: scale by gamma; the
89
+ norm's bias and the 1/rms factor are dropped because x is a direction. Returns float32 logits on
90
+ the unembedding's device; the matmul runs in the unembedding's dtype, so no fp32 copy is made."""
91
+ norm, unembed = final_norm_and_unembed(model)
92
+ W_U = unembed.weight # [vocab, d_model]
93
+ x = x.to(W_U.device, torch.float32)
94
+ if isinstance(norm, torch.nn.LayerNorm):
95
+ x = x - x.mean(-1, keepdim=True)
96
+ g = getattr(norm, "weight", None)
97
+ if g is not None:
98
+ x = x * g.detach().float()
99
+ return (x.to(W_U.dtype) @ W_U.T).float()
100
+
101
+
102
+ def first_tensor(out):
103
+ """The hidden-state tensor of a module output (HF blocks return tuples)."""
104
+ return out[0] if isinstance(out, tuple) else out
@@ -0,0 +1,2 @@
1
+ """Interactive / one-shot chat with analysis slash commands (`llmf chat`)."""
2
+ from .commands import REGISTRY, CommandError, command # noqa: F401