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.
Files changed (75) hide show
  1. llm_forensics/__init__.py +23 -0
  2. llm_forensics/arch.py +104 -0
  3. llm_forensics/chat/__init__.py +2 -0
  4. llm_forensics/chat/app.py +163 -0
  5. llm_forensics/chat/args.py +32 -0
  6. llm_forensics/chat/commands.py +176 -0
  7. llm_forensics/chat/render.py +74 -0
  8. llm_forensics/cli.py +57 -0
  9. llm_forensics/core/__init__.py +3 -0
  10. llm_forensics/core/guard.py +64 -0
  11. llm_forensics/core/loading.py +485 -0
  12. llm_forensics/core/locks.py +7 -0
  13. llm_forensics/core/session.py +322 -0
  14. llm_forensics/edit/__init__.py +11 -0
  15. llm_forensics/edit/common.py +371 -0
  16. llm_forensics/edit/covariance.py +259 -0
  17. llm_forensics/edit/delta.py +27 -0
  18. llm_forensics/edit/evaluate.py +154 -0
  19. llm_forensics/edit/memit.py +143 -0
  20. llm_forensics/edit/rome.py +269 -0
  21. llm_forensics/edit/run.py +332 -0
  22. llm_forensics/edit/spec.py +229 -0
  23. llm_forensics/forensics/__init__.py +0 -0
  24. llm_forensics/forensics/diff.py +457 -0
  25. llm_forensics/forensics/interpret.py +662 -0
  26. llm_forensics/forensics/mlp_gain.py +2063 -0
  27. llm_forensics/forensics/rank_reversal.py +275 -0
  28. llm_forensics/forensics/report_schema.py +188 -0
  29. llm_forensics/forensics/spectral.py +1005 -0
  30. llm_forensics/forensics/stats.py +66 -0
  31. llm_forensics/gcs.py +90 -0
  32. llm_forensics/identify/__init__.py +7 -0
  33. llm_forensics/identify/cli.py +232 -0
  34. llm_forensics/identify/data/__init__.py +1 -0
  35. llm_forensics/identify/data/builtin_ref.json.gz +0 -0
  36. llm_forensics/identify/engine.py +519 -0
  37. llm_forensics/identify/fingerprint.py +912 -0
  38. llm_forensics/identify/refdb.py +294 -0
  39. llm_forensics/investigator/__init__.py +7 -0
  40. llm_forensics/investigator/agent.py +141 -0
  41. llm_forensics/investigator/api.py +153 -0
  42. llm_forensics/investigator/prompts.py +109 -0
  43. llm_forensics/investigator/providers.py +486 -0
  44. llm_forensics/investigator/raw_tools.py +393 -0
  45. llm_forensics/investigator/tools.py +609 -0
  46. llm_forensics/models.py +68 -0
  47. llm_forensics/scan/__init__.py +6 -0
  48. llm_forensics/scan/cli.py +444 -0
  49. llm_forensics/scan/context.py +125 -0
  50. llm_forensics/scan/detectors/__init__.py +2 -0
  51. llm_forensics/scan/detectors/_util.py +17 -0
  52. llm_forensics/scan/detectors/llm_investigator.py +87 -0
  53. llm_forensics/scan/detectors/mlp_gain.py +43 -0
  54. llm_forensics/scan/detectors/rank_reversal.py +42 -0
  55. llm_forensics/scan/detectors/spectral.py +38 -0
  56. llm_forensics/scan/detectors/weight_diff.py +52 -0
  57. llm_forensics/scan/findings.py +204 -0
  58. llm_forensics/scan/registry.py +174 -0
  59. llm_forensics/scan/render.py +696 -0
  60. llm_forensics/scan/runner.py +311 -0
  61. llm_forensics/toolkit/__init__.py +6 -0
  62. llm_forensics/toolkit/_common.py +246 -0
  63. llm_forensics/toolkit/corpus.py +151 -0
  64. llm_forensics/toolkit/probe.py +93 -0
  65. llm_forensics/toolkit/selectivity.py +155 -0
  66. llm_forensics/toolkit/spectra.py +155 -0
  67. llm_forensics/toolkit/surgery.py +193 -0
  68. llm_forensics/toolkit/trace.py +164 -0
  69. llm_forensics/toolkit/whiten.py +207 -0
  70. llm_forensics-0.1.0.dist-info/METADATA +221 -0
  71. llm_forensics-0.1.0.dist-info/RECORD +75 -0
  72. llm_forensics-0.1.0.dist-info/WHEEL +5 -0
  73. llm_forensics-0.1.0.dist-info/entry_points.txt +2 -0
  74. llm_forensics-0.1.0.dist-info/licenses/LICENSE +28 -0
  75. 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,2 @@
1
+ """Interactive / one-shot chat with analysis slash commands (`llmf chat`)."""
2
+ from .commands import REGISTRY, CommandError, command # noqa: F401
@@ -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