ai-model-explorer 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.
@@ -0,0 +1,13 @@
1
+ """AI Model Explorer: see any AI model's architecture from its weight manifest, without downloading the weights.
2
+
3
+ from ai_model_explorer import inspect, summary
4
+ a = inspect("Qwen/Qwen3-8B")
5
+ print(summary(a))
6
+
7
+ Method: S. Singkul, "Reading the Architecture off the Weights: Manifest-Only Recovery of Neural Network Anatomy", 2026.
8
+ """
9
+ __version__ = "0.1.0"
10
+
11
+ from .api import inspect, summary # noqa: E402
12
+
13
+ __all__ = ["inspect", "summary", "__version__"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,100 @@
1
+ """Run the explorer's own JavaScript (loaders.js, infer.js) inside QuickJS, so Python gets exactly the website's result.
2
+
3
+ Python does the network I/O: the JS side calls a small fetch() shim that hands each request to Python. Binary bodies
4
+ cross the boundary as latin-1 strings (one char per byte), which QuickJS turns back into a Uint8Array.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from importlib import resources
10
+ from typing import Callable, Optional
11
+
12
+ try:
13
+ import quickjs # type: ignore
14
+ except ImportError as e: # pragma: no cover
15
+ raise ImportError("ai-model-explorer needs the 'quickjs' package: pip install quickjs") from e
16
+
17
+ _PRELUDE = r"""
18
+ var window = undefined;
19
+ function TextDecoder() {}
20
+ TextDecoder.prototype.decode = function (u8) {
21
+ if (!u8) return "";
22
+ var src = u8.buffer && u8.buffer.__s;
23
+ if (src !== undefined && u8.byteOffset === 0 && u8.length === u8.buffer.byteLength) return __pyutf8(src);
24
+ var CH = 0x8000, parts = [];
25
+ for (var i = 0; i < u8.length; i += CH) parts.push(String.fromCharCode.apply(null, u8.subarray(i, i + CH)));
26
+ var s = parts.join("");
27
+ try { return decodeURIComponent(escape(s)); } catch (e) { return s; }
28
+ };
29
+ function __bytes(s) { var u = new Uint8Array(s.length); for (var i = 0; i < s.length; i++) u[i] = s.charCodeAt(i) & 255; return u; }
30
+ function fetch(url, opts) {
31
+ var range = opts && opts.headers && opts.headers.Range ? opts.headers.Range : "";
32
+ var r = JSON.parse(__pyfetch(url, range));
33
+ var body = r.body;
34
+ return Promise.resolve({
35
+ status: r.status, ok: r.status >= 200 && r.status < 300,
36
+ text: function () { return Promise.resolve(new TextDecoder().decode(__bytes(body))); },
37
+ json: function () { return Promise.resolve(JSON.parse(new TextDecoder().decode(__bytes(body)))); },
38
+ arrayBuffer: function () { var b = __bytes(body).buffer; b.__s = body; return Promise.resolve(b); }
39
+ });
40
+ }
41
+ var module = undefined;
42
+ """
43
+
44
+
45
+ def _js(name: str) -> str:
46
+ return resources.files("ai_model_explorer").joinpath("js", name).read_text(encoding="utf-8")
47
+
48
+
49
+ class Engine:
50
+ def __init__(self, fetch: Optional[Callable[[str, str], tuple]] = None):
51
+ self.ctx = quickjs.Context()
52
+ self.ctx.set_max_stack_size(8 * 1024 * 1024)
53
+ self._fetch = fetch
54
+ self.ctx.add_callable("__pyfetch", self._pyfetch)
55
+ self.ctx.add_callable("__pyget", self._pyget)
56
+ self.ctx.add_callable("__pyutf8", lambda s: s.encode("latin-1").decode("utf-8", "replace"))
57
+ self._inbox = {}
58
+ self.ctx.eval(_PRELUDE)
59
+ self.ctx.eval(_js("loaders.js"))
60
+ self.ctx.eval(_js("infer.js"))
61
+
62
+ # ---- host callbacks
63
+ def _pyfetch(self, url, rng):
64
+ if self._fetch is None:
65
+ return json.dumps({"status": 599, "body": ""})
66
+ status, body = self._fetch(url, rng or None)
67
+ return json.dumps({"status": status, "body": body.decode("latin-1")})
68
+
69
+ def _pyget(self, key):
70
+ return self._inbox.pop(key)
71
+
72
+ def _put(self, key, obj) -> str:
73
+ self._inbox[key] = json.dumps(obj)
74
+ return f'JSON.parse(__pyget("{key}"))'
75
+
76
+ def _await(self, expr: str):
77
+ """Evaluate an async JS expression to completion and return its JSON value."""
78
+ self.ctx.eval(
79
+ "var __res = undefined, __err = undefined;"
80
+ f"Promise.resolve({expr}).then(function (v) {{ __res = JSON.stringify(v === undefined ? null : v); }},"
81
+ " function (e) { __err = String(e && e.message || e); });")
82
+ while self.ctx.execute_pending_job():
83
+ pass
84
+ err = self.ctx.eval("__err")
85
+ if err is not None:
86
+ raise RuntimeError(err)
87
+ res = self.ctx.eval("__res")
88
+ if res is None:
89
+ raise RuntimeError("JavaScript promise did not settle")
90
+ return json.loads(res)
91
+
92
+ # ---- API
93
+ def hf_manifest(self, repo: str, files: list, opt: dict):
94
+ return self._await(f'Loaders.hfManifest({json.dumps(repo)}, {self._put("files", files)}, function () {{}}, {self._put("opt", opt)})')
95
+
96
+ def infer(self, tensors: dict, lex: bool = True) -> dict:
97
+ return self._await(f'ArchInfer.infer({self._put("t", tensors)}, {{lex: {"true" if lex else "false"}}})')
98
+
99
+ def fuse(self, anatomy: dict, config: dict):
100
+ return self._await(f'ArchInfer.fuseConfig({self._put("a", anatomy)}, {self._put("c", config)})')
@@ -0,0 +1,50 @@
1
+ """Minimal HTTP for Hugging Face: file list, small JSON files, and byte ranges. Standard library only."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Optional
10
+
11
+ from . import __version__
12
+
13
+ ENDPOINT = os.environ.get("HF_ENDPOINT", "https://huggingface.co").rstrip("/")
14
+
15
+
16
+ class Hub:
17
+ def __init__(self, token: Optional[str] = None, timeout: float = 60.0):
18
+ self.token = token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
19
+ self.timeout = timeout
20
+ self.bytes_read = 0
21
+
22
+ def get(self, url: str, rng: Optional[str] = None):
23
+ """Return (status, body bytes). Never raises for HTTP errors; the caller decides."""
24
+ url = url.replace("https://huggingface.co", ENDPOINT, 1)
25
+ h = {"User-Agent": f"ai-model-explorer/{__version__}"}
26
+ if rng:
27
+ h["Range"] = rng
28
+ if self.token and url.startswith(ENDPOINT):
29
+ h["Authorization"] = f"Bearer {self.token}"
30
+ try:
31
+ with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=self.timeout) as r:
32
+ body = r.read()
33
+ self.bytes_read += len(body)
34
+ return r.status, body
35
+ except urllib.error.HTTPError as e:
36
+ return e.code, e.read() or b""
37
+
38
+ def json(self, url: str):
39
+ st, body = self.get(url)
40
+ if st in (401, 403):
41
+ raise PermissionError("gated or private repository: accept its license on Hugging Face and pass a token (HF_TOKEN)")
42
+ if st == 404:
43
+ m = re.search(r"/api/models/([^?]+)", url)
44
+ raise FileNotFoundError(f"repository not found: {m.group(1)}" if m else f"not found: {url}")
45
+ if st >= 400:
46
+ raise RuntimeError(f"HTTP {st} for {url}")
47
+ return json.loads(body.decode("utf-8"))
48
+
49
+ def model_info(self, repo: str) -> dict:
50
+ return self.json(f"https://huggingface.co/api/models/{repo}?blobs=true")
@@ -0,0 +1,166 @@
1
+ """inspect(): the anatomy of a model from its weight manifest, for a Hugging Face repo or local safetensors files."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import struct
8
+ from pathlib import Path
9
+ from typing import Optional, Union
10
+
11
+ from ._engine import Engine
12
+ from ._hub import Hub
13
+
14
+
15
+ def _local_header(path: Path) -> tuple[dict, int]:
16
+ with open(path, "rb") as f:
17
+ n = struct.unpack("<Q", f.read(8))[0]
18
+ if not 0 < n < 200_000_000:
19
+ raise ValueError(f"{path} is not a safetensors file")
20
+ h = json.loads(f.read(n).decode("utf-8"))
21
+ h.pop("__metadata__", None)
22
+ return h, 8 + n
23
+
24
+
25
+ def _local(path: Path):
26
+ files = [path] if path.is_file() else sorted(p for p in path.rglob("*.safetensors") if not re.search(r"(^|/)(optimizer|adapter[._])", str(p)))
27
+ if not files:
28
+ raise FileNotFoundError(f"no .safetensors file under {path}")
29
+ tensors, read = {}, 0
30
+ base = path if path.is_dir() else path.parent
31
+ for p in files:
32
+ h, b = _local_header(p)
33
+ read += b
34
+ rel = p.relative_to(base).parent.as_posix()
35
+ prefix = "" if rel in ("", ".") else rel.replace("/", ".") + "."
36
+ for k, v in h.items():
37
+ tensors[prefix + k] = {"dtype": v["dtype"], "shape": v["shape"]}
38
+ cfg = None
39
+ c = base / "config.json"
40
+ if c.exists():
41
+ cfg = json.loads(c.read_text(encoding="utf-8"))
42
+ return tensors, cfg, read, len(files), None
43
+
44
+
45
+ def _remote(repo: str, hub: Hub):
46
+ info = hub.model_info(repo)
47
+ files = [{"n": s["rfilename"], "size": s.get("size") or 0} for s in info.get("siblings", [])]
48
+ names = {f["n"] for f in files}
49
+ base = f"https://huggingface.co/{repo}/resolve/main/"
50
+ cfg = mi = None
51
+ if "config.json" in names:
52
+ try:
53
+ cfg = hub.json(base + "config.json")
54
+ except Exception:
55
+ cfg = None
56
+ if "model_index.json" in names:
57
+ try:
58
+ mi = hub.json(base + "model_index.json")
59
+ except Exception:
60
+ mi = None
61
+ opt = {"prefer": (cfg or {}).get("default_size"),
62
+ "only": [k for k, v in (mi or {}).items() if not k.startswith("_") and isinstance(v, list) and v and v[0]] if mi else None}
63
+ eng = Engine(fetch=hub.get)
64
+ mf = eng.hf_manifest(repo, files, opt)
65
+ if not mf:
66
+ raise FileNotFoundError(f"{repo}: no .safetensors or PyTorch checkpoint found")
67
+ weight_bytes = sum(f["size"] for f in files)
68
+ return mf["tensors"], cfg, mf["read"], mf["nfiles"], {"engine": eng, "weight_bytes": weight_bytes,
69
+ "downloads": info.get("downloads"), "hub_params": (info.get("safetensors") or {}).get("total")}
70
+
71
+
72
+ def inspect(model: Union[str, os.PathLike], token: Optional[str] = None, config: Optional[dict] = None,
73
+ include_tensors: bool = False) -> dict:
74
+ """Recover a model's anatomy without downloading its weights.
75
+
76
+ model: a Hugging Face repo id ("Qwen/Qwen3-8B", or a huggingface.co URL), a .safetensors file, or a folder of them.
77
+ token: Hugging Face token for gated or private repos (default: $HF_TOKEN).
78
+ config: a config.json dict to fuse instead of the repo's own.
79
+ Returns a JSON-serializable dict: family, totals, hyperparameters, components with their layer stacks and block
80
+ types, edges between components, quantization, the config check, and how many bytes were read.
81
+ """
82
+ s = str(model)
83
+ if os.path.exists(s):
84
+ tensors, cfg, read, nfiles, extra = _local(Path(s))
85
+ eng = Engine()
86
+ source = {"kind": "local", "path": str(Path(s).resolve())}
87
+ else:
88
+ m = re.search(r"(?:huggingface\.co/)?([\w.-]+/[\w.-]+)", s)
89
+ if not m:
90
+ raise ValueError("expected a Hugging Face repo id (owner/name) or a path to .safetensors")
91
+ repo = re.sub(r"/(tree|blob|resolve)/.*$", "", m.group(1))
92
+ tensors, cfg, read, nfiles, extra = _remote(repo, Hub(token))
93
+ eng = extra.pop("engine")
94
+ source = {"kind": "huggingface", "repo": repo, **{k: v for k, v in extra.items() if v is not None}}
95
+ if config is not None:
96
+ cfg = config
97
+ anatomy = eng.infer(tensors)
98
+ check = eng.fuse(anatomy, cfg) if cfg else None
99
+ out = {
100
+ "source": source,
101
+ "files_read": nfiles,
102
+ "header_bytes": read,
103
+ "tensor_count": len(tensors),
104
+ "family": anatomy.get("family"),
105
+ "totals": anatomy.get("totals"),
106
+ "hyperparameters": anatomy.get("hp"),
107
+ "quantization": anatomy.get("quant"),
108
+ "components": anatomy.get("comps"),
109
+ "edges": anatomy.get("edges"),
110
+ "evidence": anatomy.get("evidence"),
111
+ "config_check": check,
112
+ "has_config": cfg is not None,
113
+ }
114
+ if include_tensors:
115
+ out["tensors"] = anatomy.get("tensors")
116
+ return out
117
+
118
+
119
+ def _fmt_n(n) -> str:
120
+ if n is None:
121
+ return "?"
122
+ for u, k in (("T", 1e12), ("B", 1e9), ("M", 1e6), ("K", 1e3)):
123
+ if n >= k:
124
+ return f"{n / k:.2f}{u}".replace(".00", "")
125
+ return str(n)
126
+
127
+
128
+ def _fmt_b(n) -> str:
129
+ if n is None:
130
+ return "?"
131
+ for u, k in (("TB", 1e12), ("GB", 1e9), ("MB", 1e6), ("KB", 1e3)):
132
+ if n >= k:
133
+ return f"{n / k:.1f} {u}"
134
+ return f"{n} B"
135
+
136
+
137
+ def summary(a: dict) -> str:
138
+ """A short human-readable report of an inspect() result."""
139
+ src = a["source"]
140
+ name = src.get("repo") or src.get("path")
141
+ t = a.get("totals") or {}
142
+ hp = a.get("hyperparameters") or {}
143
+ lines = [f"{name}",
144
+ f" family {a.get('family')} · {_fmt_n(t.get('params'))} parameters · {_fmt_b(t.get('bytes'))} of weights"
145
+ + (f" · stored as {', '.join(a['quantization'])}" if a.get("quantization") else ""),
146
+ f" read {_fmt_b(a.get('header_bytes'))} from {a.get('files_read')} file(s), {a.get('tensor_count')} tensors"]
147
+ keys = [("d", "width"), ("layers", "layers"), ("heads", "heads"), ("kv_heads", "kv heads"), ("head_dim", "head dim"),
148
+ ("ffn", "ffn"), ("experts", "experts"), ("vocab", "vocab")]
149
+ hps = " · ".join(f"{lab} {hp[k]}" for k, lab in keys if hp.get(k))
150
+ if hps:
151
+ lines.append(" " + hps)
152
+ lines.append(" components:")
153
+ for c in a.get("components") or []:
154
+ role = c.get("role") or ""
155
+ head = f" {c.get('id')}" + (f" ({role})" if role else "") + (f" · d={c['d']}" if c.get("d") else "")
156
+ lines.append(head)
157
+ for st in c.get("stacks") or []:
158
+ types = ", ".join(f"{ty.get('label')}" + (f" ×{ty['n']}" if ty.get("n") and len(st.get("types", [])) > 1 else "") for ty in st.get("types") or [])
159
+ lines.append(f" {st.get('path')}: {st.get('n')} blocks · {types}")
160
+ for e in a.get("edges") or []:
161
+ lines.append(f" {e.get('from')} → {e.get('to')}" + (f" (via {e['via']})" if e.get("via") else ""))
162
+ chk = a.get("config_check")
163
+ if chk:
164
+ bad = [k for k, v in chk.items() if isinstance(v, dict) and v.get("checked") and v.get("consistent") is False]
165
+ lines.append(" config.json: " + ("agrees with the weights" if not bad else "disagrees with the weights for " + ", ".join(bad)))
166
+ return "\n".join(lines)
@@ -0,0 +1,75 @@
1
+ """Command line: `ai-model-explorer open [REPO]` starts the web app locally; `ai-model-explorer inspect REPO` prints the anatomy."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import functools
6
+ import http.server
7
+ import json
8
+ import socketserver
9
+ import sys
10
+ import threading
11
+ import urllib.parse
12
+ import webbrowser
13
+ from importlib import resources
14
+
15
+ from . import __version__
16
+
17
+
18
+ def _serve(repo: str | None, port: int, lang: str, no_browser: bool) -> None:
19
+ web = resources.files("ai_model_explorer").joinpath("web")
20
+ with resources.as_file(web) as root:
21
+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(root))
22
+ handler.log_message = lambda *a, **k: None
23
+ with socketserver.TCPServer(("127.0.0.1", port), handler) as httpd:
24
+ url = f"http://127.0.0.1:{httpd.server_address[1]}/" + ("en/" if lang == "en" else "")
25
+ if repo:
26
+ url += "#hf=" + urllib.parse.quote(repo, safe="/")
27
+ print(f"AI Model Explorer {__version__} running at {url} (Ctrl+C to stop)")
28
+ if not no_browser:
29
+ threading.Timer(0.4, lambda: webbrowser.open(url)).start()
30
+ try:
31
+ httpd.serve_forever()
32
+ except KeyboardInterrupt:
33
+ print()
34
+
35
+
36
+ def main(argv=None) -> int:
37
+ p = argparse.ArgumentParser(prog="ai-model-explorer",
38
+ description="See any AI model's architecture from its safetensors header, without downloading weights.")
39
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
40
+ sub = p.add_subparsers(dest="cmd")
41
+
42
+ o = sub.add_parser("open", help="open the web app in your browser (runs locally, works offline for files)")
43
+ o.add_argument("repo", nargs="?", help="Hugging Face repo to load right away, e.g. Qwen/Qwen3-8B")
44
+ o.add_argument("--port", type=int, default=0, help="port (default: any free port)")
45
+ o.add_argument("--lang", choices=["th", "en"], default="en")
46
+ o.add_argument("--no-browser", action="store_true", help="only print the URL")
47
+
48
+ i = sub.add_parser("inspect", help="print the anatomy of a Hugging Face repo or local .safetensors")
49
+ i.add_argument("model", help="repo id (owner/name), huggingface.co URL, .safetensors file or folder")
50
+ i.add_argument("--json", action="store_true", help="print the full result as JSON")
51
+ i.add_argument("--tensors", action="store_true", help="with --json: include the role of every tensor")
52
+ i.add_argument("--token", help="Hugging Face token for gated/private repos (default: $HF_TOKEN)")
53
+ i.add_argument("--config", help="path to a config.json to use instead of the repo's own")
54
+
55
+ a = p.parse_args(argv)
56
+ if a.cmd in (None, "open"):
57
+ _serve(getattr(a, "repo", None), getattr(a, "port", 0), getattr(a, "lang", "en"), getattr(a, "no_browser", False))
58
+ return 0
59
+
60
+ from .api import inspect, summary
61
+ cfg = None
62
+ if a.config:
63
+ with open(a.config, encoding="utf-8") as f:
64
+ cfg = json.load(f)
65
+ try:
66
+ r = inspect(a.model, token=a.token, config=cfg, include_tensors=a.tensors)
67
+ except (PermissionError, FileNotFoundError, ValueError, RuntimeError) as e:
68
+ print(f"error: {e}", file=sys.stderr)
69
+ return 1
70
+ print(json.dumps(r, indent=1, ensure_ascii=False) if a.json else summary(r))
71
+ return 0
72
+
73
+
74
+ if __name__ == "__main__":
75
+ sys.exit(main())