tinyjev 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.
tinyjev/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """tinyjev: run tiny Jev-style decision models locally, on any machine.
2
+
3
+ import tinyjev
4
+ agent = tinyjev.load("AnkitAI/tinyjev-kev-0.6b") # MLX on Apple Silicon, torch elsewhere
5
+ agent.predict({"state": "...", "questions": {...}}) # System One request shape
6
+ agent.predict({"states": [...]}) # NanoJev's native shape
7
+ """
8
+ from .agent import Agent, load, normalize_request
9
+ from .convert import convert
10
+ from .registry import MODELS
11
+
12
+ __all__ = ["Agent", "load", "convert", "normalize_request", "MODELS", "__version__"]
13
+ __version__ = "0.1.0"
tinyjev/agent.py ADDED
@@ -0,0 +1,172 @@
1
+ """One loaded model: a family (prompt + head) on a backend (MLX or torch)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from . import backends, families
10
+ from .families import softmax
11
+
12
+ SCHEMA_VERSION = "tinyjev-v1"
13
+ TYPE_ALIASES = {"noul": "boolean", "boolean": "boolean", "choice": "choice", "score": "score"}
14
+
15
+
16
+ def _resolve(model_path, subfolder: Optional[str] = None) -> Path:
17
+ from .registry import resolve
18
+ repo, sub = resolve(str(model_path))
19
+ sub = subfolder or sub
20
+ p = Path(repo).expanduser()
21
+ if p.exists():
22
+ root = p.resolve()
23
+ else:
24
+ from huggingface_hub import snapshot_download
25
+ patterns = [f"{sub}/*"] if sub else ["*"]
26
+ root = Path(snapshot_download(repo, allow_patterns=patterns))
27
+ return root / sub if sub else root
28
+
29
+
30
+ def normalize_request(payload: Dict[str, Any]) -> List[dict]:
31
+ """Accept NanoJev's {"states": [...]} or a System One {"state", "questions"} body.
32
+
33
+ Returns records: {"id", "state", "questions": [{"id","type","instructions","criteria"?}]}
34
+ with `noul` folded into `boolean`."""
35
+ if not isinstance(payload, dict):
36
+ raise ValueError("request must be a JSON object")
37
+ if "states" in payload:
38
+ if set(payload) != {"states"} or not isinstance(payload["states"], list) or not payload["states"]:
39
+ raise ValueError('native request must be exactly {"states": [...]} with at least one state')
40
+ raw = payload["states"]
41
+ elif "state" in payload and "questions" in payload:
42
+ raw = [{"id": payload.get("id") or "request", "state": payload["state"],
43
+ "questions": payload["questions"]}]
44
+ else:
45
+ raise ValueError('request needs either {"states": [...]} or {"state", "questions"}')
46
+
47
+ records, seen = [], set()
48
+ for s in raw:
49
+ if not isinstance(s, dict) or not {"id", "state", "questions"} <= set(s):
50
+ raise ValueError("each state needs id, state and questions")
51
+ sid = s["id"]
52
+ if not isinstance(sid, str) or not sid.strip() or sid in seen:
53
+ raise ValueError("state id must be a unique non-empty string")
54
+ seen.add(sid)
55
+ qs = s["questions"]
56
+ if not isinstance(qs, dict) or not qs:
57
+ raise ValueError(f"{sid}: questions must be a non-empty object")
58
+ out = []
59
+ for qid, q in qs.items():
60
+ if not isinstance(qid, str) or not qid.strip() or not isinstance(q, dict):
61
+ raise ValueError(f"{sid}: question ids must be non-empty strings mapping to objects")
62
+ typ = TYPE_ALIASES.get(q.get("type"))
63
+ if typ is None:
64
+ raise ValueError(f"{sid}:{qid}: unsupported question type {q.get('type')!r}")
65
+ item = {"id": qid, "type": typ, "instructions": q.get("instructions")}
66
+ if "criteria" in q and q["criteria"] is not None:
67
+ item["criteria"] = q["criteria"]
68
+ out.append(item)
69
+ records.append({"id": sid, "state": s["state"], "questions": out})
70
+ return records
71
+
72
+
73
+ class Agent:
74
+ def __init__(self, model_path, backend: Optional[str] = None, device: Optional[str] = None,
75
+ subfolder: Optional[str] = None, quantize: int = 0):
76
+ root = _resolve(model_path, subfolder)
77
+ manifest_path = root / "tinyjev.json"
78
+ if not manifest_path.exists():
79
+ raise FileNotFoundError(f"{root} is not a tinyjev checkpoint (no tinyjev.json); "
80
+ f"run `tinyjev convert` first")
81
+ self.manifest = json.loads(manifest_path.read_text())
82
+ if self.manifest.get("format") != "tinyjev-v2":
83
+ raise ValueError(f"{root}: layout {self.manifest.get('format')!r} is not tinyjev-v2; re-run `tinyjev convert`")
84
+ self.manifest["backbone_config"] = json.loads((root / "config.json").read_text())
85
+ self.root = root
86
+ self.family = families.make(self.manifest["family"], root, self.manifest)
87
+ name = backend or backends.default_backend()
88
+ kw = {"device": device} if (device and name == "torch") else {}
89
+ if name == "torch":
90
+ if quantize:
91
+ raise ValueError("quantize is only implemented on the mlx backend for now")
92
+ from .backends.torch_backend import Qwen3Backbone
93
+ self.backbone = Qwen3Backbone(self.manifest["backbone_config"], str(root / "model.safetensors"), **kw)
94
+ else:
95
+ self.backbone = backends.make(name, self.manifest["backbone_config"], str(root / "model.safetensors"),
96
+ quantize=quantize)
97
+ self.backend = self.backbone.name
98
+ self.quantize = int(quantize)
99
+
100
+ @property
101
+ def name(self) -> str:
102
+ return self.manifest.get("name", self.manifest["family"])
103
+
104
+ def _run(self, records: List[dict], temperature: float):
105
+ results, paths, elapsed = {}, 0, 0.0
106
+ for rec in records:
107
+ enc = self.family.encode(rec)
108
+ t = time.perf_counter()
109
+ hs = self.backbone.hidden_rows(enc.prefix, enc.rows, self.family.pad_token_id)
110
+ logits = self.family.logits(hs, enc)
111
+ elapsed += time.perf_counter() - t
112
+ paths += len(enc.rows)
113
+ for q in enc.questions:
114
+ z = logits[q["id"]]
115
+ probs = softmax(z / temperature).tolist()
116
+ results[f"{rec['id']}:{q['id']}"] = {
117
+ "state_id": rec["id"], "qid": q["id"], "type": q["type"], "keys": q["keys"],
118
+ "logits": [float(v) for v in z], "probabilities": probs,
119
+ "answer": self.family.answer(q, probs),
120
+ "path_token_counts": [len(enc.prefix) + len(enc.rows[i]) for i in
121
+ (q["rows"] if "rows" in q else [q["row"]])]}
122
+ return results, paths, elapsed * 1000.0
123
+
124
+ def logits(self, payload: Dict[str, Any]) -> Dict[str, dict]:
125
+ """Raw per-candidate logits/probabilities keyed by `<state id>:<question id>`."""
126
+ results, _, _ = self._run(normalize_request(payload), 1.0)
127
+ return {k: {kk: v[kk] for kk in ("type", "logits", "probabilities", "answer", "path_token_counts")}
128
+ | {"candidate_ids": v["keys"]} for k, v in results.items()}
129
+
130
+ def predict(self, payload: Dict[str, Any], temperature: float = 1.0) -> Dict[str, Any]:
131
+ if not isinstance(temperature, (int, float)) or isinstance(temperature, bool) \
132
+ or temperature <= 0 or temperature != temperature:
133
+ raise ValueError("temperature must be a finite positive number")
134
+ records = normalize_request(payload)
135
+ results, paths, ms = self._run(records, float(temperature))
136
+ states = {r["id"]: {"id": r["id"], "answers": {}} for r in records}
137
+ for v in results.values():
138
+ states[v["state_id"]]["answers"][v["qid"]] = v["answer"]
139
+ return {
140
+ "schema_version": SCHEMA_VERSION,
141
+ "model": {"name": self.name, "family": self.manifest["family"], "backend": self.backend,
142
+ "quantize_bits": self.quantize or None,
143
+ "directory": str(self.root), "upstream": self.manifest.get("upstream", {})},
144
+ "temperature": {"value": float(temperature)},
145
+ "execution": {"states": len(records), "questions": len(results), "candidate_paths": paths,
146
+ "autoregressive_decode_steps": 0, "model_ms": round(ms, 2)},
147
+ "states": list(states.values()),
148
+ }
149
+
150
+ def systemone(self, body: Dict[str, Any]) -> Dict[str, Any]:
151
+ """TypeSafe System One response for a System One request."""
152
+ res = self.predict(body)
153
+ answers = {}
154
+ for qid, a in res["states"][0]["answers"].items():
155
+ if a["type"] in ("boolean", "noul"):
156
+ answers[qid] = {"type": "noul", "noul": round(float(a["p_true"]), 4)}
157
+ elif a["type"] == "choice":
158
+ answers[qid] = {"type": "choice", "choice": a["choice"],
159
+ "confidence": a.get("confidence"), "probabilities": a["probabilities"]}
160
+ else:
161
+ answers[qid] = {"type": "score", "score": round(float(a["score"]), 4),
162
+ "legend": a.get("legend"), "probabilities": a["probabilities"],
163
+ "confidence": a.get("confidence")}
164
+ return {"model": body.get("model") or self.name, "answers": answers,
165
+ "latency_ms": res["execution"]["model_ms"]}
166
+
167
+
168
+ def load(model_path, backend: Optional[str] = None, device: Optional[str] = None,
169
+ subfolder: Optional[str] = None, quantize: int = 0) -> Agent:
170
+ """`load("kev-0.6b")` (alias), `load("/path/to/dir")`, or `load("org/repo", subfolder="name")`.
171
+ quantize=8 or 4 quantizes the backbone's Linear layers at load time (mlx backend)."""
172
+ return Agent(model_path, backend=backend, device=device, subfolder=subfolder, quantize=quantize)
@@ -0,0 +1,46 @@
1
+ """Backbone backends. Each exposes `Qwen3Backbone(config, weights_path)` with
2
+
3
+ hidden_rows(prefix: list[int], suffixes: list[list[int]], pad_token: int) -> list[np.ndarray]
4
+
5
+ returning the fp32 hidden states [len(prefix)+len(suffix_i), d] of every row prefix+suffix_i.
6
+ Attention is causal, so a shared prefix can be run once and its KV broadcast to every suffix.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import platform
11
+
12
+
13
+ def available() -> list[str]:
14
+ out = []
15
+ try:
16
+ import mlx.core # noqa: F401
17
+ out.append("mlx")
18
+ except Exception:
19
+ pass
20
+ try:
21
+ import torch # noqa: F401
22
+ out.append("torch")
23
+ except Exception:
24
+ pass
25
+ return out
26
+
27
+
28
+ def default_backend() -> str:
29
+ have = available()
30
+ if "mlx" in have and platform.machine() == "arm64" and platform.system() == "Darwin":
31
+ return "mlx"
32
+ if have:
33
+ return have[0]
34
+ raise RuntimeError("install either mlx (Apple Silicon) or torch")
35
+
36
+
37
+ def make(name: str, config: dict, weights_path: str, prefix_min_tokens: int = 96, quantize: int = 0):
38
+ if name == "mlx":
39
+ from .mlx_backend import Qwen3Backbone
40
+ return Qwen3Backbone(config, weights_path, prefix_min_tokens=prefix_min_tokens, quantize=quantize)
41
+ if name == "torch":
42
+ if quantize:
43
+ raise ValueError("quantize is only implemented on the mlx backend for now")
44
+ from .torch_backend import Qwen3Backbone
45
+ return Qwen3Backbone(config, weights_path, prefix_min_tokens=prefix_min_tokens)
46
+ raise ValueError(f"unknown backend {name!r}; choose mlx or torch")
@@ -0,0 +1,74 @@
1
+ """Qwen3 backbone on MLX with shared-prefix KV reuse."""
2
+ from __future__ import annotations
3
+
4
+ from typing import List, Sequence
5
+
6
+ import mlx.core as mx
7
+ import mlx.nn as nn
8
+ import numpy as np
9
+ from mlx_lm.models.qwen3 import ModelArgs, Qwen3Model
10
+
11
+
12
+ def qwen3_args(cfg: dict) -> ModelArgs:
13
+ rope = cfg.get("rope_parameters") or {}
14
+ return ModelArgs(
15
+ model_type="qwen3", hidden_size=cfg["hidden_size"],
16
+ num_hidden_layers=cfg["num_hidden_layers"], intermediate_size=cfg["intermediate_size"],
17
+ num_attention_heads=cfg["num_attention_heads"], rms_norm_eps=cfg["rms_norm_eps"],
18
+ vocab_size=cfg["vocab_size"], num_key_value_heads=cfg["num_key_value_heads"],
19
+ head_dim=cfg["head_dim"], max_position_embeddings=cfg.get("max_position_embeddings", 40960),
20
+ rope_theta=rope.get("rope_theta", cfg.get("rope_theta", 1000000)),
21
+ tie_word_embeddings=cfg.get("tie_word_embeddings", True))
22
+
23
+
24
+ class Qwen3Backbone:
25
+ name = "mlx"
26
+
27
+ def __init__(self, config: dict, weights_path: str, prefix_min_tokens: int = 96,
28
+ quantize: int = 0, group_size: int = 64):
29
+ self.model = Qwen3Model(qwen3_args(config))
30
+ weights = mx.load(weights_path) # model.safetensors: standard Qwen3Model keys
31
+ self.model.load_weights(list(weights.items()))
32
+ if quantize:
33
+ # Backbone Linear layers only (the decision head runs fp32 in numpy). Embeddings stay
34
+ # unquantized: they are gathered, not multiplied, and small models lose most at 4-bit.
35
+ nn.quantize(self.model, group_size=group_size, bits=int(quantize),
36
+ class_predicate=lambda _p, m: isinstance(m, nn.Linear))
37
+ self.quantize = int(quantize)
38
+ self.model.eval()
39
+ mx.eval(self.model.parameters())
40
+ self.prefix_min_tokens = prefix_min_tokens
41
+
42
+ def _rows_plain(self, rows: Sequence[Sequence[int]], pad: int) -> List[np.ndarray]:
43
+ lengths = [len(r) for r in rows]
44
+ width = max(lengths)
45
+ tokens = mx.array([list(r) + [pad] * (width - len(r)) for r in rows])
46
+ h = self.model(tokens).astype(mx.float32)
47
+ mx.eval(h)
48
+ arr = np.array(h)
49
+ return [arr[i, :n] for i, n in enumerate(lengths)]
50
+
51
+ def _rows_shared(self, prefix: Sequence[int], suffixes: Sequence[Sequence[int]], pad: int):
52
+ from mlx_lm.models.cache import KVCache
53
+ cache = [KVCache() for _ in self.model.layers]
54
+ h_prefix = self.model(mx.array([list(prefix)]), cache=cache).astype(mx.float32)
55
+ k = len(suffixes)
56
+ shared = []
57
+ for c in cache:
58
+ keys, values = c.state
59
+ bc = KVCache()
60
+ bc.keys, bc.values = mx.repeat(keys, k, axis=0), mx.repeat(values, k, axis=0)
61
+ bc.offset = keys.shape[2]
62
+ shared.append(bc)
63
+ lengths = [len(s) for s in suffixes]
64
+ width = max(lengths)
65
+ tokens = mx.array([list(s) + [pad] * (width - len(s)) for s in suffixes])
66
+ h = self.model(tokens, cache=shared).astype(mx.float32)
67
+ mx.eval(h_prefix, h)
68
+ hp, hs = np.array(h_prefix)[0], np.array(h)
69
+ return [np.concatenate([hp, hs[i, :n]], axis=0) for i, n in enumerate(lengths)]
70
+
71
+ def hidden_rows(self, prefix: Sequence[int], suffixes: Sequence[Sequence[int]], pad: int):
72
+ if len(suffixes) >= 2 and len(prefix) >= self.prefix_min_tokens:
73
+ return self._rows_shared(prefix, suffixes, pad)
74
+ return self._rows_plain([list(prefix) + list(s) for s in suffixes], pad)
@@ -0,0 +1,71 @@
1
+ """Qwen3 backbone on PyTorch (CPU, CUDA or MPS) with shared-prefix KV reuse."""
2
+ from __future__ import annotations
3
+
4
+ from typing import List, Sequence
5
+
6
+ import numpy as np
7
+
8
+
9
+ class Qwen3Backbone:
10
+ name = "torch"
11
+
12
+ def __init__(self, config: dict, weights_path: str, prefix_min_tokens: int = 96, device=None):
13
+ """`weights_path` is <root>/model.safetensors; the root is a standard transformers model dir."""
14
+ import torch
15
+ from pathlib import Path
16
+ from transformers import AutoConfig, AutoModel
17
+
18
+ self.torch = torch
19
+ self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else
20
+ "mps" if torch.backends.mps.is_available() else "cpu"))
21
+ root = str(Path(weights_path).parent)
22
+ cfg = AutoConfig.from_pretrained(root)
23
+ # transformers 4.x ignores `rope_parameters`; the converter also writes rope_theta at the top
24
+ # level, but force it here too in case the config came from elsewhere
25
+ rope = config.get("rope_parameters") or {}
26
+ cfg.rope_theta = rope.get("rope_theta", config.get("rope_theta", getattr(cfg, "rope_theta", None)))
27
+ cfg.use_cache = True
28
+ dtype = torch.float16 if self.device.type != "cpu" else torch.float32
29
+ self.model = AutoModel.from_pretrained(root, config=cfg, dtype=dtype, attn_implementation="sdpa")
30
+ self.model.to(self.device).eval()
31
+ self.prefix_min_tokens = prefix_min_tokens
32
+
33
+ def _pad(self, rows, pad):
34
+ torch = self.torch
35
+ lengths = [len(r) for r in rows]
36
+ width = max(lengths)
37
+ ids = torch.full((len(rows), width), pad, dtype=torch.long)
38
+ att = torch.zeros((len(rows), width), dtype=torch.long)
39
+ for i, r in enumerate(rows):
40
+ ids[i, :len(r)] = torch.tensor(r)
41
+ att[i, :len(r)] = 1
42
+ return ids.to(self.device), att.to(self.device), lengths
43
+
44
+ def _rows_plain(self, rows, pad) -> List[np.ndarray]:
45
+ torch = self.torch
46
+ ids, att, lengths = self._pad(rows, pad)
47
+ with torch.inference_mode():
48
+ h = self.model(input_ids=ids, attention_mask=att, use_cache=False).last_hidden_state.float().cpu().numpy()
49
+ return [h[i, :n] for i, n in enumerate(lengths)]
50
+
51
+ def _rows_shared(self, prefix, suffixes, pad):
52
+ torch = self.torch
53
+ from transformers import DynamicCache
54
+ k = len(suffixes)
55
+ with torch.inference_mode():
56
+ p = torch.tensor([list(prefix)], device=self.device)
57
+ out = self.model(input_ids=p, past_key_values=DynamicCache(), use_cache=True)
58
+ hp = out.last_hidden_state[0].float().cpu().numpy()
59
+ cache = out.past_key_values
60
+ cache.reorder_cache(torch.zeros(k, dtype=torch.long, device=self.device))
61
+ ids, att, lengths = self._pad(suffixes, pad)
62
+ full_att = torch.cat([torch.ones((k, len(prefix)), dtype=torch.long, device=self.device), att], 1)
63
+ pos = torch.arange(len(prefix), len(prefix) + ids.shape[1], device=self.device)[None].expand(k, -1)
64
+ h = self.model(input_ids=ids, attention_mask=full_att, position_ids=pos,
65
+ past_key_values=cache, use_cache=True).last_hidden_state.float().cpu().numpy()
66
+ return [np.concatenate([hp, h[i, :n]], axis=0) for i, n in enumerate(lengths)]
67
+
68
+ def hidden_rows(self, prefix, suffixes, pad):
69
+ if len(suffixes) >= 2 and len(prefix) >= self.prefix_min_tokens:
70
+ return self._rows_shared(prefix, suffixes, pad)
71
+ return self._rows_plain([list(prefix) + list(s) for s in suffixes], pad)
tinyjev/cli.py ADDED
@@ -0,0 +1,64 @@
1
+ """tinyjev command line."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+
8
+
9
+ def main(argv=None) -> int:
10
+ parser = argparse.ArgumentParser(prog="tinyjev", description="tiny Jev-style decision models, locally")
11
+ sub = parser.add_subparsers(dest="command", required=True)
12
+
13
+ c = sub.add_parser("convert", help="convert an upstream checkpoint into the tinyjev layout")
14
+ c.add_argument("family", choices=["nanojev", "kev"])
15
+ c.add_argument("dest")
16
+ c.add_argument("--source", help="nanojev: upstream checkpoint dir")
17
+ c.add_argument("--adapter", help="kev: adapter dir (adapter_model.safetensors + head.pt)")
18
+ c.add_argument("--base", help="kev: Qwen3 base model dir")
19
+ c.add_argument("--name", default=None)
20
+ c.add_argument("--dtype", default="float16", choices=["float16", "float32"])
21
+
22
+ def model_args(p):
23
+ p.add_argument("--backend", choices=["mlx", "torch"], default=None)
24
+ p.add_argument("--device", default=None, help="torch backend: cpu, mps or cuda")
25
+ p.add_argument("--quantize", type=int, default=0, choices=[0, 4, 8], help="mlx: quantize backbone Linear layers to 4 or 8 bits at load")
26
+
27
+ s = sub.add_parser("serve", help="serve a checkpoint over HTTP (/predict, /v1/systemone)")
28
+ s.add_argument("model"); s.add_argument("--host", default="127.0.0.1")
29
+ s.add_argument("--port", type=int, default=8077); model_args(s)
30
+
31
+ a = sub.add_parser("ask", help="answer one request (System One or native shape) from a file or stdin")
32
+ a.add_argument("model"); a.add_argument("request", nargs="?", default="-"); model_args(a)
33
+
34
+ ls = sub.add_parser("models", help="list known checkpoints")
35
+
36
+ args = parser.parse_args(argv)
37
+
38
+ if args.command == "models":
39
+ from .registry import MODELS
40
+ for k, v in MODELS.items():
41
+ print(f"{k:<13} {v['params']:<5} {v['family']:<8} {v['repo']:<22} {v['what']}")
42
+ print("\nOther projects' models: convert them locally, e.g.\n"
43
+ " tinyjev convert nanojev <dest> --source <C-Tianyu/NanoJev checkout>\n"
44
+ " tinyjev convert kev <dest> --adapter <kev run or hub dir> --base <Qwen3 base dir>")
45
+ return 0
46
+ if args.command == "convert":
47
+ from .convert import convert
48
+ kw = {"source": args.source, "adapter": args.adapter, "base": args.base, "name": args.name or args.family}
49
+ convert(args.family, args.dest, dtype=args.dtype, **{k: v for k, v in kw.items() if v})
50
+ return 0
51
+
52
+ from . import load
53
+ if args.command == "serve":
54
+ from .serve import serve
55
+ serve(load(args.model, backend=args.backend, device=args.device, quantize=args.quantize), host=args.host, port=args.port)
56
+ return 0
57
+ payload = json.load(sys.stdin) if args.request == "-" else json.load(open(args.request))
58
+ agent = load(args.model, backend=args.backend, device=args.device, quantize=args.quantize)
59
+ print(json.dumps(agent.predict(payload), indent=2, ensure_ascii=False))
60
+ return 0
61
+
62
+
63
+ if __name__ == "__main__":
64
+ raise SystemExit(main())