innards 0.0.1__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. innards/__init__.py +79 -0
  2. innards/__main__.py +7 -0
  3. innards/_env.py +78 -0
  4. innards/_text.py +76 -0
  5. innards/cli.py +308 -0
  6. innards/collectors/__init__.py +6 -0
  7. innards/collectors/memory.py +85 -0
  8. innards/collectors/timing.py +32 -0
  9. innards/experts/__init__.py +43 -0
  10. innards/experts/analysis.py +513 -0
  11. innards/experts/routing.py +446 -0
  12. innards/experts/trace.py +679 -0
  13. innards/features/__init__.py +72 -0
  14. innards/features/base.py +359 -0
  15. innards/features/deltanet.py +321 -0
  16. innards/features/gates.py +157 -0
  17. innards/features/kv.py +238 -0
  18. innards/features/layout.py +371 -0
  19. innards/features/mhc.py +240 -0
  20. innards/features/moe.py +231 -0
  21. innards/features/registry.py +199 -0
  22. innards/kv/__init__.py +26 -0
  23. innards/kv/calculator.py +287 -0
  24. innards/kv/config.py +649 -0
  25. innards/kv/dtypes.py +56 -0
  26. innards/lineage/__init__.py +200 -0
  27. innards/lineage/configs/Qwen__Qwen3-0.6B.json +30 -0
  28. innards/lineage/configs/Qwen__Qwen3-1.7B.json +30 -0
  29. innards/lineage/configs/Qwen__Qwen3.5-0.8B.json +96 -0
  30. innards/lineage/configs/Qwen__Qwen3.5-2B.json +96 -0
  31. innards/lineage/configs/ibm-granite__granite-3.1-3b-a800m-instruct.json +35 -0
  32. innards/lineage/configs/ibm-granite__granite-swash-3b-a600m.json +70 -0
  33. innards/lineage/configs/sources.json +56 -0
  34. innards/lineage/data/compressed.json +18 -0
  35. innards/lineage/data/dsa.json +18 -0
  36. innards/lineage/data/fp8_kv.json +16 -0
  37. innards/lineage/data/gqa.json +19 -0
  38. innards/lineage/data/hybrid.json +20 -0
  39. innards/lineage/data/mha.json +19 -0
  40. innards/lineage/data/mla.json +18 -0
  41. innards/lineage/data/mqa.json +19 -0
  42. innards/lineage/data/sliding_window.json +19 -0
  43. innards/lineage/data/ssm.json +18 -0
  44. innards/lineage/lineup.json +79 -0
  45. innards/lineage/lineup.py +290 -0
  46. innards/observer/__init__.py +30 -0
  47. innards/observer/cache.py +304 -0
  48. innards/observer/events.py +74 -0
  49. innards/observer/observer.py +291 -0
  50. innards/observer/probes.py +204 -0
  51. innards/observer/queue.py +62 -0
  52. innards/observer/watch.py +904 -0
  53. innards/py.typed +0 -0
  54. innards/schema.py +315 -0
  55. innards/sets.py +289 -0
  56. innards/sinks/__init__.py +85 -0
  57. innards/sinks/console.py +95 -0
  58. innards/store.py +241 -0
  59. innards/sync.py +126 -0
  60. innards/validate.py +562 -0
  61. innards/view/__init__.py +111 -0
  62. innards/view/analysis.py +235 -0
  63. innards/view/app.py +497 -0
  64. innards/view/experts.py +292 -0
  65. innards/view/runner.py +152 -0
  66. innards/view/static/app.js +1031 -0
  67. innards/view/static/index.html +138 -0
  68. innards/view/static/style.css +219 -0
  69. innards/view/static/vendor/PLOTLY-LICENSE +21 -0
  70. innards/view/static/vendor/plotly-4.1.1.min.js +3620 -0
  71. innards-0.0.1.dist-info/METADATA +401 -0
  72. innards-0.0.1.dist-info/RECORD +75 -0
  73. innards-0.0.1.dist-info/WHEEL +4 -0
  74. innards-0.0.1.dist-info/entry_points.txt +3 -0
  75. innards-0.0.1.dist-info/licenses/LICENSE +201 -0
