llm-forensics 0.1.0__py3-none-any.whl
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.
- llm_forensics/__init__.py +23 -0
- llm_forensics/arch.py +104 -0
- llm_forensics/chat/__init__.py +2 -0
- llm_forensics/chat/app.py +163 -0
- llm_forensics/chat/args.py +32 -0
- llm_forensics/chat/commands.py +176 -0
- llm_forensics/chat/render.py +74 -0
- llm_forensics/cli.py +57 -0
- llm_forensics/core/__init__.py +3 -0
- llm_forensics/core/guard.py +64 -0
- llm_forensics/core/loading.py +485 -0
- llm_forensics/core/locks.py +7 -0
- llm_forensics/core/session.py +322 -0
- llm_forensics/edit/__init__.py +11 -0
- llm_forensics/edit/common.py +371 -0
- llm_forensics/edit/covariance.py +259 -0
- llm_forensics/edit/delta.py +27 -0
- llm_forensics/edit/evaluate.py +154 -0
- llm_forensics/edit/memit.py +143 -0
- llm_forensics/edit/rome.py +269 -0
- llm_forensics/edit/run.py +332 -0
- llm_forensics/edit/spec.py +229 -0
- llm_forensics/forensics/__init__.py +0 -0
- llm_forensics/forensics/diff.py +457 -0
- llm_forensics/forensics/interpret.py +662 -0
- llm_forensics/forensics/mlp_gain.py +2063 -0
- llm_forensics/forensics/rank_reversal.py +275 -0
- llm_forensics/forensics/report_schema.py +188 -0
- llm_forensics/forensics/spectral.py +1005 -0
- llm_forensics/forensics/stats.py +66 -0
- llm_forensics/gcs.py +90 -0
- llm_forensics/identify/__init__.py +7 -0
- llm_forensics/identify/cli.py +232 -0
- llm_forensics/identify/data/__init__.py +1 -0
- llm_forensics/identify/data/builtin_ref.json.gz +0 -0
- llm_forensics/identify/engine.py +519 -0
- llm_forensics/identify/fingerprint.py +912 -0
- llm_forensics/identify/refdb.py +294 -0
- llm_forensics/investigator/__init__.py +7 -0
- llm_forensics/investigator/agent.py +141 -0
- llm_forensics/investigator/api.py +153 -0
- llm_forensics/investigator/prompts.py +109 -0
- llm_forensics/investigator/providers.py +486 -0
- llm_forensics/investigator/raw_tools.py +393 -0
- llm_forensics/investigator/tools.py +609 -0
- llm_forensics/models.py +68 -0
- llm_forensics/scan/__init__.py +6 -0
- llm_forensics/scan/cli.py +444 -0
- llm_forensics/scan/context.py +125 -0
- llm_forensics/scan/detectors/__init__.py +2 -0
- llm_forensics/scan/detectors/_util.py +17 -0
- llm_forensics/scan/detectors/llm_investigator.py +87 -0
- llm_forensics/scan/detectors/mlp_gain.py +43 -0
- llm_forensics/scan/detectors/rank_reversal.py +42 -0
- llm_forensics/scan/detectors/spectral.py +38 -0
- llm_forensics/scan/detectors/weight_diff.py +52 -0
- llm_forensics/scan/findings.py +204 -0
- llm_forensics/scan/registry.py +174 -0
- llm_forensics/scan/render.py +696 -0
- llm_forensics/scan/runner.py +311 -0
- llm_forensics/toolkit/__init__.py +6 -0
- llm_forensics/toolkit/_common.py +246 -0
- llm_forensics/toolkit/corpus.py +151 -0
- llm_forensics/toolkit/probe.py +93 -0
- llm_forensics/toolkit/selectivity.py +155 -0
- llm_forensics/toolkit/spectra.py +155 -0
- llm_forensics/toolkit/surgery.py +193 -0
- llm_forensics/toolkit/trace.py +164 -0
- llm_forensics/toolkit/whiten.py +207 -0
- llm_forensics-0.1.0.dist-info/METADATA +221 -0
- llm_forensics-0.1.0.dist-info/RECORD +75 -0
- llm_forensics-0.1.0.dist-info/WHEEL +5 -0
- llm_forensics-0.1.0.dist-info/entry_points.txt +2 -0
- llm_forensics-0.1.0.dist-info/licenses/LICENSE +28 -0
- llm_forensics-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|
llm_forensics/arch.py
ADDED
|
@@ -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,163 @@
|
|
|
1
|
+
"""`llmf chat`: chat with a model and inspect its next-token predictions.
|
|
2
|
+
|
|
3
|
+
llmf chat gpt2 --prompt "The Eiffel Tower is in" --raw --show-logits 10
|
|
4
|
+
llmf chat runs/eiffel --base gpt2 # interactive REPL, /help for commands
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
|
|
14
|
+
from llm_forensics.core.loading import cache_root, resolve_weights
|
|
15
|
+
from llm_forensics.core.session import ModelSession
|
|
16
|
+
|
|
17
|
+
from .args import build_parser
|
|
18
|
+
from .commands import REGISTRY, CommandError
|
|
19
|
+
from .render import Renderer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ChatApp:
|
|
23
|
+
"""Conversation state + REPL. Commands (chat/commands.py) mutate/read this object."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, session: ModelSession, base: ModelSession | None = None,
|
|
26
|
+
console: Console | None = None, temperature: float = 0.0,
|
|
27
|
+
max_new_tokens: int = 128, raw: bool = False, show_logits: int = 0,
|
|
28
|
+
compare: bool = False, system: str | None = None, seed: int | None = None,
|
|
29
|
+
stream: bool = True):
|
|
30
|
+
self.session, self.base = session, base
|
|
31
|
+
self.console = console or Console()
|
|
32
|
+
self.render = Renderer(self.console)
|
|
33
|
+
self.temperature, self.max_new_tokens = temperature, max_new_tokens
|
|
34
|
+
self.raw, self.show_logits = raw, show_logits
|
|
35
|
+
self.compare_on = compare and base is not None
|
|
36
|
+
self.system, self.seed, self.stream = system, seed, stream
|
|
37
|
+
self.messages: list[dict] = []
|
|
38
|
+
self.last_prompt: str | None = None # exact text last fed to the model
|
|
39
|
+
self.registry = REGISTRY
|
|
40
|
+
self.reset()
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------ state
|
|
43
|
+
def reset(self) -> None:
|
|
44
|
+
self.messages = [{"role": "system", "content": self.system}] if self.system else []
|
|
45
|
+
|
|
46
|
+
def require_prompt(self) -> str:
|
|
47
|
+
if self.last_prompt is None:
|
|
48
|
+
raise CommandError("no prompt yet — say something first")
|
|
49
|
+
return self.last_prompt
|
|
50
|
+
|
|
51
|
+
def sampling(self) -> dict:
|
|
52
|
+
return dict(max_new_tokens=self.max_new_tokens, temperature=self.temperature, seed=self.seed)
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------ turns
|
|
55
|
+
def _gen(self, sess: ModelSession, msgs: list[dict], stream: bool):
|
|
56
|
+
on_text = None
|
|
57
|
+
if stream:
|
|
58
|
+
on_text = lambda s: self.console.print(s, end="", markup=False, highlight=False) # noqa: E731
|
|
59
|
+
if self.raw:
|
|
60
|
+
return sess.generate(sess.format_chat(msgs, raw=True), on_text=on_text, **self.sampling())
|
|
61
|
+
return sess.chat(msgs, on_text=on_text, **self.sampling())
|
|
62
|
+
|
|
63
|
+
def ask(self, text: str) -> str:
|
|
64
|
+
"""One user turn: generate (and stream) the reply, then auto-analysis. Returns the reply."""
|
|
65
|
+
msgs = [{"role": "user", "content": text}] if self.raw else self.messages + [{"role": "user", "content": text}]
|
|
66
|
+
self.last_prompt = self.session.format_chat(msgs, raw=self.raw)
|
|
67
|
+
streaming = self.stream and not self.compare_on
|
|
68
|
+
if self.compare_on:
|
|
69
|
+
self.console.rule(f"[cyan]{self.session.name}")
|
|
70
|
+
g = self._gen(self.session, msgs, streaming)
|
|
71
|
+
if streaming:
|
|
72
|
+
self.console.print()
|
|
73
|
+
else:
|
|
74
|
+
self.console.print(g.text, markup=False, highlight=False)
|
|
75
|
+
if self.compare_on:
|
|
76
|
+
self.console.rule(f"[magenta]{self.base.name}")
|
|
77
|
+
gb = self._gen(self.base, msgs, False)
|
|
78
|
+
self.console.print(gb.text, markup=False, highlight=False)
|
|
79
|
+
self.console.rule()
|
|
80
|
+
c = self.session.compare(self.base, self.last_prompt, k=self.show_logits or 5)
|
|
81
|
+
self.render.comparison(c, self.session.name, self.base.name)
|
|
82
|
+
if self.show_logits:
|
|
83
|
+
self.render.topk(self.session.next_token_topk(self.last_prompt, self.show_logits),
|
|
84
|
+
title=f"next token · {self.session.name}")
|
|
85
|
+
if not self.raw:
|
|
86
|
+
self.messages = msgs + [{"role": "assistant", "content": g.text}]
|
|
87
|
+
return g.text
|
|
88
|
+
|
|
89
|
+
def handle(self, line: str):
|
|
90
|
+
"""Process one input line (command or chat turn). Returns 'quit' to stop."""
|
|
91
|
+
line = line.strip()
|
|
92
|
+
if not line:
|
|
93
|
+
return None
|
|
94
|
+
if line.startswith("/"):
|
|
95
|
+
try:
|
|
96
|
+
return self.registry.dispatch(self, line)
|
|
97
|
+
except CommandError as e:
|
|
98
|
+
self.console.print(f"[red]{e}[/]")
|
|
99
|
+
return None
|
|
100
|
+
self.ask(line)
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ REPL
|
|
104
|
+
def _reader(self):
|
|
105
|
+
if sys.stdin.isatty():
|
|
106
|
+
try:
|
|
107
|
+
from prompt_toolkit import PromptSession
|
|
108
|
+
from prompt_toolkit.completion import WordCompleter
|
|
109
|
+
from prompt_toolkit.history import FileHistory
|
|
110
|
+
hist = cache_root() / "chat_history"
|
|
111
|
+
hist.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
ps = PromptSession(history=FileHistory(str(hist)),
|
|
113
|
+
completer=WordCompleter(self.registry.names(), sentence=True))
|
|
114
|
+
return lambda: ps.prompt("you> ")
|
|
115
|
+
except (ImportError, OSError): # no prompt_toolkit, or an unusable terminal / history file
|
|
116
|
+
pass
|
|
117
|
+
return lambda: input("you> ")
|
|
118
|
+
|
|
119
|
+
def repl(self) -> int:
|
|
120
|
+
s = self.session
|
|
121
|
+
mode = "raw completion" if self.raw else ("chat template" if s.has_chat_template else "User/Assistant transcript")
|
|
122
|
+
info = (f"[bold]{s.name}[/] ({s.lm.source_kind}"
|
|
123
|
+
+ (f", base {s.lm.base_name}" if s.lm.base_name else "") + f") · {mode} · temp {self.temperature}")
|
|
124
|
+
if self.base is not None:
|
|
125
|
+
info += f"\ncompare: {self.base.name} ({'on' if self.compare_on else 'off'}, /compare toggles)"
|
|
126
|
+
self.console.print(Panel(info + "\n/help for commands", title="llmf chat", expand=False))
|
|
127
|
+
read = self._reader()
|
|
128
|
+
while True:
|
|
129
|
+
try:
|
|
130
|
+
line = read()
|
|
131
|
+
except (EOFError, KeyboardInterrupt):
|
|
132
|
+
self.console.print()
|
|
133
|
+
return 0
|
|
134
|
+
try:
|
|
135
|
+
if self.handle(line) == "quit":
|
|
136
|
+
return 0
|
|
137
|
+
except KeyboardInterrupt:
|
|
138
|
+
self.console.print("[yellow]interrupted[/]")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run(a: argparse.Namespace, console: Console | None = None) -> int:
|
|
142
|
+
console = console or Console()
|
|
143
|
+
with console.status(f"loading {a.weights} ..."):
|
|
144
|
+
session = ModelSession(resolve_weights(a.weights, base=a.base, device=a.device, dtype=a.dtype))
|
|
145
|
+
base = None
|
|
146
|
+
if a.base:
|
|
147
|
+
base = ModelSession(resolve_weights(a.base, device=a.device, dtype=a.dtype))
|
|
148
|
+
app = ChatApp(session, base, console=console, temperature=a.temperature,
|
|
149
|
+
max_new_tokens=a.max_new_tokens, raw=a.raw, show_logits=a.show_logits,
|
|
150
|
+
compare=base is not None, system=a.system, seed=a.seed,
|
|
151
|
+
stream=not a.no_stream and a.prompt is None)
|
|
152
|
+
if a.prompt is not None:
|
|
153
|
+
app.ask(a.prompt)
|
|
154
|
+
return 0
|
|
155
|
+
return app.repl()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main(argv: list[str] | None = None) -> int:
|
|
159
|
+
return run(build_parser().parse_args(argv))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__": # pragma: no cover
|
|
163
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Argument definitions for `llmf chat` (no torch import, so `llmf --help` stays fast)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
from llm_forensics.cli import DTYPE_CHOICES
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_parser(prog: str = "llmf chat") -> argparse.ArgumentParser:
|
|
10
|
+
ap = argparse.ArgumentParser(
|
|
11
|
+
prog=prog, description="Chat with a model and inspect its next-token predictions.\n\n"
|
|
12
|
+
"WEIGHTS can be an HF id, local dir, run dir, .safetensors file (with --base) or gs:// path.",
|
|
13
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
14
|
+
add_arguments(ap)
|
|
15
|
+
return ap
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def add_arguments(ap: argparse.ArgumentParser) -> None:
|
|
19
|
+
ap.add_argument("weights", metavar="WEIGHTS", help="the model to chat with")
|
|
20
|
+
ap.add_argument("--base", help="base (pre-edit) weights: shown side by side with KL, toggled with /compare; "
|
|
21
|
+
"also supplies config and tokenizer for a bare .safetensors file")
|
|
22
|
+
ap.add_argument("--prompt", help="answer this prompt and exit")
|
|
23
|
+
ap.add_argument("--max-new-tokens", type=int, default=128, help="tokens to generate per answer (default 128)")
|
|
24
|
+
ap.add_argument("--temperature", type=float, default=0.0, help="sampling temperature; 0 = greedy (default)")
|
|
25
|
+
ap.add_argument("--seed", type=int, default=None, help="random seed for sampling")
|
|
26
|
+
ap.add_argument("--show-logits", type=int, default=0, metavar="K",
|
|
27
|
+
help="after each answer, show the top-K next tokens for the prompt")
|
|
28
|
+
ap.add_argument("--raw", action="store_true", help="plain text completion, without a chat template")
|
|
29
|
+
ap.add_argument("--system", help="system message")
|
|
30
|
+
ap.add_argument("--device", default=None, help="cpu, cuda or mps (default: auto)")
|
|
31
|
+
ap.add_argument("--dtype", default=None, choices=DTYPE_CHOICES, help="default: float32 on CPU, bfloat16 on GPU")
|
|
32
|
+
ap.add_argument("--no-stream", action="store_true", help="print each answer when it is complete")
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Slash-command registry for `llmf chat`.
|
|
2
|
+
|
|
3
|
+
Adding an analysis command is one function + decorator:
|
|
4
|
+
|
|
5
|
+
@command("entropy", help="entropy of the next-token distribution", usage="/entropy")
|
|
6
|
+
def _entropy(app, args: str) -> None:
|
|
7
|
+
t = app.session.next_token_topk(app.require_prompt(), k=1)
|
|
8
|
+
app.console.print(f"H = {t.entropy:.3f} nats")
|
|
9
|
+
|
|
10
|
+
The function receives the ChatApp and the raw argument string. Raise CommandError for a
|
|
11
|
+
user-facing error message. Return the string "quit" to leave the REPL.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING, Callable
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
20
|
+
from .app import ChatApp
|
|
21
|
+
|
|
22
|
+
Handler = Callable[["ChatApp", str], object]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CommandError(Exception):
|
|
26
|
+
"""Message shown to the user; the REPL keeps running."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Command:
|
|
31
|
+
name: str
|
|
32
|
+
func: Handler
|
|
33
|
+
help: str = ""
|
|
34
|
+
usage: str = ""
|
|
35
|
+
aliases: tuple[str, ...] = field(default_factory=tuple)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CommandRegistry:
|
|
39
|
+
def __init__(self):
|
|
40
|
+
self._cmds: dict[str, Command] = {}
|
|
41
|
+
self._alias: dict[str, str] = {}
|
|
42
|
+
|
|
43
|
+
def register(self, name: str, help: str = "", usage: str | None = None,
|
|
44
|
+
aliases: tuple[str, ...] | list[str] = ()) -> Callable[[Handler], Handler]:
|
|
45
|
+
def deco(func: Handler) -> Handler:
|
|
46
|
+
if name in self._cmds or name in self._alias:
|
|
47
|
+
raise ValueError(f"command /{name} already registered")
|
|
48
|
+
self._cmds[name] = Command(name, func, help or (func.__doc__ or "").strip().split("\n")[0],
|
|
49
|
+
usage or f"/{name}", tuple(aliases))
|
|
50
|
+
for a in aliases:
|
|
51
|
+
self._alias[a] = name
|
|
52
|
+
return func
|
|
53
|
+
return deco
|
|
54
|
+
|
|
55
|
+
def get(self, name: str) -> Command | None:
|
|
56
|
+
name = name.lstrip("/")
|
|
57
|
+
return self._cmds.get(self._alias.get(name, name))
|
|
58
|
+
|
|
59
|
+
def __contains__(self, name: str) -> bool:
|
|
60
|
+
return self.get(name) is not None
|
|
61
|
+
|
|
62
|
+
def __iter__(self):
|
|
63
|
+
return iter(self._cmds.values())
|
|
64
|
+
|
|
65
|
+
def names(self) -> list[str]:
|
|
66
|
+
return ["/" + n for n in self._cmds] + ["/" + a for a in self._alias]
|
|
67
|
+
|
|
68
|
+
def dispatch(self, app: "ChatApp", line: str):
|
|
69
|
+
"""Run a '/name args' line. Raises CommandError for unknown commands."""
|
|
70
|
+
line = line.strip()
|
|
71
|
+
if not line.startswith("/"):
|
|
72
|
+
raise CommandError("not a command")
|
|
73
|
+
name, _, args = line[1:].partition(" ")
|
|
74
|
+
cmd = self.get(name)
|
|
75
|
+
if cmd is None:
|
|
76
|
+
raise CommandError(f"unknown command /{name} (try /help)")
|
|
77
|
+
return cmd.func(app, args.strip())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
REGISTRY = CommandRegistry()
|
|
81
|
+
command = REGISTRY.register
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_ints(args: str) -> list[int]:
|
|
85
|
+
"""'3 5 7' / '3,5,7' / '0-11' / '0-11:2' -> list of ints."""
|
|
86
|
+
out: list[int] = []
|
|
87
|
+
for tok in args.replace(",", " ").split():
|
|
88
|
+
rng, _, step = tok.partition(":")
|
|
89
|
+
m = re.fullmatch(r"(-?\d+)-(-?\d+)", rng)
|
|
90
|
+
if m:
|
|
91
|
+
out += list(range(int(m[1]), int(m[2]) + 1, int(step or 1)))
|
|
92
|
+
else:
|
|
93
|
+
out.append(int(rng))
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --------------------------------------------------------------------------- built-ins
|
|
98
|
+
|
|
99
|
+
@command("help", help="list commands", aliases=("h", "?"))
|
|
100
|
+
def _help(app, args):
|
|
101
|
+
app.render.help(REGISTRY)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@command("quit", help="exit", aliases=("exit", "q"))
|
|
105
|
+
def _quit(app, args):
|
|
106
|
+
return "quit"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@command("logits", help="top-k next-token table for the last prompt", usage="/logits [k]")
|
|
110
|
+
def _logits(app, args):
|
|
111
|
+
k = int(args) if args else app.show_logits or 10
|
|
112
|
+
prompt = app.require_prompt()
|
|
113
|
+
app.render.topk(app.session.next_token_topk(prompt, k), title=f"next token · {app.session.name}")
|
|
114
|
+
if app.compare_on and app.base is not None:
|
|
115
|
+
app.render.topk(app.base.next_token_topk(prompt, k), title=f"next token · {app.base.name}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@command("lens", help="logit lens: top tokens per layer at the last prompt position",
|
|
119
|
+
usage="/lens [layers e.g. 0-11:2 or 3 6 9] [k=5]")
|
|
120
|
+
def _lens(app, args):
|
|
121
|
+
k = 5
|
|
122
|
+
parts = []
|
|
123
|
+
for tok in args.split():
|
|
124
|
+
if tok.startswith("k="):
|
|
125
|
+
k = int(tok[2:])
|
|
126
|
+
else:
|
|
127
|
+
parts.append(tok)
|
|
128
|
+
try:
|
|
129
|
+
layers = parse_ints(" ".join(parts)) if parts else None
|
|
130
|
+
except ValueError as e:
|
|
131
|
+
raise CommandError(f"bad layer spec {args!r}: {e}") from None
|
|
132
|
+
app.render.lens(app.session.logit_lens(app.require_prompt(), layers, k=k), title=app.session.name)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@command("compare", help="toggle side-by-side completions against the --base model, with KL")
|
|
136
|
+
def _compare(app, args):
|
|
137
|
+
if app.base is None:
|
|
138
|
+
raise CommandError("no base model; start with --base <weights>")
|
|
139
|
+
app.compare_on = not app.compare_on if args not in ("on", "off") else args == "on"
|
|
140
|
+
app.console.print(f"compare: [bold]{'on' if app.compare_on else 'off'}[/] ({app.base.name})")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@command("temp", help="set sampling temperature (0 = greedy)", usage="/temp X")
|
|
144
|
+
def _temp(app, args):
|
|
145
|
+
try:
|
|
146
|
+
app.temperature = float(args)
|
|
147
|
+
except ValueError:
|
|
148
|
+
raise CommandError("usage: /temp X") from None
|
|
149
|
+
app.console.print(f"temperature = {app.temperature}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@command("max", help="set max new tokens", usage="/max N")
|
|
153
|
+
def _max(app, args):
|
|
154
|
+
try:
|
|
155
|
+
app.max_new_tokens = int(args)
|
|
156
|
+
except ValueError:
|
|
157
|
+
raise CommandError("usage: /max N") from None
|
|
158
|
+
app.console.print(f"max_new_tokens = {app.max_new_tokens}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@command("reset", help="clear the conversation history")
|
|
162
|
+
def _reset(app, args):
|
|
163
|
+
app.reset()
|
|
164
|
+
app.console.print("history cleared")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@command("raw", help="toggle raw completion mode (no chat template / transcript, no history)")
|
|
168
|
+
def _raw(app, args):
|
|
169
|
+
app.raw = not app.raw if args not in ("on", "off") else args == "on"
|
|
170
|
+
app.reset()
|
|
171
|
+
app.console.print(f"raw mode: [bold]{'on' if app.raw else 'off'}[/] (history cleared)")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@command("prompt", help="show the exact text of the last prompt fed to the model")
|
|
175
|
+
def _prompt(app, args):
|
|
176
|
+
app.console.print(repr(app.require_prompt()), markup=False, highlight=False)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Rich rendering of core.session results (tables with probability bars)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.markup import escape
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from llm_forensics.core.session import Comparison, LogitLens, TopK
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def bar(p: float, width: int = 24) -> str:
|
|
12
|
+
"""Unicode probability bar, e.g. bar(0.5, 8) -> '████ '."""
|
|
13
|
+
p = min(max(p, 0.0), 1.0)
|
|
14
|
+
eighths = int(round(p * width * 8))
|
|
15
|
+
full, rem = divmod(eighths, 8)
|
|
16
|
+
s = "█" * full + (" ▏▎▍▌▋▊▉"[rem] if rem else "")
|
|
17
|
+
return s.ljust(width)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def tok_repr(t: str) -> str:
|
|
21
|
+
return repr(t)[1:-1] if t.strip() == t and t else repr(t)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Renderer:
|
|
25
|
+
def __init__(self, console: Console):
|
|
26
|
+
self.console = console
|
|
27
|
+
|
|
28
|
+
def topk(self, t: TopK, title: str = "next token") -> Table:
|
|
29
|
+
tab = Table(title=f"{escape(title)} (H={t.entropy:.2f} nats)", title_justify="left", expand=False)
|
|
30
|
+
tab.add_column("#", justify="right", style="dim")
|
|
31
|
+
tab.add_column("token", style="cyan", no_wrap=True)
|
|
32
|
+
tab.add_column("id", justify="right", style="dim")
|
|
33
|
+
tab.add_column("prob", justify="right")
|
|
34
|
+
tab.add_column("", style="green", no_wrap=True)
|
|
35
|
+
tab.add_column("logit", justify="right", style="dim")
|
|
36
|
+
for i, (tok, tid, p, lg) in enumerate(zip(t.tokens, t.ids, t.probs, t.logits), 1):
|
|
37
|
+
tab.add_row(str(i), escape(tok_repr(tok)), str(tid), f"{p:.4f}", bar(p), f"{lg:.2f}")
|
|
38
|
+
self.console.print(tab)
|
|
39
|
+
return tab
|
|
40
|
+
|
|
41
|
+
def lens(self, ll: LogitLens, title: str = "") -> Table:
|
|
42
|
+
k = len(ll.layers[0].tokens) if ll.layers else 0
|
|
43
|
+
tab = Table(title=f"logit lens {escape(title)} @pos {ll.position}", title_justify="left")
|
|
44
|
+
tab.add_column("layer", justify="right", style="dim")
|
|
45
|
+
for j in range(k):
|
|
46
|
+
tab.add_column(f"top{j + 1}", no_wrap=True)
|
|
47
|
+
for L in ll.layers:
|
|
48
|
+
tab.add_row(str(L.layer), *[f"[cyan]{escape(tok_repr(t))}[/] [dim]{p:.2f}[/]"
|
|
49
|
+
for t, p in zip(L.tokens, L.probs)])
|
|
50
|
+
self.console.print(tab)
|
|
51
|
+
return tab
|
|
52
|
+
|
|
53
|
+
def comparison(self, c: Comparison, a_name: str, b_name: str) -> Table:
|
|
54
|
+
tab = Table(title=f"KL({escape(a_name)}‖{escape(b_name)})={c.kl:.4f} KL(rev)={c.kl_reverse:.4f} JS={c.js:.4f}"
|
|
55
|
+
f" top1 {'agree' if c.top1_agree else '[red]DIFFER[/]'}", title_justify="left")
|
|
56
|
+
tab.add_column("#", justify="right", style="dim")
|
|
57
|
+
tab.add_column(escape(a_name), style="cyan", no_wrap=True)
|
|
58
|
+
tab.add_column("p", justify="right")
|
|
59
|
+
tab.add_column(escape(b_name), style="magenta", no_wrap=True)
|
|
60
|
+
tab.add_column("p", justify="right")
|
|
61
|
+
for i in range(len(c.self_topk.ids)):
|
|
62
|
+
tab.add_row(str(i + 1), escape(tok_repr(c.self_topk.tokens[i])), f"{c.self_topk.probs[i]:.4f}",
|
|
63
|
+
escape(tok_repr(c.other_topk.tokens[i])), f"{c.other_topk.probs[i]:.4f}")
|
|
64
|
+
self.console.print(tab)
|
|
65
|
+
return tab
|
|
66
|
+
|
|
67
|
+
def help(self, registry) -> None:
|
|
68
|
+
tab = Table(title="commands", title_justify="left", show_header=False, box=None)
|
|
69
|
+
tab.add_column(style="bold cyan", no_wrap=True)
|
|
70
|
+
tab.add_column()
|
|
71
|
+
for c in registry:
|
|
72
|
+
al = f" [dim](/{', /'.join(c.aliases)})[/]" if c.aliases else ""
|
|
73
|
+
tab.add_row(escape(c.usage), escape(c.help) + al)
|
|
74
|
+
self.console.print(tab)
|
llm_forensics/cli.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""`llmf`: detect and explain edits hidden in LLM weights.
|
|
2
|
+
|
|
3
|
+
<weights> is a Hugging Face id, a local HF directory, an `llmf edit` run directory, a
|
|
4
|
+
.safetensors file (with --base) or a gs:// path. `llmf <command> -h` shows a command's options.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import importlib
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from llm_forensics import BUILD
|
|
13
|
+
|
|
14
|
+
DTYPE_CHOICES = ("float32", "bfloat16", "float16") # --dtype in every command
|
|
15
|
+
|
|
16
|
+
# command -> (module with main(argv), usage line, help)
|
|
17
|
+
COMMANDS = {
|
|
18
|
+
"models": ("llm_forensics.models", "llmf models {register,list}",
|
|
19
|
+
"register existing base-model directories for reuse"),
|
|
20
|
+
"identify": ("llm_forensics.identify.cli", "llmf identify <weights> [--top-k K]",
|
|
21
|
+
"identify the base/ancestor checkpoint of unknown weights"),
|
|
22
|
+
"scan": ("llm_forensics.scan.cli", "llmf scan <weights> [--base <base>]",
|
|
23
|
+
"scan weights for hidden edits"),
|
|
24
|
+
"edit": ("llm_forensics.edit.run", "llmf edit <config.yaml>",
|
|
25
|
+
"apply a ROME or MEMIT edit described by a YAML config"),
|
|
26
|
+
"chat": ("llm_forensics.chat.app", "llmf chat <weights>",
|
|
27
|
+
"chat with a model and inspect its next-token predictions"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
usage = "\n".join(f" {u:<38} {h}" for _, u, h in COMMANDS.values())
|
|
33
|
+
ap = argparse.ArgumentParser(prog="llmf", description=f"{__doc__.splitlines()[0]}\n\n{usage}\n\n"
|
|
34
|
+
+ "\n".join(__doc__.splitlines()[2:]),
|
|
35
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
36
|
+
ap.add_argument("--version", action="version", version=f"llmf {BUILD}")
|
|
37
|
+
sub = ap.add_subparsers(dest="cmd", metavar="{scan,edit,chat,identify,models}")
|
|
38
|
+
for name, (_, _, help_) in COMMANDS.items():
|
|
39
|
+
sub.add_parser(name, help=help_, add_help=False)
|
|
40
|
+
return ap
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv: list[str] | None = None) -> int:
|
|
44
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
45
|
+
if argv and argv[0] in COMMANDS: # each command parses its own options; heavy imports happen here
|
|
46
|
+
mod = importlib.import_module(COMMANDS[argv[0]][0])
|
|
47
|
+
return int(mod.main(argv[1:]) or 0)
|
|
48
|
+
ap = build_parser()
|
|
49
|
+
a = ap.parse_args(argv)
|
|
50
|
+
if a.cmd is None:
|
|
51
|
+
ap.print_help()
|
|
52
|
+
return 2
|
|
53
|
+
return 0 # pragma: no cover
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
"""Shared core: weights loading (`loading`) and reusable analysis primitives (`session`)."""
|
|
2
|
+
from .loading import LoadedModel, TensorReader, open_tensors, resolve_weights # noqa: F401
|
|
3
|
+
from .session import Comparison, Generation, LogitLens, MLPKeys, ModelSession, TopK # noqa: F401
|