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/convert.py ADDED
@@ -0,0 +1,243 @@
1
+ """Convert upstream checkpoints into the tinyjev layout (v2, transformers-compatible).
2
+
3
+ <dest>/config.json standard Qwen3Model config (AutoModel.from_pretrained loads the backbone)
4
+ <dest>/model.safetensors backbone with standard Qwen3Model keys (fp16 by default)
5
+ <dest>/head.safetensors the decision head (fp32), keys without prefix
6
+ <dest>/tinyjev.json family, head config, tokenizer ids, upstream provenance
7
+ <dest>/tokenizer.json ... tokenizer files at the root, repaired for current loaders
8
+
9
+ Both backends load exactly this. Conversion streams tensor by tensor so a 2.4 GB fp32
10
+ checkpoint converts on a 16 GB machine.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import shutil
16
+ from pathlib import Path
17
+ from typing import Dict
18
+
19
+ import numpy as np
20
+
21
+ NANOJEV_HEAD_PREFIXES = ("norm.", "scalar.", "set_project.", "set_attention.", "set_output.")
22
+
23
+
24
+ def _save(dest: Path, weights: Dict[str, np.ndarray]):
25
+ """Split into model.safetensors (backbone, standard keys) and head.safetensors (head, unprefixed keys)."""
26
+ from safetensors.numpy import save_file
27
+ backbone = {k[len("backbone."):]: v for k, v in weights.items() if k.startswith("backbone.")}
28
+ head = {k[len("head."):]: v for k, v in weights.items() if k.startswith("head.")}
29
+ save_file(backbone, str(dest / "model.safetensors"), metadata={"format": "pt"})
30
+ save_file(head, str(dest / "head.safetensors"), metadata={"format": "pt"})
31
+
32
+
33
+ def _write_backbone_config(dest: Path, backbone_cfg: dict, dtype: str):
34
+ """A config.json transformers 4.x and 5.x both read correctly: rope base at the top level AND under
35
+ rope_parameters (4.x ignores the latter and would silently default to 10000 — the NanoJev bug)."""
36
+ cfg = dict(backbone_cfg)
37
+ rope = cfg.get("rope_parameters") or {}
38
+ theta = rope.get("rope_theta", cfg.get("rope_theta"))
39
+ if theta is not None:
40
+ cfg["rope_theta"] = theta
41
+ cfg["rope_parameters"] = {**rope, "rope_theta": theta, "rope_type": rope.get("rope_type", "default")}
42
+ cfg["architectures"] = ["Qwen3Model"]
43
+ cfg["model_type"] = cfg.get("model_type", "qwen3")
44
+ cfg["dtype"] = dtype
45
+ cfg["torch_dtype"] = dtype
46
+ cfg.pop("transformers_version", None)
47
+ (dest / "config.json").write_text(json.dumps(cfg, indent=2))
48
+
49
+
50
+ def _cast(arr: np.ndarray, dtype: str) -> np.ndarray:
51
+ return arr.astype(np.float16 if dtype == "float16" else np.float32)
52
+
53
+
54
+ def normalize_tokenizer_config(cfg: dict):
55
+ """Repair fields newer loaders refuse: NanoJev stores extra_special_tokens as a list."""
56
+ changed = False
57
+ extra = cfg.get("extra_special_tokens")
58
+ if isinstance(extra, list):
59
+ cfg["extra_special_tokens"] = {f"extra_{i}": t for i, t in enumerate(extra)}
60
+ changed = True
61
+ if cfg.get("tokenizer_class") in (None, "TokenizersBackend"):
62
+ cfg["tokenizer_class"] = "PreTrainedTokenizerFast"
63
+ cfg.pop("backend", None)
64
+ cfg.pop("is_local", None)
65
+ changed = True
66
+ return cfg, changed
67
+
68
+
69
+ def _copy_tokenizer(src_dir: Path, dest: Path):
70
+ tok = dest
71
+ tok.mkdir(parents=True, exist_ok=True)
72
+ for name in ("tokenizer.json", "tokenizer_config.json", "special_tokens_map.json",
73
+ "added_tokens.json", "vocab.json", "merges.txt", "chat_template.jinja"):
74
+ if (src_dir / name).exists():
75
+ shutil.copy2(src_dir / name, tok / name)
76
+ cfg_path = tok / "tokenizer_config.json"
77
+ if cfg_path.exists():
78
+ cfg, changed = normalize_tokenizer_config(json.loads(cfg_path.read_text()))
79
+ if changed:
80
+ cfg_path.write_text(json.dumps(cfg, indent=2))
81
+ return tok
82
+
83
+
84
+ def _eos_pad_from_tokenizer(tok_dir: Path, backbone_cfg: dict):
85
+ from tokenizers import Tokenizer
86
+ t = Tokenizer.from_file(str(tok_dir / "tokenizer.json"))
87
+ eos = backbone_cfg.get("eos_token_id")
88
+ cfg_path = tok_dir / "tokenizer_config.json"
89
+ if cfg_path.exists():
90
+ cfg = json.loads(cfg_path.read_text())
91
+ for key in ("eos_token", "pad_token"):
92
+ v = cfg.get(key)
93
+ if isinstance(v, dict):
94
+ cfg[key] = v.get("content")
95
+ if isinstance(cfg.get("eos_token"), str) and t.token_to_id(cfg["eos_token"]) is not None:
96
+ eos = t.token_to_id(cfg["eos_token"])
97
+ pad = t.token_to_id(cfg["pad_token"]) if isinstance(cfg.get("pad_token"), str) else None
98
+ else:
99
+ pad = None
100
+ return int(eos), int(pad if pad is not None else eos)
101
+
102
+
103
+ # ---------------------------------------------------------------- NanoJev
104
+ def convert_nanojev(source, dest, dtype: str = "float16") -> Path:
105
+ from safetensors import safe_open
106
+ src, dst = Path(source).expanduser().resolve(strict=True), Path(dest).expanduser()
107
+ for rel in ("config.json", "backbone_config/config.json", "tokenizer/tokenizer.json", "best.safetensors"):
108
+ if not (src / rel).exists():
109
+ raise FileNotFoundError(f"NanoJev checkpoint is missing {rel}")
110
+ dst.mkdir(parents=True, exist_ok=True)
111
+ run = json.loads((src / "config.json").read_text())
112
+ backbone_cfg = json.loads((src / "backbone_config/config.json").read_text())
113
+
114
+ weights, n_body, n_head = {}, 0, 0
115
+ with safe_open(str(src / "best.safetensors"), framework="numpy") as f:
116
+ for key in f.keys():
117
+ arr = f.get_tensor(key)
118
+ if key.startswith(NANOJEV_HEAD_PREFIXES):
119
+ weights["head." + key] = _cast(arr, "float32"); n_head += 1
120
+ elif key.startswith("backbone."):
121
+ weights[key] = _cast(arr, dtype); n_body += 1
122
+ else:
123
+ raise ValueError(f"unexpected tensor {key}")
124
+ _save(dst, weights); del weights
125
+ _write_backbone_config(dst, backbone_cfg, dtype)
126
+ tok = _copy_tokenizer(src / "tokenizer", dst)
127
+ eos, pad = _eos_pad_from_tokenizer(tok, backbone_cfg)
128
+ (dst / "tinyjev.json").write_text(json.dumps({
129
+ "format": "tinyjev-v2", "family": "marker", "name": "nanojev",
130
+ "head": {"set_head": run.get("set_head", "attention")},
131
+ "tokenizer": {"eos_token_id": eos, "pad_token_id": eos},
132
+ "max_length": run.get("max_length", 8192),
133
+ "dtypes": {"backbone": dtype, "head": "float32"},
134
+ "upstream": {"repo": "C-Tianyu/NanoJev", "base_model": run.get("model"),
135
+ "base_revision": run.get("resolved_model_revision"),
136
+ "schema": run.get("schema_version")},
137
+ }, indent=2))
138
+ print(f"nanojev: {n_body} backbone tensors -> {dtype}, {n_head} head tensors -> float32\n-> {dst}")
139
+ return dst
140
+
141
+
142
+ # ---------------------------------------------------------------- Kev
143
+ def convert_kev(adapter_dir, base_dir=None, dest=None, dtype: str = "float16", name: str = "kev") -> Path:
144
+ """Convert a Kev run: either a LoRA run (adapter + head.pt, merged into `base_dir`) or a
145
+ full fine-tune run from `kev.train --lora 0` (the run dir holds the whole backbone via
146
+ save_pretrained, plus head.pt; `base_dir` is then ignored)."""
147
+ from safetensors import safe_open
148
+ try:
149
+ import torch
150
+ except ImportError as exc:
151
+ raise ImportError("converting a Kev checkpoint needs torch: pip install 'tinyjev[convert]'") from exc
152
+
153
+ adapter = Path(adapter_dir).expanduser().resolve(strict=True)
154
+ dst = Path(dest).expanduser()
155
+ dst.mkdir(parents=True, exist_ok=True)
156
+ full_ft = not (adapter / "adapter_model.safetensors").exists()
157
+ if full_ft:
158
+ base = adapter # the run dir IS the backbone
159
+ scale, acfg = 0.0, {"r": 0, "lora_alpha": 0}
160
+ else:
161
+ if base_dir is None:
162
+ raise ValueError("a LoRA run needs base_dir (the Qwen3 base the adapter was trained on)")
163
+ base = Path(base_dir).expanduser().resolve(strict=True)
164
+ acfg = json.loads((adapter / "adapter_config.json").read_text())
165
+ if acfg.get("trainable_token_indices"):
166
+ raise ValueError("adapters with trained token embeddings are not supported by this converter")
167
+ scale = float(acfg["lora_alpha"]) / float(acfg["r"])
168
+ if acfg.get("use_rslora"):
169
+ scale = float(acfg["lora_alpha"]) / float(acfg["r"]) ** 0.5
170
+ backbone_cfg = json.loads((base / "config.json").read_text())
171
+
172
+ # LoRA deltas keyed by the base tensor they modify (empty for a full fine-tune)
173
+ deltas: Dict[str, np.ndarray] = {}
174
+ adapter_file = adapter / "adapter_model.safetensors"
175
+ with (safe_open(str(adapter_file), framework="numpy") if not full_ft else _NoTensors()) as f:
176
+ keys = list(f.keys())
177
+ a_keys = [k for k in keys if k.endswith("lora_A.weight")]
178
+ for ka in a_keys:
179
+ kb = ka.replace("lora_A.weight", "lora_B.weight")
180
+ A, B = f.get_tensor(ka).astype(np.float32), f.get_tensor(kb).astype(np.float32)
181
+ # peft key: base_model.model.model.layers.N.self_attn.q_proj.lora_A.weight
182
+ target = ka.split("base_model.model.")[-1].replace(".lora_A.weight", ".weight")
183
+ if target.startswith("model."):
184
+ target = target[len("model."):]
185
+ deltas[target] = (B @ A) * scale
186
+ merged_count = 0
187
+ weights: Dict[str, np.ndarray] = {}
188
+ base_files = sorted(p for p in base.glob("*.safetensors") if p.name != "adapter_model.safetensors")
189
+ if not base_files:
190
+ raise FileNotFoundError(f"no backbone safetensors found in {base}")
191
+ for bf in base_files:
192
+ with safe_open(str(bf), framework="pt") as f: # base is bf16; numpy cannot read it
193
+ for key in f.keys():
194
+ if key.startswith("lm_head."):
195
+ continue # never generates text
196
+ t = f.get_tensor(key).to(torch.float32).numpy()
197
+ short = key[len("model."):] if key.startswith("model.") else key
198
+ if short in deltas:
199
+ t = t + deltas.pop(short); merged_count += 1
200
+ weights["backbone." + short] = _cast(t, dtype)
201
+ if deltas:
202
+ raise ValueError(f"{len(deltas)} adapter tensors matched no base weight, e.g. {list(deltas)[:3]}")
203
+
204
+ meta = torch.load(str(adapter / "head.pt"), map_location="cpu")
205
+ head = meta["head"]
206
+ for k in ("q.weight", "q.bias", "k.weight", "k.bias"):
207
+ weights["head." + k] = head[k].to(torch.float32).numpy()
208
+ _save(dst, weights); del weights
209
+ _write_backbone_config(dst, backbone_cfg, dtype)
210
+ tok = _copy_tokenizer(adapter, dst)
211
+ eos, pad = _eos_pad_from_tokenizer(tok, backbone_cfg)
212
+ (dst / "tinyjev.json").write_text(json.dumps({
213
+ "format": "tinyjev-v2", "family": "pointer", "name": name,
214
+ "head": {"head_dim": int(meta.get("head_dim", 256)), "temperature": float(meta.get("temperature", 1.0)),
215
+ "option_isolation": bool(meta.get("option_isolation", False))},
216
+ "tokenizer": {"eos_token_id": eos, "pad_token_id": pad},
217
+ "max_state": 8192, "max_branch": 8192,
218
+ "dtypes": {"backbone": dtype, "head": "float32"},
219
+ "upstream": {"repo": str(adapter_dir), "base_model": meta.get("base"),
220
+ "base_revision": meta.get("base_revision"), "lora_rank": int(acfg["r"]),
221
+ "lora_alpha": acfg["lora_alpha"], "merged_tensors": merged_count,
222
+ "full_finetune": full_ft},
223
+ }, indent=2))
224
+ print(f"kev: merged {merged_count} LoRA deltas into the base, backbone -> {dtype}, head -> float32\n-> {dst}")
225
+ return dst
226
+
227
+
228
+ class _NoTensors:
229
+ """Stand-in for safe_open when a run has no adapter file."""
230
+ def __enter__(self):
231
+ return self
232
+ def __exit__(self, *exc):
233
+ return False
234
+ def keys(self):
235
+ return []
236
+
237
+
238
+ def convert(family: str, dest, dtype: str = "float16", **kw) -> Path:
239
+ if family == "nanojev":
240
+ return convert_nanojev(kw["source"], dest, dtype)
241
+ if family == "kev":
242
+ return convert_kev(kw["adapter"], kw.get("base"), dest, dtype, name=kw.get("name", "kev"))
243
+ raise ValueError(f"unknown family {family!r}")
@@ -0,0 +1,55 @@
1
+ """Model families: a prompt format plus the decision head that reads it.
2
+
3
+ pointer one branch per question ending in a decide token, scored against each option's end token
4
+ marker one sequence per candidate, pooled at its end token, with attention across the candidate set
5
+
6
+
7
+ enc = family.encode(record) # -> Encoded(prefix, rows, questions)
8
+ hs = backbone.hidden_rows(enc.prefix, enc.rows, family.pad_token_id)
9
+ out = family.logits(hs, enc) # -> {question id: np.ndarray logits}
10
+
11
+ Heads run in numpy (fp32); they are tiny and this keeps every backend numerically identical.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Dict, List
17
+
18
+
19
+ @dataclass
20
+ class Encoded:
21
+ prefix: List[int] # tokens shared by every row (the state)
22
+ rows: List[List[int]] # per-row suffix tokens, appended to the prefix
23
+ questions: List[Dict[str, Any]] = field(default_factory=list) # family-specific readout info
24
+
25
+
26
+ def make(name: str, root, manifest: dict):
27
+ aliases = {"nanojev": "marker", "kev": "pointer"} # layouts written before the rename
28
+ name = aliases.get(name, name)
29
+ if name == "marker":
30
+ from .marker import MarkerFamily as F
31
+ elif name == "pointer":
32
+ from .pointer import PointerFamily as F
33
+ else:
34
+ raise ValueError(f"unknown family {name!r}; expected 'pointer' or 'marker'")
35
+ return F(root, manifest)
36
+
37
+
38
+ def load_head(root) -> Dict[str, Any]:
39
+ """The fp32 decision head from head.safetensors, as numpy; the backbone is never touched."""
40
+ import numpy as np
41
+ from pathlib import Path
42
+ from safetensors import safe_open
43
+ out = {}
44
+ with safe_open(str(Path(root) / "head.safetensors"), framework="numpy") as f:
45
+ for key in f.keys():
46
+ out[key] = np.asarray(f.get_tensor(key), dtype=np.float32)
47
+ return out
48
+
49
+
50
+ def softmax(z):
51
+ import numpy as np
52
+ z = np.asarray(z, dtype=np.float64)
53
+ z = z - z.max()
54
+ e = np.exp(z)
55
+ return (e / e.sum()).astype(np.float64)
@@ -0,0 +1,152 @@
1
+ """NanoJev: one row per candidate, EOS pooling, scalar scorer + set-attention over choice options.
2
+
3
+ Prompt bytes and the head follow upstream `predict_toy_decisions` / `train_toy_decisions`
4
+ (MIT, OpenJev contributors). Parity is checked against upstream's own CUDA predictions.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import math
10
+ from typing import Any, Dict, List
11
+
12
+ import numpy as np
13
+
14
+ from . import Encoded, load_head, softmax
15
+
16
+ QUESTION_TYPES = {"boolean", "choice", "score"}
17
+ SET_DIM, SET_HEADS = 128, 4
18
+
19
+
20
+ def _nonempty(v) -> bool:
21
+ return isinstance(v, str) and bool(v.strip())
22
+
23
+
24
+ def validate_question(where: str, q: dict):
25
+ if not isinstance(q, dict) or set(q) - {"id", "type", "instructions", "criteria"}:
26
+ raise ValueError(f"{where}: question may only hold type, instructions, criteria")
27
+ typ = q.get("type")
28
+ if typ not in QUESTION_TYPES or not _nonempty(q.get("instructions")):
29
+ raise ValueError(f"{where}: invalid type or instructions")
30
+ c = q.get("criteria")
31
+ if typ == "boolean":
32
+ if "criteria" in q and (not isinstance(c, dict) or set(c) - {"false", "true"}
33
+ or not all(_nonempty(v) for v in c.values())):
34
+ raise ValueError(f"{where}: boolean criteria may only hold non-empty false/true")
35
+ elif typ == "choice":
36
+ if not isinstance(c, dict) or not 2 <= len(c) <= 255 or not all(
37
+ _nonempty(k) and _nonempty(v) for k, v in c.items()):
38
+ raise ValueError(f"{where}: choice criteria must be 2-255 non-empty id: description pairs")
39
+ else:
40
+ if not isinstance(c, list) or not 2 <= len(c) <= 10 or not all(_nonempty(v) for v in c):
41
+ raise ValueError(f"{where}: score criteria must be an ordered list of 2-10 non-empty levels")
42
+
43
+
44
+ def candidates(q: dict):
45
+ typ = q["type"]
46
+ if typ == "boolean":
47
+ return ["false", "true"], ["The proposition is true."]
48
+ if typ == "choice":
49
+ ids = list(q["criteria"])
50
+ return ids, [f"{k}: {q['criteria'][k]}" for k in ids]
51
+ return [str(i) for i in range(len(q["criteria"]))], list(q["criteria"])
52
+
53
+
54
+ def layer_norm(x, w, b, eps=1e-5):
55
+ mu = x.mean(-1, keepdims=True)
56
+ var = ((x - mu) ** 2).mean(-1, keepdims=True)
57
+ return (x - mu) / np.sqrt(var + eps) * w + b
58
+
59
+
60
+ class MarkerFamily:
61
+ name = "marker"
62
+
63
+ def __init__(self, root, manifest: dict):
64
+ from tokenizers import Tokenizer
65
+ self.tok = Tokenizer.from_file(str(root / "tokenizer.json"))
66
+ tk = manifest["tokenizer"]
67
+ self.eos_token_id = int(tk["eos_token_id"])
68
+ self.pad_token_id = int(tk.get("pad_token_id", tk["eos_token_id"]))
69
+ self.max_length = int(manifest.get("max_length", 8192))
70
+ self.set_head = manifest["head"].get("set_head", "attention")
71
+ self.w = load_head(root)
72
+
73
+ def _enc(self, text: str) -> List[int]:
74
+ return self.tok.encode(text, add_special_tokens=False).ids
75
+
76
+ # ---- prompt ----
77
+ def encode(self, record: dict) -> Encoded:
78
+ state = record["state"]
79
+ if not isinstance(state, (str, dict, list)) or not state:
80
+ raise ValueError(f"{record['id']}: state must be a non-empty string, object or array")
81
+ prefix = self._enc(f"State:\n{state}\n") # str() of dict/list, as upstream trained
82
+ rows, questions = [], []
83
+ for q in record["questions"]:
84
+ where = f"{record['id']}:{q['id']}"
85
+ validate_question(where, q)
86
+ typ = q["type"]
87
+ ids, texts = candidates(q)
88
+ head = f"Question type: {typ}\nQuestion:\n{q['instructions']}\n"
89
+ if typ == "boolean" and "criteria" in q:
90
+ for key, label in (("false", "False"), ("true", "True")):
91
+ if key in q["criteria"]:
92
+ head += f"{label} criterion: {q['criteria'][key]}\n"
93
+ head_ids = self._enc(head)
94
+ start = len(rows)
95
+ for t in texts:
96
+ rows.append(head_ids + self._enc(f"Candidate:\n{t}\nDecision:") + [self.eos_token_id])
97
+ longest = len(prefix) + max(len(r) for r in rows[start:])
98
+ if longest > self.max_length:
99
+ raise ValueError(f"{where}: candidate path is {longest} tokens, over max_length={self.max_length}")
100
+ questions.append({"id": q["id"], "type": typ, "keys": ids,
101
+ "rows": list(range(start, len(rows)))})
102
+ return Encoded(prefix=prefix, rows=rows, questions=questions)
103
+
104
+ # ---- head ----
105
+ def _set_attention(self, u: np.ndarray) -> np.ndarray:
106
+ w = self.w
107
+ k_, d = u.shape
108
+ qkv = u @ w["set_attention.in_proj_weight"].T + w["set_attention.in_proj_bias"]
109
+ q, k, v = np.split(qkv, 3, axis=-1)
110
+ hd = d // SET_HEADS
111
+ heads = lambda t: t.reshape(k_, SET_HEADS, hd).transpose(1, 0, 2) # [H,K,hd]
112
+ q, k, v = heads(q), heads(k), heads(v)
113
+ s = (q @ k.transpose(0, 2, 1)) / math.sqrt(hd)
114
+ s = s - s.max(-1, keepdims=True)
115
+ a = np.exp(s); a /= a.sum(-1, keepdims=True)
116
+ mixed = (a @ v).transpose(1, 0, 2).reshape(k_, d)
117
+ return mixed @ w["set_attention.out_proj.weight"].T + w["set_attention.out_proj.bias"]
118
+
119
+ def logits(self, hidden_rows: List[np.ndarray], enc: Encoded) -> Dict[str, np.ndarray]:
120
+ w, out = self.w, {}
121
+ for q in enc.questions:
122
+ leaves = np.stack([hidden_rows[i][-1] for i in q["rows"]]).astype(np.float32)
123
+ h = layer_norm(leaves, w["norm.weight"], w["norm.bias"])
124
+ z = (h @ w["scalar.weight"].T + w["scalar.bias"]).reshape(-1)
125
+ if q["type"] == "choice" and self.set_head == "attention":
126
+ log_k = np.full((h.shape[0], 1), math.log(h.shape[0]), dtype=np.float32)
127
+ u = np.concatenate([h, log_k], -1) @ w["set_project.weight"].T + w["set_project.bias"]
128
+ delta = (np.tanh(u + self._set_attention(u)) @ w["set_output.weight"].T
129
+ + w["set_output.bias"]).reshape(-1)
130
+ z = z + delta
131
+ if q["type"] == "boolean":
132
+ z = np.array([0.0, float(z[0])], dtype=np.float32)
133
+ out[q["id"]] = z.astype(np.float32)
134
+ return out
135
+
136
+ # ---- answers ----
137
+ @staticmethod
138
+ def answer(q: dict, probs: List[float]) -> dict:
139
+ ids = q["keys"]
140
+ if len(probs) != len(ids) or not all(math.isfinite(p) and 0 <= p <= 1 for p in probs) \
141
+ or abs(math.fsum(probs) - 1.0) > 1e-5:
142
+ raise ValueError("model produced invalid probabilities")
143
+ best = max(range(len(ids)), key=probs.__getitem__)
144
+ a = {"type": q["type"], "probabilities": dict(zip(ids, probs))}
145
+ if q["type"] == "boolean":
146
+ a.update(p_true=probs[1], value=bool(best))
147
+ elif q["type"] == "choice":
148
+ a.update(choice=ids[best], value=ids[best])
149
+ else:
150
+ s = math.fsum(i * p for i, p in enumerate(probs))
151
+ a.update(score=s, level=best, value=s)
152
+ return a
@@ -0,0 +1,131 @@
1
+ """Kev: one row per question, delimiter tokens, pointer head (<decide> against each </opt>).
2
+
3
+ Prompt layout, delimiter choice and the head follow `kev.model` / `kev.api` (Apache-2.0,
4
+ Jared Palmer). Each question runs as its own causal row continuing from the state, which
5
+ kev.model documents as exactly equivalent to its packed block-causal form.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+ from typing import Any, Dict, List
12
+
13
+ import numpy as np
14
+
15
+ from . import Encoded, load_head
16
+
17
+ SPECIAL = ["<|fim_prefix|>", "<|fim_middle|>", "<|box_start|>", "<|box_end|>", "<|fim_suffix|>"]
18
+ _SPECIAL_RE = re.compile(r"<\|([A-Za-z0-9_]+)\|>")
19
+ MAX_OPTIONS = 255
20
+
21
+
22
+ def render(v, indent: int = 0) -> str:
23
+ pad = " " * indent
24
+ if v is None:
25
+ return ""
26
+ if isinstance(v, (str, int, float, bool)):
27
+ return str(v)
28
+ if isinstance(v, list):
29
+ return "\n".join(f"{pad}- {render(x, indent + 1).lstrip()}" for x in v)
30
+ return "\n".join(f"{pad}{k}:\n{render(x, indent + 1)}" if isinstance(x, (dict, list))
31
+ else f"{pad}{k}: {render(x)}" for k, x in v.items())
32
+
33
+
34
+ def option_text(name: str, desc) -> str:
35
+ return name if desc is None or desc == "" else f"{name}: {render(desc)}"
36
+
37
+
38
+ class PointerFamily:
39
+ name = "pointer"
40
+
41
+ def __init__(self, root, manifest: dict):
42
+ from tokenizers import Tokenizer
43
+ self.tok = Tokenizer.from_file(str(root / "tokenizer.json"))
44
+ ids = [self.tok.token_to_id(t) for t in SPECIAL]
45
+ if any(i is None for i in ids):
46
+ raise ValueError("tokenizer lacks the Qwen delimiter tokens Kev relies on")
47
+ self.state_id, self.q_id, self.o_id, self.c_id, self.d_id = ids
48
+ tk = manifest["tokenizer"]
49
+ self.pad_token_id = int(tk.get("pad_token_id", 0))
50
+ self.max_state = int(manifest.get("max_state", 8192))
51
+ self.max_branch = int(manifest.get("max_branch", 8192))
52
+ self.temperature = float(manifest["head"].get("temperature", 1.0))
53
+ self.dp = int(manifest["head"].get("head_dim", 256))
54
+ self.w = load_head(root)
55
+
56
+ def user_tokens(self, text: str) -> List[int]:
57
+ # caller text can never forge a delimiter: <|name|> becomes <¦name¦> before tokenizing
58
+ return self.tok.encode(_SPECIAL_RE.sub(r"<¦\1¦>", text), add_special_tokens=False).ids
59
+
60
+ # ---- prompt ----
61
+ def encode(self, record: dict) -> Encoded:
62
+ state_tokens = self.user_tokens(render(record["state"]))
63
+ if len(state_tokens) + 1 > self.max_state:
64
+ raise ValueError(f"{record['id']}: state exceeds {self.max_state} tokens")
65
+ prefix = [self.state_id] + state_tokens
66
+ rows, questions = [], []
67
+ for q in record["questions"]:
68
+ where = f"{record['id']}:{q['id']}"
69
+ typ = q["type"]
70
+ c = q.get("criteria")
71
+ if typ == "boolean":
72
+ c = c or {}
73
+ if not isinstance(c, dict) or set(c) - {"false", "true"}:
74
+ raise ValueError(f"{where}: boolean criteria may only hold false/true")
75
+ keys, opts = ["false", "true"], [option_text("no", c.get("false")), option_text("yes", c.get("true"))]
76
+ elif typ == "choice":
77
+ if not isinstance(c, dict) or not 1 <= len(c) <= MAX_OPTIONS:
78
+ raise ValueError(f"{where}: choice criteria must hold 1..{MAX_OPTIONS} options")
79
+ keys, opts = list(c), [option_text(k, v) for k, v in c.items()]
80
+ elif typ == "score":
81
+ if not isinstance(c, list) or not 2 <= len(c) <= MAX_OPTIONS:
82
+ raise ValueError(f"{where}: score criteria must be a list of 2..{MAX_OPTIONS} levels")
83
+ keys, opts = [str(i) for i in range(len(c))], [render(x) for x in c]
84
+ else:
85
+ raise ValueError(f"{where}: unsupported question type {typ!r}")
86
+ instr = [self.q_id] + self.user_tokens(render(q["instructions"]))
87
+ spans = [[self.o_id] + self.user_tokens(o) + [self.c_id] for o in opts]
88
+ branch = instr + [t for sp in spans for t in sp] + [self.d_id]
89
+ if len(branch) > self.max_branch - len(prefix):
90
+ raise ValueError(f"{where}: branch too long ({len(branch)} tokens)")
91
+ ends, cursor = [], len(instr)
92
+ for sp in spans:
93
+ cursor += len(sp)
94
+ ends.append(cursor - 1)
95
+ rows.append(branch)
96
+ questions.append({"id": q["id"], "type": typ, "keys": keys, "row": len(rows) - 1,
97
+ "decide": len(prefix) + len(branch) - 1,
98
+ "opts": [len(prefix) + e for e in ends],
99
+ "legend": dict(zip(keys, opts)) if typ == "score" else None})
100
+ return Encoded(prefix=prefix, rows=rows, questions=questions)
101
+
102
+ # ---- head ----
103
+ def logits(self, hidden_rows: List[np.ndarray], enc: Encoded) -> Dict[str, np.ndarray]:
104
+ w, out = self.w, {}
105
+ for q in enc.questions:
106
+ h = hidden_rows[q["row"]]
107
+ qv = h[q["decide"]] @ w["q.weight"].T + w["q.bias"]
108
+ kv = h[q["opts"]] @ w["k.weight"].T + w["k.bias"]
109
+ z = (kv @ qv) / math.sqrt(self.dp)
110
+ out[q["id"]] = (z / self.temperature).astype(np.float32)
111
+ return out
112
+
113
+ # ---- answers (System One shape, as kev.api.to_answers) ----
114
+ @staticmethod
115
+ def answer(q: dict, probs: List[float]) -> dict:
116
+ r2 = lambda x: round(float(x), 2)
117
+ if q["type"] == "boolean":
118
+ return {"type": "noul", "noul": r2(probs[1]), "value": bool(probs[1] >= 0.5),
119
+ "p_true": probs[1], "probabilities": dict(zip(q["keys"], probs))}
120
+ if q["type"] == "choice":
121
+ K = len(probs)
122
+ best = max(range(K), key=probs.__getitem__)
123
+ conf = 1.0 if K == 1 else (max(probs) - 1 / K) / (1 - 1 / K)
124
+ return {"type": "choice", "choice": q["keys"][best], "value": q["keys"][best],
125
+ "confidence": r2(conf), "probabilities": dict(zip(q["keys"], probs))}
126
+ L = len(probs)
127
+ mode = max(range(L), key=probs.__getitem__)
128
+ score = sum(i * p for i, p in enumerate(probs))
129
+ conf = 1.0 - sum(p * abs(i - mode) for i, p in enumerate(probs)) / (L - 1)
130
+ return {"type": "score", "score": r2(score), "value": score, "level": mode, "legend": q["legend"],
131
+ "probabilities": {str(i): v for i, v in enumerate(probs)}, "confidence": r2(conf)}
tinyjev/registry.py ADDED
@@ -0,0 +1,13 @@
1
+ """Known checkpoints. One entry: our model. Other projects' models are converted locally with
2
+ `tinyjev convert` — we do not republish their weights."""
3
+ MODELS = {
4
+ "tinyjev-0.6b": {"repo": "AnkitAI/tinyjev-0.6b", "family": "pointer", "params": "0.6B",
5
+ "what": "Qwen3-0.6B-Base + pointer head, 596M"},
6
+ }
7
+
8
+
9
+ def resolve(name: str):
10
+ """alias -> (repo, None); anything else -> (name, None): a local path or a Hub repo id."""
11
+ if name in MODELS:
12
+ return MODELS[name]["repo"], None
13
+ return name, None