innards/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """Innards: see inside open-weight models while they run.
2
+
3
+ Your code loads the model; Innards watches it::
4
+
5
+ import innards
6
+
7
+ watch = innards.observe(model, tokenizer, model_id="Qwen/Qwen3.5-0.8B", run="deltanet-study")
8
+ out = model.generate(**inputs, max_new_tokens=256) # one turn
9
+ watch.view() # the web view on this machine
10
+
11
+ Feature analyzers run automatically for the features the model has, and can be called explicitly::
12
+
13
+ from innards import GatedDeltaNet
14
+ GatedDeltaNet(model="Qwen/Qwen3.5-0.8B", args=watch.last_turn())
15
+
16
+ For MoE models, :mod:`innards.experts` records which experts each token and topic activates, during
17
+ inference and training, and which experts a topic needs::
18
+
19
+ trace = innards.experts.trace(model, tokenizer, run="topic-experts")
20
+ with trace.topic("math"):
21
+ model.generate(**inputs, max_new_tokens=64)
22
+ trace.map().select("math", coverage=0.9)
23
+ """
24
+
25
+ from importlib.metadata import PackageNotFoundError, version
26
+ from typing import Any
27
+
28
+ try:
29
+ __version__ = version("innards")
30
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
31
+ __version__ = "0.0.0"
32
+
33
+ from innards import experts
34
+ from innards.features import (
35
+ CSAHCA,
36
+ DSA,
37
+ MLA,
38
+ GatedAttention,
39
+ GatedDeltaNet,
40
+ KVContext,
41
+ MoE,
42
+ mHC,
43
+ )
44
+ from innards.observer.watch import CapturedTurn, Watch, observe
45
+ from innards.schema import ConversationRecord, TurnRecord
46
+
47
+ __all__ = [
48
+ "CSAHCA",
49
+ "DSA",
50
+ "MLA",
51
+ "CapturedTurn",
52
+ "ConversationRecord",
53
+ "GatedAttention",
54
+ "GatedDeltaNet",
55
+ "KVContext",
56
+ "MoE",
57
+ "TurnRecord",
58
+ "Watch",
59
+ "__version__",
60
+ "experts",
61
+ "mHC",
62
+ "observe",
63
+ "view",
64
+ ]
65
+
66
+
67
+ def view(
68
+ root: str | None = None,
69
+ *,
70
+ host: str = "127.0.0.1",
71
+ port: int = 8000,
72
+ runner: Any = None,
73
+ open_browser: bool = False,
74
+ block: bool = True,
75
+ ) -> object:
76
+ """Serve the web view over a folder of runs (default ``./innards_runs``). Needs ``innards[view]``."""
77
+ from innards.view import serve
78
+
79
+ return serve(root=root, runner=runner, host=host, port=port, open_browser=open_browser, block=block)
innards/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m innards`` runs the CLI."""
2
+
3
+ import sys
4
+
5
+ from innards.cli import main
6
+
7
+ sys.exit(main())
innards/_env.py ADDED
@@ -0,0 +1,78 @@
1
+ """Versions and host facts recorded with every run, and the device helpers ``observe`` uses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import socket
8
+ from typing import Any
9
+
10
+ __all__ = ["DEFAULT_BUDGET_GB", "budget_bytes", "device_name", "environment", "host_kind"]
11
+
12
+ DEFAULT_BUDGET_GB = 16.0
13
+ """The memory budget Innards checks against (GPU memory, or unified memory on Apple silicon), unless
14
+ ``budget_gb`` or ``$INNARDS_GPU_BUDGET_GB`` says otherwise. 16 GB fits a local machine running models at 16 bit."""
15
+
16
+
17
+ def budget_bytes(budget_gb: float | None = None) -> int:
18
+ value = budget_gb if budget_gb is not None else float(os.environ.get("INNARDS_GPU_BUDGET_GB") or DEFAULT_BUDGET_GB)
19
+ if value <= 0:
20
+ raise ValueError("The memory budget must be positive")
21
+ return int(value * 1e9)
22
+
23
+
24
+ def host_kind() -> str:
25
+ if os.environ.get("INNARDS_HOST"):
26
+ return os.environ["INNARDS_HOST"]
27
+ if os.environ.get("KAGGLE_KERNEL_RUN_TYPE"):
28
+ return "kaggle"
29
+ if os.environ.get("COLAB_RELEASE_TAG"):
30
+ return "colab"
31
+ if os.environ.get("SM_CURRENT_HOST") or os.environ.get("SAGEMAKER_INTERNAL_IMAGE_URI"):
32
+ return "sagemaker"
33
+ if os.environ.get("AWS_EXECUTION_ENV") or os.environ.get("ECS_CONTAINER_METADATA_URI"):
34
+ return "aws"
35
+ return "local"
36
+
37
+
38
+ def device_name(device: Any) -> str:
39
+ """``NVIDIA H200``, ``mps (arm64)`` or ``cpu (x86_64)``."""
40
+ kind = getattr(device, "type", str(device))
41
+ if kind == "cuda":
42
+ try:
43
+ import torch
44
+
45
+ index = device.index if getattr(device, "index", None) is not None else torch.cuda.current_device()
46
+ return str(torch.cuda.get_device_name(index))
47
+ except Exception:
48
+ return "cuda"
49
+ return f"{kind} ({platform.machine()})"
50
+
51
+
52
+ def environment() -> dict[str, Any]:
53
+ """Versions and host facts, recorded once per run and per conversation."""
54
+ from innards import __version__
55
+
56
+ env: dict[str, Any] = {
57
+ "innards": __version__,
58
+ "python": platform.python_version(),
59
+ "platform": platform.platform(),
60
+ "machine": platform.machine(),
61
+ "host": socket.gethostname(),
62
+ "host_kind": host_kind(),
63
+ }
64
+ try:
65
+ import torch
66
+
67
+ env["torch"] = torch.__version__
68
+ env["cuda"] = bool(torch.cuda.is_available())
69
+ env["mps"] = bool(getattr(torch.backends, "mps", None) and torch.backends.mps.is_available())
70
+ except ImportError:
71
+ pass
72
+ try:
73
+ import transformers
74
+
75
+ env["transformers"] = transformers.__version__
76
+ except ImportError:
77
+ pass
78
+ return env
innards/_text.py ADDED
@@ -0,0 +1,76 @@
1
+ """Incremental detokenization for streaming, with stop-string hold-back."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+
7
+ __all__ = ["IncrementalDecoder", "strip_stop"]
8
+
9
+
10
+ def strip_stop(text: str, stop_strings: Sequence[str]) -> tuple[str, bool]:
11
+ """Cut ``text`` at the first stop string. Returns ``(text, stopped)``."""
12
+ cut = min((i for i in (text.find(s) for s in stop_strings if s) if i >= 0), default=-1)
13
+ return (text[:cut], True) if cut >= 0 else (text, False)
14
+
15
+
16
+ class IncrementalDecoder:
17
+ """Turn a growing list of token ids into text deltas.
18
+
19
+ Decodes a short window (prefix/read offsets) instead of the whole sequence,
20
+ holds back incomplete UTF-8 sequences, and holds back any tail that could be
21
+ the start of a stop string.
22
+ """
23
+
24
+ def __init__(self, decode: Callable[[list[int]], str], stop_strings: Sequence[str] = ()) -> None:
25
+ self._decode = decode
26
+ self._stop = [s for s in stop_strings if s]
27
+ self._ids: list[int] = []
28
+ self._prefix = 0
29
+ self._read = 0
30
+ self._decoded = "" # all text decoded so far
31
+ self._released = 0 # characters of _decoded handed to the caller
32
+ self.stopped = False
33
+
34
+ @property
35
+ def text(self) -> str:
36
+ """Full text so far, cut at the first stop string."""
37
+ return strip_stop(self._decoded, self._stop)[0]
38
+
39
+ def _hold(self, text: str) -> int:
40
+ """Length of the longest suffix of ``text`` that is a proper prefix of a stop string."""
41
+ best = 0
42
+ for stop in self._stop:
43
+ for n in range(min(len(stop) - 1, len(text)), 0, -1):
44
+ if text.endswith(stop[:n]):
45
+ best = max(best, n)
46
+ break
47
+ return best
48
+
49
+ def push(self, ids: Sequence[int]) -> str:
50
+ if self.stopped:
51
+ return ""
52
+ self._ids.extend(ids)
53
+ prefix_text = self._decode(self._ids[self._prefix : self._read])
54
+ new_text = self._decode(self._ids[self._prefix :])
55
+ if len(new_text) > len(prefix_text) and not new_text.endswith("�"):
56
+ self._decoded += new_text[len(prefix_text) :]
57
+ self._prefix, self._read = self._read, len(self._ids)
58
+ return self._release(final=False)
59
+
60
+ def flush(self) -> str:
61
+ if not self.stopped and self._read < len(self._ids):
62
+ tail = self._decode(self._ids[self._prefix :])[len(self._decode(self._ids[self._prefix : self._read])) :]
63
+ self._decoded += tail
64
+ self._read = len(self._ids)
65
+ return self._release(final=True)
66
+
67
+ def _release(self, final: bool) -> str:
68
+ text, stopped = strip_stop(self._decoded, self._stop)
69
+ if stopped:
70
+ self.stopped = True
71
+ end = len(text)
72
+ else:
73
+ end = len(text) if final else len(text) - self._hold(text)
74
+ out = text[self._released : end] if end > self._released else ""
75
+ self._released = max(self._released, end)
76
+ return out
innards/cli.py ADDED
@@ -0,0 +1,308 @@
1
+ """Command line.
2
+
3
+ innards view [folder] the web view over a folder of runs (or one run folder)
4
+ innards validate <model> [--device --set] the before-download checks for this machine
5
+ innards predict --model <id> --context <n> KV size from the config, no weights
6
+ innards explain <family | model> what an architecture changed from its predecessor
7
+ innards lineup the pairs and what each newer model changed
8
+ innards sync up <run> s3://bucket/prefix copy a run folder to S3 (and ``sync down`` back)
9
+ innards experts [folder] [--topic T] MoE routing traces: top experts and the set a topic needs
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import re
17
+ import sys
18
+ from collections.abc import Sequence
19
+ from typing import Any
20
+
21
+ from innards import __version__
22
+
23
+ __all__ = ["main"]
24
+
25
+ _UNITS = {"": 1, "b": 1, "k": 1000, "kb": 1000, "m": 10**6, "mb": 10**6, "g": 10**9, "gb": 10**9, "t": 10**12}
26
+ _UNITS |= {"tb": 10**12, "kib": 2**10, "mib": 2**20, "gib": 2**30, "tib": 2**40}
27
+
28
+
29
+ def parse_bytes(text: str) -> int:
30
+ """``"16GiB"``, ``"8 GB"``, ``"512m"`` or a plain integer."""
31
+ match = re.fullmatch(r"\s*([\d.]+)\s*([a-zA-Z]*)\s*", text)
32
+ if not match or match.group(2).lower() not in _UNITS:
33
+ raise argparse.ArgumentTypeError(f"not a size: {text!r} (try 16GiB, 8GB or 1073741824)")
34
+ return int(float(match.group(1)) * _UNITS[match.group(2).lower()])
35
+
36
+
37
+ def _build_parser() -> argparse.ArgumentParser:
38
+ parser = argparse.ArgumentParser(
39
+ prog="innards", description="See inside open-weight models: KV cache, context growth and memory per message."
40
+ )
41
+ parser.add_argument("--version", action="version", version=f"innards {__version__}")
42
+ sub = parser.add_subparsers(dest="command", required=True)
43
+
44
+ view = sub.add_parser("view", help="Serve the web view over a folder of runs")
45
+ view.add_argument(
46
+ "root", nargs="?", default=None, help="Folder of runs, or one run folder (default ./innards_runs)"
47
+ )
48
+ view.add_argument("--host", default="127.0.0.1", help="Address to bind (default 127.0.0.1; use an SSH tunnel)")
49
+ view.add_argument("--port", type=int, default=8000)
50
+ view.add_argument("--open", action="store_true", help="Open the page in a browser")
51
+
52
+ validate = sub.add_parser("validate", help="Check a model against this machine before downloading it")
53
+ validate.add_argument("model", help="Hub repo id or local folder")
54
+ validate.add_argument("--device", default=None, help="cuda, mps or cpu (default: the best available)")
55
+ validate.add_argument("--precision", default=None, help="auto (published), bf16, fp16, fp32 ...")
56
+ validate.add_argument("--set", default=None, help="Conversation set that sizes the KV check")
57
+ validate.add_argument(
58
+ "--budget-gb", type=float, default=None, help="Memory budget (default 16 or $INNARDS_GPU_BUDGET_GB)"
59
+ )
60
+ validate.add_argument("--json", action="store_true")
61
+
62
+ lineup = sub.add_parser("lineup", help="The lineup: pairs and what each newer model changed")
63
+ lineup.add_argument("--json", action="store_true")
64
+
65
+ sync = sub.add_parser("sync", help="Copy run folders to or from S3 (needs innards[aws])")
66
+ sync_sub = sync.add_subparsers(dest="direction", required=True)
67
+ up = sync_sub.add_parser("up", help="Upload a run folder: innards sync up <run> s3://bucket/prefix")
68
+ up.add_argument("run")
69
+ up.add_argument("url")
70
+ up.add_argument("--root", default=None, help="Folder of runs (default ./innards_runs)")
71
+ down = sync_sub.add_parser("down", help="Download runs: innards sync down s3://bucket/prefix[/run]")
72
+ down.add_argument("url")
73
+ down.add_argument("--root", default=None, help="Folder of runs (default ./innards_runs)")
74
+
75
+ predict = sub.add_parser("predict", help="Predict KV-cache size from a model config (no weights)")
76
+ predict.add_argument("--model", required=True, help="Hub repo id, local path or config.json")
77
+ predict.add_argument("--context", type=int, required=True, help="Context length in tokens")
78
+ predict.add_argument(
79
+ "--kv-dtype", default="float16", help="float32, float16, bfloat16 or float8 (default: float16)"
80
+ )
81
+ predict.add_argument("--memory", type=parse_bytes, default=None, help="KV memory budget for capacity, e.g. 16GiB")
82
+ predict.add_argument("--batch-size", type=int, default=1)
83
+ predict.add_argument("--revision", default=None, help="Hub revision")
84
+ predict.add_argument("--json", action="store_true", help="Print the prediction as JSON")
85
+
86
+ experts = sub.add_parser("experts", help="MoE routing traces: which experts a topic or token activates")
87
+ experts.add_argument(
88
+ "path", nargs="?", default=None, help="Folder of runs, a run folder or a trace folder (default ./innards_runs)"
89
+ )
90
+ experts.add_argument("--topic", default=None, help="Topic to show (default: list the traces and topics)")
91
+ experts.add_argument("--by", default="selection", choices=["selection", "gate", "lift"], help="Ranking measure")
92
+ experts.add_argument("--layer", type=int, action="append", default=None, help="Only these MoE layers")
93
+ experts.add_argument("--top", type=int, default=8, help="Experts to show per layer (default 8)")
94
+ experts.add_argument(
95
+ "--select", type=float, default=None, metavar="COVERAGE", help="The experts that cover this share, e.g. 0.9"
96
+ )
97
+ experts.add_argument("--out", default=None, help="Write the --select expert set to this JSON file")
98
+ experts.add_argument("--token", default=None, help="Where one token goes, per layer (reads the token shards)")
99
+ experts.add_argument("--json", action="store_true")
100
+
101
+ explain = sub.add_parser("explain", help="What an architecture changed from its predecessor")
102
+ explain.add_argument("target", help="Family (mha, gqa, mla, sliding_window, hybrid ...) or a model id/path")
103
+ explain.add_argument("--json", action="store_true")
104
+ return parser
105
+
106
+
107
+ # --------------------------------------------------------------------------- predict / explain
108
+
109
+
110
+ def _cmd_predict(args: argparse.Namespace) -> int:
111
+ from innards.kv import predict
112
+ from innards.lineage import explain
113
+ from innards.sinks.console import format_bytes
114
+
115
+ p = predict(
116
+ args.model,
117
+ args.context,
118
+ args.kv_dtype,
119
+ memory_bytes=args.memory,
120
+ batch_size=args.batch_size,
121
+ revision=args.revision,
122
+ )
123
+ if args.json:
124
+ print(json.dumps(p.to_dict(), indent=2))
125
+ return 0
126
+ groups = p.by_layer_type
127
+ attention = f" ({p.attention.upper()} attention)" if p.attention and p.attention != p.architecture else ""
128
+ print(f"{p.model_id or args.model} · {p.architecture}{attention} · KV dtype {p.kv_dtype}")
129
+ print(f" per token: {p.formula}")
130
+ print(
131
+ f" at {p.context_tokens:,} tokens: KV {format_bytes(p.kv_bytes)} ({p.kv_bytes:,} B) · "
132
+ f"global {format_bytes(groups['global'])} · sliding {format_bytes(groups['sliding'])} · "
133
+ f"recurrent {format_bytes(p.recurrent_state_bytes)}"
134
+ )
135
+ if p.max_context_tokens:
136
+ line = f" max context: {p.max_context_tokens:,} tokens"
137
+ if p.memory_bytes is not None:
138
+ line += f" · fits in {format_bytes(p.memory_bytes)}: {p.capacity_tokens:,} tokens"
139
+ print(line)
140
+ entry = explain(p.architecture)
141
+ print(f" lineage: {entry.family} ← {entry.predecessor or '-'}: {entry.change}")
142
+ for note in p.notes:
143
+ print(f" note: {note}")
144
+ return 0
145
+
146
+
147
+ def _cmd_explain(args: argparse.Namespace) -> int:
148
+ from innards.lineage import explain
149
+
150
+ entry = explain(args.target)
151
+ print(json.dumps(entry.to_dict(), indent=2) if args.json else str(entry))
152
+ return 0
153
+
154
+
155
+ # --------------------------------------------------------------------------- view / validate / lineup / sync
156
+
157
+
158
+ def _cmd_view(args: argparse.Namespace) -> int:
159
+ from innards.view import serve
160
+
161
+ serve(root=args.root, host=args.host, port=args.port, open_browser=args.open, block=True)
162
+ return 0
163
+
164
+
165
+ def _cmd_validate(args: argparse.Namespace) -> int:
166
+ from innards.validate import validate
167
+
168
+ report = validate(
169
+ args.model, precision=args.precision, device=args.device, conversation_set=args.set, budget_gb=args.budget_gb
170
+ )
171
+ print(json.dumps(report.to_dict(), indent=2) if args.json else str(report))
172
+ return 0 if report.ok else 1
173
+
174
+
175
+ def _cmd_lineup(args: argparse.Namespace) -> int:
176
+ from innards.lineage.lineup import lineup, what_changed
177
+
178
+ data = lineup()
179
+ if args.json:
180
+ print(json.dumps(data.to_dict(), indent=2))
181
+ return 0
182
+ for pair in data.pairs:
183
+ changed = what_changed(pair.newer, pair.previous)
184
+ added = ", ".join(a["name"] for a in changed["added"]) or "-"
185
+ print(f"{pair.feature} [{pair.where}]")
186
+ print(f" {pair.previous} → {pair.newer}")
187
+ print(f" adds: {added}")
188
+ for newer, previous in pair.alternates:
189
+ print(f" or: {previous} → {newer}")
190
+ return 0
191
+
192
+
193
+ def _cmd_sync(args: argparse.Namespace) -> int:
194
+ from innards.store import RunStore
195
+ from innards.sync import sync_down, sync_up
196
+
197
+ if args.direction == "up":
198
+ result = sync_up(RunStore(args.root), args.run, args.url)
199
+ else:
200
+ result = sync_down(args.url, RunStore(args.root).root)
201
+ print(result)
202
+ return 0
203
+
204
+
205
+ def _pct(value: float | None) -> str:
206
+ return f"{value * 100:5.1f}%" if value is not None else " n/a "
207
+
208
+
209
+ def _cmd_experts(args: argparse.Namespace) -> int:
210
+ from pathlib import Path
211
+
212
+ from innards.experts import TokenRouting, load, traces
213
+ from innards.store import RunStore
214
+
215
+ path = Path(args.path) if args.path else RunStore().root
216
+ if args.topic is None and args.token is None:
217
+ found: list[dict[str, Any]]
218
+ if (path / "summary.json").is_file():
219
+ found = [{"run": None, "name": str(path), **_brief(load(path))}]
220
+ elif (path / "experts").is_dir():
221
+ found = [t for t in traces(path.parent) if t["run"] == path.name]
222
+ else:
223
+ found = traces(path)
224
+ if args.json:
225
+ print(json.dumps(found, indent=2))
226
+ return 0
227
+ if not found:
228
+ print(f"No expert traces under {path}")
229
+ for t in found:
230
+ where = f"{t['run']}/experts/{t['name']}" if t.get("run") else t["name"]
231
+ print(f"{where} · {t['model']} · {t['layers']} MoE layers")
232
+ for topic, count in t["tokens"].items():
233
+ print(f" {topic}: {count:,} tokens")
234
+ return 0
235
+ experts = load(path)
236
+ layers = args.layer or experts.layers
237
+ if args.token is not None:
238
+ folder = experts.folder
239
+ if folder is None:
240
+ raise ValueError("--token reads one trace folder's token shards; pass that folder")
241
+ where = TokenRouting(folder).token(args.token, topic=args.topic)
242
+ if args.json:
243
+ print(json.dumps(where, indent=2))
244
+ return 0
245
+ print(f"{args.token!r}: {where['occurrences']:,} occurrences (measured; ids {where['ids']})")
246
+ for layer, row in zip(where["layers"], where["counts"], strict=True):
247
+ if layer in layers:
248
+ total = where["occurrences"] or 1
249
+ ranked = sorted(range(len(row)), key=lambda e: -row[e])[: args.top]
250
+ print(f" layer {layer:>3}: " + " ".join(f"e{e} {row[e] / total:.0%}" for e in ranked if row[e]))
251
+ return 0
252
+ topic = args.topic
253
+ if args.select is not None:
254
+ chosen = experts.select(topic, coverage=args.select, by="gate" if args.by == "gate" else "selection")
255
+ if args.out:
256
+ chosen.save(args.out)
257
+ print(json.dumps(chosen.to_dict(), indent=2) if args.json else chosen)
258
+ if args.out and not args.json:
259
+ print(f"(written to {args.out})")
260
+ return 0
261
+ rows = {layer: experts.top(topic, layer, n=args.top, by=args.by) for layer in layers}
262
+ if args.json:
263
+ print(json.dumps({str(k): v for k, v in rows.items()}, indent=2))
264
+ return 0
265
+ print(f"{experts.model} · {topic}: {experts.tokens(topic):,} tokens measured · ranked by {args.by} (derived)")
266
+ print(" selection = share of the topic's tokens whose top-k included the expert; lift = vs the other topics")
267
+ for layer, top in rows.items():
268
+ cells = " ".join(
269
+ f"e{r['expert']} {_pct(r['selection']).strip()}"
270
+ + (f" ×{r['lift']:.1f}" if r["lift"] is not None and args.by == "lift" else "")
271
+ for r in top
272
+ )
273
+ print(f" layer {layer:>3}: {cells}")
274
+ return 0
275
+
276
+
277
+ def _brief(experts: object) -> dict[str, Any]:
278
+ from innards.experts import ExpertMap
279
+
280
+ assert isinstance(experts, ExpertMap)
281
+ return {
282
+ "model": experts.model,
283
+ "layers": len(experts.layers),
284
+ "tokens": {t: experts.tokens(t) for t in experts.topics},
285
+ }
286
+
287
+
288
+ def main(argv: Sequence[str] | None = None) -> int:
289
+ args = _build_parser().parse_args(argv)
290
+ commands = {
291
+ "predict": _cmd_predict,
292
+ "explain": _cmd_explain,
293
+ "view": _cmd_view,
294
+ "validate": _cmd_validate,
295
+ "lineup": _cmd_lineup,
296
+ "sync": _cmd_sync,
297
+ "experts": _cmd_experts,
298
+ }
299
+ try:
300
+ return commands[args.command](args)
301
+ except (ValueError, KeyError, FileNotFoundError, ImportError) as exc:
302
+ message = exc.args[0] if isinstance(exc, KeyError) and exc.args else exc
303
+ print(f"innards: error: {message}", file=sys.stderr)
304
+ return 2
305
+
306
+
307
+ if __name__ == "__main__": # pragma: no cover
308
+ sys.exit(main())
@@ -0,0 +1,6 @@
1
+ """Collectors turn raw engine facts into TurnRecord fields. They run in observer workers."""
2
+
3
+ from innards.collectors.memory import rss_sampler, split_memory, tensor_bytes, weights_bytes
4
+ from innards.collectors.timing import timing_from
5
+
6
+ __all__ = ["rss_sampler", "split_memory", "tensor_bytes", "timing_from", "weights_bytes"]
@@ -0,0 +1,85 @@
1
+ """Memory collectors: process RSS (psutil) and the weights / KV / activations split."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from innards.schema import MemoryInfo
10
+
11
+ __all__ = ["rss_sampler", "split_memory", "tensor_bytes", "weights_bytes"]
12
+
13
+ log = logging.getLogger("innards.collectors")
14
+
15
+
16
+ def rss_sampler() -> Callable[[], int | None] | None:
17
+ """Return a callable reading this process's RSS, or ``None`` if psutil is not installed."""
18
+ try:
19
+ import psutil
20
+ except ImportError:
21
+ log.debug("psutil not installed; CPU memory will be reported as null")
22
+ return None
23
+ process = psutil.Process()
24
+
25
+ def sample() -> int | None:
26
+ return int(process.memory_info().rss)
27
+
28
+ return sample
29
+
30
+
31
+ def tensor_bytes(tensor: Any) -> int:
32
+ """Bytes a tensor really holds on its device (metadata only: no values are read).
33
+
34
+ Quantized weights can be tensor subclasses that report their logical dtype, so
35
+ ``numel * element_size`` would count them at the unpacked size. Their inner tensors (packed data
36
+ and scales) are counted instead.
37
+ """
38
+ if hasattr(type(tensor), "__tensor_flatten__"): # plain tensors and Parameters do not have it
39
+ try:
40
+ names, _ctx = tensor.__tensor_flatten__()
41
+ return sum(tensor_bytes(getattr(tensor, name)) for name in names)
42
+ except Exception:
43
+ log.debug("could not unwrap %s; counting its logical size", type(tensor).__name__, exc_info=True)
44
+ return int(tensor.numel() * tensor.element_size())
45
+
46
+
47
+ def weights_bytes(model: Any) -> int:
48
+ """Bytes of a model's parameters and buffers, each tensor counted once."""
49
+ seen: set[int] = set()
50
+ total = 0
51
+ for tensor in [*model.parameters(), *model.buffers()]:
52
+ if id(tensor) in seen:
53
+ continue
54
+ seen.add(id(tensor))
55
+ total += tensor_bytes(tensor)
56
+ return total
57
+
58
+
59
+ def split_memory(
60
+ *,
61
+ weights_bytes: int | None,
62
+ kv_bytes: int | None,
63
+ peak_bytes: int | None,
64
+ source: str | None,
65
+ allocator_only: bool,
66
+ allocated_bytes: int | None = None,
67
+ budget_bytes: int | None = None,
68
+ ) -> MemoryInfo:
69
+ """Build MemoryInfo. ``activations = peak - weights - kv`` only when the peak counts tensors alone.
70
+
71
+ Process RSS also counts the Python runtime, libraries and allocator caches, so on
72
+ CPU the activation share is left ``None`` rather than guessed.
73
+ """
74
+ activations: int | None = None
75
+ if allocator_only and peak_bytes is not None and weights_bytes is not None and kv_bytes is not None:
76
+ activations = max(peak_bytes - weights_bytes - kv_bytes, 0)
77
+ return MemoryInfo(
78
+ weights_bytes=weights_bytes,
79
+ kv_bytes=kv_bytes,
80
+ activations_bytes=activations,
81
+ peak_bytes=peak_bytes,
82
+ allocated_bytes=allocated_bytes,
83
+ budget_bytes=budget_bytes,
84
+ source=source,
85
+ )
@@ -0,0 +1,32 @@
1
+ """Timing from streamer timestamps (``time.perf_counter_ns``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from innards.observer.events import TurnEnd
6
+ from innards.schema import TimingInfo
7
+
8
+ __all__ = ["timing_from"]
9
+
10
+
11
+ def _ms(start: int | None, end: int | None) -> float | None:
12
+ if start is None or end is None:
13
+ return None
14
+ return round((end - start) / 1e6, 3)
15
+
16
+
17
+ def timing_from(t_request_ns: int | None, end: TurnEnd) -> TimingInfo:
18
+ """TTFT and total time are measured from the request; prefill from the start of ``generate``.
19
+
20
+ Decode speed counts tokens after the first one, over the time between the first
21
+ and last token, so it excludes prefill.
22
+ """
23
+ decode: float | None = None
24
+ first, last = end.t_first_token_ns, end.t_last_token_ns
25
+ if first is not None and last is not None and last > first and end.generated_tokens > 1:
26
+ decode = round((end.generated_tokens - 1) / ((last - first) / 1e9), 3)
27
+ return TimingInfo(
28
+ ttft_ms=_ms(t_request_ns, first),
29
+ prefill_ms=_ms(end.t_gen_start_ns, first),
30
+ decode_tokens_per_s=decode,
31
+ total_ms=_ms(t_request_ns, end.t_end_ns),
32
+ )