edgejev 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.
edgejev/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """EdgeJev —— 在自己的设备上跑 Jev 式的类型化决策:快、小、运行时不需要 torch。
2
+
3
+ edgejev build --backend laya --out ./jev-int8
4
+ from edgejev import Agent
5
+ Agent("./jev-int8").system_one(state, questions)
6
+ """
7
+ from .agent import Agent, load
8
+
9
+ __all__ = ["Agent", "load"]
10
+ __version__ = "0.1.0"
edgejev/agent.py ADDED
@@ -0,0 +1,101 @@
1
+ """运行时:只依赖 onnxruntime + tokenizers + numpy。跨 Linux / macOS / Windows。"""
2
+ import json
3
+ import os
4
+ from typing import Any, Dict, Optional, Union
5
+
6
+ from . import backends, post, providers
7
+ from .tokenize import Encoder
8
+
9
+ CONFIG_NAME = "edgejev.json"
10
+
11
+
12
+ class Agent:
13
+ """本地 System One 决策。
14
+
15
+ from edgejev import Agent
16
+ ag = Agent("./jev-int8")
17
+ ag.system_one("客户被扣了两次款", {
18
+ "dept": {"type": "choice", "instructions": "转给哪个组",
19
+ "criteria": {"billing": "支付扣款", "technical": "程序缺陷"}}})
20
+ """
21
+
22
+ def __init__(self, model_dir: str, threads: Optional[int] = None,
23
+ provider: Optional[str] = None):
24
+ cfg_path = os.path.join(model_dir, CONFIG_NAME)
25
+ if not os.path.exists(cfg_path):
26
+ raise FileNotFoundError(
27
+ "%s 里没有 %s,这个目录需要用 `edgejev build` 生成。" % (model_dir, CONFIG_NAME))
28
+ with open(cfg_path, encoding="utf-8") as f:
29
+ self.cfg = json.load(f)
30
+
31
+ self.backend = backends.get(self.cfg.get("backend", "laya"))
32
+ self.runtime = self.cfg.get("runtime", "onnx")
33
+ self.temperature = self.cfg.get("temperature", [1.0, 1.0, 1.0])
34
+ self.temperature_by_options = self.cfg.get("temperature_by_options", {})
35
+ self.model_name = self.cfg.get("model_name", "edgejev")
36
+
37
+ if self.runtime == "torch-vlm":
38
+ from .runtimes.torch_vlm import TorchVLM
39
+ self.vlm = TorchVLM(self.cfg["source_model"], template=self.cfg.get("template", "plain"))
40
+ self.provider_note = self.vlm.note
41
+ self.slots = self.vlm.slot_ids(self.backend)
42
+ return
43
+
44
+ import onnxruntime as ort
45
+ ids_map = {k: self.cfg[k] for k in self.cfg if k.endswith("_id")}
46
+ ids_map["mask_token"] = self.cfg.get("mask_token", "<mask>")
47
+ self.enc = Encoder.from_file(os.path.join(model_dir, "tokenizer.json"), ids_map)
48
+
49
+ so = ort.SessionOptions()
50
+ so.intra_op_num_threads = threads or (os.cpu_count() or 4)
51
+ so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
52
+ provs, self.provider_note = providers.detect(provider)
53
+ self.sess = ort.InferenceSession(
54
+ os.path.join(model_dir, self.cfg["onnx_file"]), so, providers=provs)
55
+
56
+ def system_one(self, state: Union[str, dict, list],
57
+ questions: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
58
+ """对一份 state 并行评估一组带类型的问题。返回与官方 /v1/systemone 同构的结果。"""
59
+ if not questions:
60
+ raise ValueError("questions 不能为空")
61
+ qids = list(questions.keys())
62
+ qs = [post.to_internal(questions[q]) for q in qids]
63
+
64
+ if self.runtime == "torch-vlm":
65
+ return self._vlm_system_one(state, qids, qs)
66
+
67
+ prep = self.backend.prepare(self.enc, state, qs, self.cfg)
68
+ outputs = self.sess.run(None, prep["feed"])
69
+ logits, act = self.backend.read_logits(outputs, prep)
70
+
71
+ answers = post.assemble(qs, qids, logits, act, prep["k"],
72
+ self.temperature, self.temperature_by_options)
73
+ return {"model": self.model_name, "answers": answers,
74
+ "usage": {"input_tokens": prep["input_tokens"], "output_tokens": 0}}
75
+
76
+ def _vlm_system_one(self, state, qids, qs):
77
+ """视觉后端:每题一次前向(提示里只放一道题,读最后位置的字母槽)。"""
78
+ import numpy as np
79
+
80
+ image = None
81
+ answers = {}
82
+ for qid, q in zip(qids, qs):
83
+ opts = self.backend.options_from_question(q)
84
+ prompt = self.backend.build_plain_prompt(opts, q["ins"], 1)
85
+ if image is None:
86
+ from .runtimes.torch_vlm import _to_pil
87
+ image = _to_pil(state)
88
+ logits = self.vlm.last_logits(image, prompt)
89
+ probs, allowed = self.backend.readout(logits, self.slots, len(opts))
90
+ conf = round(self.backend.choice_confidence(probs), 4)
91
+ a = post.format_answer(q, np.asarray(probs), conf, None)
92
+ a["allowed_mass"] = round(allowed, 7) # 量级在 1e-4,4 位精度会把不同输入显示成同一个数
93
+ answers[qid] = a
94
+ return {"model": self.model_name, "answers": answers,
95
+ "usage": {"input_tokens": 0, "output_tokens": 0}}
96
+
97
+ predict = system_one
98
+
99
+
100
+ def load(model_dir: str, **kw) -> Agent:
101
+ return Agent(model_dir, **kw)
@@ -0,0 +1,20 @@
1
+ """后端注册表。新增一个后端=加一个模块并在这里登记。"""
2
+ from . import laya, playjev
3
+
4
+ _REGISTRY = {"laya": laya, "playjev": playjev}
5
+
6
+ try:
7
+ from . import kev
8
+ _REGISTRY["kev"] = kev
9
+ except Exception: # kev 适配器是可选的
10
+ pass
11
+
12
+
13
+ def get(name):
14
+ if name not in _REGISTRY:
15
+ raise ValueError("未知后端 %r,已注册:%s" % (name, ", ".join(sorted(_REGISTRY))))
16
+ return _REGISTRY[name]
17
+
18
+
19
+ def names():
20
+ return sorted(_REGISTRY)
@@ -0,0 +1,24 @@
1
+ """后端适配器协议。
2
+
3
+ 不同的开源 Jev 复现,序列构造和打分头完全不同:
4
+
5
+ * laya —— mmBERT 编码器,每题一个 batch 行,读 [MASK] 标记位的隐状态
6
+ * kev —— Qwen 解码器 + LoRA,多题打包进一条序列,block-causal 掩码,
7
+ PointerHead 用 <decide> 的隐状态去打各个 </opt> 位置的分
8
+
9
+ 所以后端必须自己负责:把 (state, questions) 变成模型输入、说明 ONNX 的输入输出签名、
10
+ 以及构建期怎么从上游 checkpoint 导出。后处理(温度、熵置信度、答案格式)是共用的。
11
+ """
12
+ from typing import Any, Dict, List, Protocol
13
+
14
+
15
+ class Backend(Protocol):
16
+ name: str
17
+ #: ONNX 图的输入名,顺序无关
18
+ input_names: List[str]
19
+
20
+ def prepare(self, enc, state, questions: List[Dict], cfg: Dict) -> Dict[str, Any]:
21
+ """返回 {"feed": {onnx输入名: ndarray}, "k": [每题的选项数]}。"""
22
+
23
+ def read_logits(self, outputs, meta: Dict) -> Any:
24
+ """从 ONNX 输出里取出 [问题数, 最大选项数] 的 logits 和可选的 act 概率。"""
@@ -0,0 +1,108 @@
1
+ """laya 后端:mmBERT 编码器 + 两层 transformer head + marker 打分。
2
+
3
+ 序列格式: [CLS] <type> question: 指令 [SEP] [MASK] opt0 [MASK] opt1 ... [SEP] state [SEP]
4
+ 每个选项前的 [MASK] 位置记为 marker,scorer 读这些位置的隐状态各出一个 logit。
5
+ 每个问题占 batch 的一行,所以 N 个问题 = encoder 跑 batch=N。
6
+
7
+ 与上游 laya(PyTorch)逐位一致:fp32 模式下最大概率偏差 0.00000。
8
+ """
9
+ import json
10
+ from typing import Dict, List, Union
11
+
12
+ import numpy as np
13
+
14
+ QTYPES = {"choice": 0, "score": 1, "noul": 2}
15
+ QTYPE_NAMES = {v: k for k, v in QTYPES.items()}
16
+
17
+ name = "laya"
18
+ input_names = ["input_ids", "attention_mask", "marker_pos", "marker_mask", "qtype"]
19
+ TOKEN_IDS = ["cls_id", "sep_id", "mask_id", "pad_id"]
20
+
21
+
22
+ def serialize_state(state: Union[str, dict, list]) -> str:
23
+ return state if isinstance(state, str) else json.dumps(state, ensure_ascii=False)
24
+
25
+
26
+ def render_criterion(value) -> str:
27
+ if isinstance(value, str):
28
+ return value
29
+ return json.dumps(value, ensure_ascii=False, separators=(", ", ": "), default=str)
30
+
31
+
32
+ def render_options(q: Dict) -> List[str]:
33
+ t, crit = q["t"], q.get("crit")
34
+ if t == "choice":
35
+ return [k if v is None or v == "" else "%s: %s" % (k, render_criterion(v))
36
+ for k, v in crit.items()]
37
+ if t == "score":
38
+ return ["level %d: %s" % (i, render_criterion(c)) for i, c in enumerate(crit)]
39
+ crit = crit or {}
40
+ fc, tc = crit.get("false"), crit.get("true")
41
+ return [
42
+ "false: " + (render_criterion(fc) if fc not in (None, "") else "no, the statement does not hold"),
43
+ "true: " + (render_criterion(tc) if tc not in (None, "") else "yes, the statement holds"),
44
+ ]
45
+
46
+
47
+ def build_sequence(enc, state, q: Dict, max_len: int, head_max_len: int,
48
+ truncate_left: bool = False):
49
+ mt = enc.mask_token
50
+ opts = render_options(q)
51
+ ins = str(q["ins"]).replace(mt, " ")
52
+ head_ids = enc.ids("%s question: %s" % (q["t"], ins))
53
+
54
+ opt_ids = [[enc.mask_id] + enc.ids(" " + o.replace(mt, " "))[:48] for o in opts]
55
+ budget = head_max_len - sum(len(o) for o in opt_ids)
56
+ if budget < 16:
57
+ per = max(4, (head_max_len - 16) // max(1, len(opt_ids)))
58
+ opt_ids = [o[:per] for o in opt_ids]
59
+ budget = head_max_len - sum(len(o) for o in opt_ids)
60
+ head_ids = head_ids[: max(8, budget)]
61
+
62
+ ids = [enc.cls_id] + head_ids + [enc.sep_id]
63
+ markers = []
64
+ for o in opt_ids:
65
+ markers.append(len(ids))
66
+ ids.extend(o)
67
+ ids.append(enc.sep_id)
68
+
69
+ room = max(0, max_len - len(ids) - 1)
70
+ st = enc.ids(serialize_state(state).replace(mt, " "))
71
+ st = st[-room:] if truncate_left else st[:room]
72
+ ids = ids + st + [enc.sep_id]
73
+ return ids[:max_len], [m for m in markers if m < max_len]
74
+
75
+
76
+ def prepare(enc, state, questions: List[Dict], cfg: Dict) -> Dict:
77
+ max_len = cfg.get("max_len", 1024)
78
+ head_max_len = cfg.get("head_max_len", 256)
79
+ items = []
80
+ for i, q in enumerate(questions):
81
+ seq, markers = build_sequence(enc, state, q, max_len, head_max_len)
82
+ if len(markers) != len(render_options(q)):
83
+ raise ValueError("第 %d 个问题的选项超出 head_max_len=%d" % (i, head_max_len))
84
+ items.append((seq, markers, QTYPES[q["t"]]))
85
+
86
+ n = len(items)
87
+ L = max(len(s) for s, _, _ in items)
88
+ K = max(len(m) for _, m, _ in items)
89
+ ids = np.full((n, L), enc.pad_id, dtype=np.int64)
90
+ att = np.zeros((n, L), dtype=np.int64)
91
+ mpos = np.zeros((n, K), dtype=np.int64)
92
+ mmask = np.zeros((n, K), dtype=bool)
93
+ for i, (seq, markers, _) in enumerate(items):
94
+ ids[i, :len(seq)] = seq
95
+ att[i, :len(seq)] = 1
96
+ mpos[i, :len(markers)] = markers
97
+ mmask[i, :len(markers)] = True
98
+ feed = {"input_ids": ids, "attention_mask": att, "marker_pos": mpos,
99
+ "marker_mask": mmask,
100
+ "qtype": np.array([t for _, _, t in items], dtype=np.int64)}
101
+ return {"feed": feed, "k": [len(m) for _, m, _ in items],
102
+ "input_tokens": int(att.sum())}
103
+
104
+
105
+ def read_logits(outputs, meta):
106
+ logits, act = outputs[0], outputs[1]
107
+ act = np.exp(act - act.max(-1, keepdims=True))
108
+ return logits, act / act.sum(-1, keepdims=True)
@@ -0,0 +1,111 @@
1
+ """PlayJev 后端:Qwen3.5-0.8B 视觉模型,从游戏画面一次前向读「选项字母」的概率。
2
+
3
+ 与 laya / kev 的区别,三点都很关键:
4
+
5
+ 1. **输入是像素**,不是文本 state。图像经 Qwen2VL 风格的处理器切成 patch。
6
+ 2. **读出方式是「字母槽」**:把选项渲染成 `A. name: desc` 的列表,取最后一个位置的
7
+ 全词表 logits,只在 " A" / " B" ... 这 K 个 token 上做 float32 softmax。
8
+ 上游特意用 float32:bf16 在 logit 量级 25–50 时量化步长到 0.125,会让选项打平。
9
+ 3. **置信度用 Jev 的 Choice 公式** `(p_max - 1/K) / (1 - 1/K)`,不是 laya 的归一化熵。
10
+
11
+ ⚠️ 这个后端**跑在 torch 上,不走 EdgeJev 的 ONNX + 量化主路径**。原因是 Qwen3.5 的文本塔是
12
+ 混合线性注意力(config 里 `layer_types` 有 `linear_attention`),那些层依赖
13
+ `causal_conv1d` / flash-linear-attention 这类带递归状态的自定义核,没有对应的标准 ONNX 算子。
14
+ 所以用这个后端时,EdgeJev 只统一了 API 和 `serve`,拿不到「不依赖 torch」和量化加速。
15
+ """
16
+ from typing import Dict, List, Sequence
17
+
18
+ import numpy as np
19
+
20
+ name = "playjev"
21
+ runtime = "torch-vlm"
22
+
23
+ LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
24
+ PROMPT_VERSION = "playjev-letters-v1"
25
+ # 与上游逐字一致。上游注释说明:这段「冻结的 OpenJev 措辞」比
26
+ # "You are a System One decision model" 那种开头高 3–6 个点,所以不要自己加前缀。
27
+ SYSTEM_PROMPT = (
28
+ "Apply the question to the state. Choose exactly one of the listed options. "
29
+ "Respond with only its uppercase letter, with no explanation or reasoning."
30
+ )
31
+ DEFAULT_INSTRUCTIONS = "Which move should the player make next?"
32
+ FRAME_PLACEHOLDER = "<|vision_start|><|image_pad|><|vision_end|>"
33
+ PLAIN_ANSWER_CUE = "Answer:"
34
+
35
+
36
+ def options_block(options: Sequence[dict]) -> str:
37
+ if len(options) > len(LETTERS):
38
+ raise ValueError("%d 个选项超出 %d 个字母槽" % (len(options), len(LETTERS)))
39
+ lines = []
40
+ for letter, opt in zip(LETTERS, options):
41
+ desc = (opt.get("description") or "").strip()
42
+ lines.append("%s. %s: %s" % (letter, opt["name"], desc) if desc
43
+ else "%s. %s" % (letter, opt["name"]))
44
+ return "\n".join(lines)
45
+
46
+
47
+ def render_suffix(options, instructions=DEFAULT_INSTRUCTIONS) -> str:
48
+ letters = ", ".join(LETTERS[: len(options)])
49
+ return ("Question: %s\n\nOptions:\n%s\n\nAnswer with one letter: %s."
50
+ % (instructions, options_block(options), letters))
51
+
52
+
53
+ def render_state(n_placeholders: int) -> str:
54
+ return "<state>\n" + "\n".join([FRAME_PLACEHOLDER] * n_placeholders) + "\n</state>"
55
+
56
+
57
+ def build_plain_prompt(options, instructions=DEFAULT_INSTRUCTIONS, n_placeholders=1) -> str:
58
+ return "%s\n\n%s\n\n%s\n%s" % (SYSTEM_PROMPT, render_state(n_placeholders),
59
+ render_suffix(options, instructions), PLAIN_ANSWER_CUE)
60
+
61
+
62
+ def choice_confidence(probs: Sequence[float]) -> float:
63
+ """Jev 的 Choice 置信度:(p_max - 1/K) / (1 - 1/K)。K=1 时恒为 1。"""
64
+ k = len(probs)
65
+ if k == 1:
66
+ return 1.0
67
+ return (max(probs) - 1.0 / k) / (1.0 - 1.0 / k)
68
+
69
+
70
+ def slot_ids(tokenizer, template="plain") -> List[int]:
71
+ """解析字母槽的 token id,并校验每个都是单 token 且能往返。"""
72
+ ids = []
73
+ for letter in LETTERS:
74
+ text = (" %s" % letter) if template == "plain" else letter
75
+ enc = tokenizer.encode(text, add_special_tokens=False)
76
+ if len(enc) != 1 or tokenizer.decode(enc) != text:
77
+ raise ValueError("字母槽 %r 在这个 tokenizer 下不是单个可往返的 token" % text)
78
+ ids.append(enc[0])
79
+ if len(set(ids)) != len(ids):
80
+ raise ValueError("字母槽 token 冲突")
81
+ return ids
82
+
83
+
84
+ def readout(last_logits: np.ndarray, slot_token_ids: Sequence[int], k: int):
85
+ """全词表 logits(最后一个位置)-> (K 个选项的概率, allowed_mass)。
86
+
87
+ allowed_mass 是全词表 softmax 落在这 K 个答案槽上的质量,低于 ~0.5 说明模型
88
+ 其实没在回答这道题(比如跑去输出别的 token),是很有用的健康指标。
89
+ """
90
+ z = last_logits.astype(np.float64)
91
+ z = z - z.max()
92
+ full = np.exp(z)
93
+ full /= full.sum()
94
+ sel = full[list(slot_token_ids[:k])]
95
+ allowed_mass = float(sel.sum())
96
+ probs = sel / max(sel.sum(), 1e-12)
97
+ return probs.astype(np.float64), allowed_mass
98
+
99
+
100
+ def options_from_question(q: Dict) -> List[dict]:
101
+ """把 EdgeJev 的带类型问题转成 PlayJev 的选项列表。"""
102
+ t, crit = q["t"], q.get("crit")
103
+ if t == "choice":
104
+ return [{"name": kk, "description": vv if isinstance(vv, str) else None}
105
+ for kk, vv in crit.items()]
106
+ if t == "score":
107
+ return [{"name": "level %d" % i, "description": c if isinstance(c, str) else None}
108
+ for i, c in enumerate(crit)]
109
+ crit = crit or {}
110
+ return [{"name": "false", "description": crit.get("false") or "the statement does not hold"},
111
+ {"name": "true", "description": crit.get("true") or "the statement holds"}]
edgejev/build.py ADDED
@@ -0,0 +1,275 @@
1
+ """构建期:把上游 checkpoint 转成 EdgeJev 目录(ONNX + tokenizer + 配置)。
2
+
3
+ 只有这一步需要 torch(`pip install 'edgejev[build]'`),转完运行时就不需要了。
4
+ """
5
+ import json
6
+ import os
7
+ import shutil
8
+ import sys
9
+
10
+ PRECISIONS = ("int8", "int8-pc", "int8-static", "mixed", "fp32")
11
+ DEFAULT_MODELS = {"laya": "convaiinnovations/laya-multilingual",
12
+ "kev": "jaredpalmer/kev-0.5b",
13
+ "playjev": "OmniJev/PlayJev-0.8B"}
14
+
15
+
16
+ # ---------------------------------------------------------------- laya 导出
17
+ def _export_laya(model_id, subfolder, out_dir):
18
+ """返回 (fp32_onnx_path, cfg_dict, tokenizer_json_path, decision_node_cut)。"""
19
+ import torch
20
+ from torch.export import Dim
21
+ import laya
22
+ from laya.common import QTYPES, build_sequence, collate_items
23
+
24
+ ag = laya.load(model_id, device="cpu", subfolder=subfolder)
25
+ ag.model.eval().float()
26
+ cfg, tok = ag.cfg, ag.tok
27
+
28
+ qs = [{"t": "choice", "ins": "which team", "crit": {"a": "1", "b": "2", "c": "3"}},
29
+ {"t": "score", "ins": "how severe", "crit": ["low", "med", "high"]},
30
+ {"t": "noul", "ins": "is it urgent", "crit": None}]
31
+ items = []
32
+ for q in qs:
33
+ seq, mk = build_sequence(tok, "a short piece of state text", q,
34
+ cfg.get("max_len", 1024), cfg.get("head_max_len", 256))
35
+ items.append({"ids": seq, "markers": mk, "qtype": QTYPES[q["t"]]})
36
+ b = collate_items([items], tok.pad_token_id)
37
+ args = (b["input_ids"], b["attention_mask"], b["marker_pos"], b["marker_mask"], b["qtype"])
38
+
39
+ path = os.path.join(out_dir, "_fp32.onnx")
40
+ B, L, K = Dim("B", min=1, max=64), Dim("L", min=8, max=1024), Dim("K", min=2, max=255)
41
+ # 必须 dynamo:旧的 TorchScript 导出器会把 head 里 nn.MultiheadAttention 的 batch/seq
42
+ # 固化成导出时的形状,换个输入长度就抛 Reshape 错误,而且用同一批次做比对发现不了。
43
+ torch.onnx.export(
44
+ ag.model, args, path,
45
+ input_names=["input_ids", "attention_mask", "marker_pos", "marker_mask", "qtype"],
46
+ output_names=["logits", "act_logits"],
47
+ dynamic_shapes={"input_ids": {0: B, 1: L}, "attention_mask": {0: B, 1: L},
48
+ "marker_pos": {0: B, 1: K}, "marker_mask": {0: B, 1: K},
49
+ "qtype": {0: B}},
50
+ opset_version=20, dynamo=True, external_data=False)
51
+
52
+ import onnx
53
+ m = onnx.load(path)
54
+ del m.graph.value_info[:] # dynamo 留下的标注与推断冲突,会挡住量化
55
+ onnx.save(m, path)
56
+
57
+ tmp = os.path.join(out_dir, "_tok")
58
+ tok.save_pretrained(tmp)
59
+ tok_json = os.path.join(tmp, "tokenizer.json")
60
+ if not os.path.exists(tok_json):
61
+ sys.exit("这个 checkpoint 不是 fast tokenizer,导不出 tokenizer.json")
62
+
63
+ conf = {"backend": "laya", "model_name": cfg.get("model_name", "laya"),
64
+ "source_model": model_id, "max_len": cfg.get("max_len", 1024),
65
+ "head_max_len": cfg.get("head_max_len", 256),
66
+ "temperature": cfg.get("temperature", [1.0, 1.0, 1.0]),
67
+ "temperature_by_options": cfg.get("temperature_by_options", {}),
68
+ "cls_id": tok.cls_token_id, "sep_id": tok.sep_token_id,
69
+ "mask_id": tok.mask_token_id, "pad_id": tok.pad_token_id,
70
+ "mask_token": tok.mask_token}
71
+ return path, conf, tok_json, "type_emb.weight"
72
+
73
+
74
+ def _write_playjev(model_id, subfolder, out_dir):
75
+ """playjev 不导 ONNX:Qwen3.5 的线性注意力层没有对应的 ONNX 算子。
76
+
77
+ 这里只落一份配置,运行时直接用 transformers 加载;EdgeJev 统一的是 API 和 serve。
78
+ """
79
+ from huggingface_hub import snapshot_download
80
+
81
+ local = os.path.exists(model_id)
82
+ if not local:
83
+ print(" 预拉权重 ...", flush=True)
84
+ snapshot_download(model_id)
85
+ conf = {"backend": "playjev", "runtime": "torch-vlm", "template": "plain",
86
+ "model_name": "playjev", "source_model": model_id,
87
+ "temperature": [1.0, 1.0, 1.0], "temperature_by_options": {}}
88
+ return None, conf, None, None
89
+
90
+
91
+ EXPORTERS = {"laya": _export_laya, "playjev": _write_playjev}
92
+
93
+
94
+ # ------------------------------------------------------------------- 量化
95
+ class _Calib:
96
+ """给静态量化用的标定数据。
97
+
98
+ 动态量化在运行时按实际张量算激活 scale,padding 一变 scale 就变,
99
+ 于是**同一条输入跟谁一批会影响它的答案**(实测 logits 能差 2.4)。
100
+ 静态量化把 scale 在这里固定下来,换来批次无关、可复现的结果。
101
+ """
102
+
103
+ def __init__(self, enc, cfg, backend, n=64):
104
+ import itertools
105
+ states = ["The customer was charged twice and is asking for a refund.",
106
+ "客户反馈 App 打开就闪退,已经第三次提工单了。",
107
+ "Server returned 502 for every request during the sale. " * 6,
108
+ "请问企业版一年多少钱?有没有教育优惠?"]
109
+ qs = [{"t": "choice", "ins": "which team", "crit": {"a": "one", "b": "two", "c": "three"}},
110
+ {"t": "score", "ins": "how severe", "crit": ["low", "med", "high"]},
111
+ {"t": "noul", "ins": "is it urgent", "crit": None},
112
+ {"t": "choice", "ins": "pick", "crit": {c: None for c in "abcdefgh"}}]
113
+ self.data = []
114
+ for st, q in itertools.islice(itertools.product(states, qs), n):
115
+ self.data.append(backend.prepare(enc, st, [q], cfg)["feed"])
116
+ # 也放几个多题批次,让激活范围覆盖 batch>1 的情形
117
+ for st in states:
118
+ self.data.append(backend.prepare(enc, st, qs[:3], cfg)["feed"])
119
+ self.it = iter(self.data)
120
+
121
+ def get_next(self):
122
+ return next(self.it, None)
123
+
124
+ def rewind(self):
125
+ self.it = iter(self.data)
126
+
127
+
128
+ def quantize(src, dst, precision, decision_marker=None, calib=None):
129
+ import onnx
130
+ from onnxruntime.quantization import (QuantType, quantize_dynamic, quantize_static,
131
+ CalibrationMethod, QuantFormat)
132
+
133
+ if precision == "int8-static":
134
+ if calib is None:
135
+ raise ValueError("静态量化需要标定数据")
136
+ print(" [实验性] int8-static 目前会大幅掉点:MinMax 标定下实测 AG News 25.8%"
137
+ "(随机基线 25%)、emotion 29.8%,且比动态量化慢约 4 倍。"
138
+ "它能做到批次无关,但标定方法还没调好——生产请用 int8 或 fp32。", flush=True)
139
+ quantize_static(src, dst, calib, quant_format=QuantFormat.QDQ,
140
+ activation_type=QuantType.QInt8, weight_type=QuantType.QInt8,
141
+ per_channel=True, calibrate_method=CalibrationMethod.MinMax)
142
+ return []
143
+
144
+ # 有符号 int8 是唯一值得选的:x86 的 AVX512-VNNI 只对 QInt8 有快路径,
145
+ # 实测同体积的 QUInt8 慢将近一倍(27.9ms vs 15.6ms)。ARM 走 SDOT,差距没这么大。
146
+ per_channel = precision in ("int8-pc", "mixed")
147
+ exclude = []
148
+ if precision == "mixed" and decision_marker:
149
+ g = onnx.load(src, load_external_data=False).graph
150
+ cut = next((i for i, n in enumerate(g.node) if decision_marker in n.input), None)
151
+ if cut is not None:
152
+ exclude = [n.name for n in g.node[cut:] if n.op_type in ("MatMul", "Gemm")]
153
+ quantize_dynamic(src, dst, weight_type=QuantType.QInt8,
154
+ per_channel=per_channel, nodes_to_exclude=exclude)
155
+ return exclude
156
+
157
+
158
+ # ------------------------------------------------------------------- 自检
159
+ def verify(out_dir):
160
+ """多形状自检:不同问题数、不同 state 长度、不同选项数都要跑通。
161
+
162
+ 这一步是硬性的——导出器把形状固化成导出时的批次是真实会发生的 bug,
163
+ 只在换输入形状时才暴露,所以不能只靠一次数值比对。
164
+ """
165
+ from .agent import Agent
166
+
167
+ print("\n自检 ...", flush=True)
168
+ ag = Agent(out_dir)
169
+ print(" provider: %s" % ag.provider_note)
170
+ cases = [
171
+ ("三题 / 短 state", "short state",
172
+ {"a": {"type": "choice", "instructions": "x", "criteria": {"p": "1", "q": "2", "r": "3"}},
173
+ "b": {"type": "score", "instructions": "y", "criteria": ["l", "m", "h"]},
174
+ "c": {"type": "noul", "instructions": "z"}}),
175
+ ("单题 / 长 state", "a much longer piece of state text " * 30,
176
+ {"only": {"type": "noul", "instructions": "is this long"}}),
177
+ ("八选项 / 中文", "客户说账号被扣了两次款,很生气。",
178
+ {"k": {"type": "choice", "instructions": "选一个",
179
+ "criteria": {c: None for c in "abcdefgh"}}}),
180
+ ]
181
+ ok = True
182
+ for name, state, qs in cases:
183
+ try:
184
+ r = ag.system_one(state, qs)
185
+ assert set(r["answers"]) == set(qs)
186
+ print(" [OK] %s" % name)
187
+ except Exception as e:
188
+ ok = False
189
+ print(" [失败] %s: %s" % (name, str(e)[:160]))
190
+
191
+ # 批次无关性:同一条输入单独跑和跟别人一批跑,结果应该一致。
192
+ # 动态量化做不到这一点(激活 scale 随 padding 变),所以这里只警告不失败。
193
+ import numpy as np
194
+ from .backends import laya as _laya
195
+ q = {"t": "noul", "ins": "is this urgent", "crit": None}
196
+ texts = ["short one", "a considerably longer piece of state text " * 8]
197
+ solo = []
198
+ for t in texts:
199
+ p = _laya.prepare(ag.enc, t, [q], ag.cfg)
200
+ solo.append(ag.sess.run(None, p["feed"])[0][0, :2].copy())
201
+ items = [_laya.build_sequence(ag.enc, t, q, ag.cfg["max_len"], ag.cfg["head_max_len"])
202
+ for t in texts]
203
+ L = max(len(s_) for s_, _ in items)
204
+ ids = np.full((2, L), ag.enc.pad_id, dtype=np.int64)
205
+ att = np.zeros((2, L), dtype=np.int64)
206
+ mp = np.zeros((2, 2), dtype=np.int64); mm = np.zeros((2, 2), dtype=bool)
207
+ for i, (s_, m_) in enumerate(items):
208
+ ids[i, :len(s_)] = s_; att[i, :len(s_)] = 1
209
+ mp[i, :len(m_)] = m_; mm[i, :len(m_)] = True
210
+ lgb = ag.sess.run(None, {"input_ids": ids, "attention_mask": att, "marker_pos": mp,
211
+ "marker_mask": mm, "qtype": np.full(2, 2, dtype=np.int64)})[0]
212
+ drift = max(float(np.abs(lgb[i, :2] - solo[i]).max()) for i in range(2))
213
+ if drift < 1e-3:
214
+ print(" [OK] 批次无关性(单条与批量一致,漂移 %.1e)" % drift)
215
+ else:
216
+ print(" [注意] 批次会影响结果:logits 漂移 %.2f。动态量化按实际张量算激活 scale,"
217
+ "padding 一变 scale 就变。要可复现请用 --precision int8-static 或 fp32,"
218
+ "或者固定 batch=1。" % drift)
219
+ return ok
220
+
221
+
222
+ def run(backend="laya", model=None, subfolder=None, out=None, precision="int8",
223
+ keep_fp32=False):
224
+ if backend not in EXPORTERS:
225
+ sys.exit("后端 %r 还没有构建器,目前支持:%s" % (backend, ", ".join(EXPORTERS)))
226
+ try:
227
+ import torch # noqa: F401
228
+ except ImportError:
229
+ sys.exit("缺少转换依赖,请先: pip install 'edgejev[build]'")
230
+
231
+ model = model or DEFAULT_MODELS[backend]
232
+ os.makedirs(out, exist_ok=True)
233
+ print("加载 %s(后端 %s)..." % (model, backend), flush=True)
234
+ fp32_path, conf, tok_json, marker = EXPORTERS[backend](model, subfolder, out)
235
+
236
+ if fp32_path is None: # 不走 ONNX 的后端(playjev)
237
+ with open(os.path.join(out, "edgejev.json"), "w", encoding="utf-8") as f:
238
+ json.dump(conf, f, ensure_ascii=False, indent=2)
239
+ print(" 运行时 %s(不导 ONNX,无量化)" % conf["runtime"])
240
+ print("\n完成 -> %s" % out)
241
+ print(' from edgejev import Agent; Agent("%s").system_one(image, questions)' % out)
242
+ return
243
+ print(" fp32 %.0f MB" % (os.path.getsize(fp32_path) / 1e6), flush=True)
244
+
245
+ final = os.path.join(out, "model.onnx")
246
+ if precision == "fp32":
247
+ shutil.move(fp32_path, final)
248
+ else:
249
+ print("量化 (%s) ..." % precision, flush=True)
250
+ calib = None
251
+ if precision == "int8-static":
252
+ from .tokenize import Encoder
253
+ from . import backends as _b
254
+ shutil.copyfile(tok_json, os.path.join(out, "tokenizer.json"))
255
+ ids_map = {k: conf[k] for k in conf if k.endswith("_id")}
256
+ ids_map["mask_token"] = conf.get("mask_token", "<mask>")
257
+ calib = _Calib(Encoder.from_file(os.path.join(out, "tokenizer.json"), ids_map),
258
+ conf, _b.get(backend))
259
+ excl = quantize(fp32_path, final, precision, marker, calib)
260
+ if excl:
261
+ print(" 决策路径保持 fp32 的节点:%d 个" % len(excl))
262
+ if not keep_fp32:
263
+ os.remove(fp32_path)
264
+ print(" model.onnx %.0f MB" % (os.path.getsize(final) / 1e6), flush=True)
265
+
266
+ shutil.copyfile(tok_json, os.path.join(out, "tokenizer.json"))
267
+ shutil.rmtree(os.path.join(out, "_tok"), ignore_errors=True)
268
+ conf.update(onnx_file="model.onnx", precision=precision)
269
+ with open(os.path.join(out, "edgejev.json"), "w", encoding="utf-8") as f:
270
+ json.dump(conf, f, ensure_ascii=False, indent=2)
271
+
272
+ if not verify(out):
273
+ sys.exit("自检未通过,产出的模型不可用")
274
+ print("\n完成 -> %s" % out)
275
+ print(' from edgejev import Agent; Agent("%s").system_one(state, questions)' % out)