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 +13 -0
- tinyjev/agent.py +172 -0
- tinyjev/backends/__init__.py +46 -0
- tinyjev/backends/mlx_backend.py +74 -0
- tinyjev/backends/torch_backend.py +71 -0
- tinyjev/cli.py +64 -0
- tinyjev/convert.py +243 -0
- tinyjev/families/__init__.py +55 -0
- tinyjev/families/marker.py +152 -0
- tinyjev/families/pointer.py +131 -0
- tinyjev/registry.py +13 -0
- tinyjev/serve.py +82 -0
- tinyjev-0.1.0.dist-info/METADATA +237 -0
- tinyjev-0.1.0.dist-info/RECORD +19 -0
- tinyjev-0.1.0.dist-info/WHEEL +5 -0
- tinyjev-0.1.0.dist-info/entry_points.txt +2 -0
- tinyjev-0.1.0.dist-info/licenses/LICENSE +21 -0
- tinyjev-0.1.0.dist-info/licenses/NOTICE +20 -0
- tinyjev-0.1.0.dist-info/top_level.txt +1 -0
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
|