dsh-router-laya 2.1.0

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.
package/install.mjs ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Place this plugin where a bare `@deepseek-ai/*` import resolves, because that is the only place the
3
+ * judge can work.
4
+ *
5
+ * A bare specifier resolves against the importing module's own location, so the source in this
6
+ * directory cannot import `@deepseek-ai/dsh-llm` however it is loaded -- not by absolute `file:///`
7
+ * URL, and not through a junction, since Node resolves ESM against the realpath. A package simply has
8
+ * to exist under a `node_modules` that reaches the harness. That is why the pilot could load the arm
9
+ * selector by URL but the judge cannot.
10
+ *
11
+ * node routing/plugin/dsh-router-laya/install.mjs <module-root>
12
+ *
13
+ * Copies (never links), so the two installs -- the isolated harness profile and the real profile --
14
+ * stay independent and either can be deleted without touching the other or this checkout.
15
+ *
16
+ * It also writes a `package.json` when one is missing: the real profile's module root is populated by
17
+ * pnpm and has no entry for a local plugin.
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const PACKAGE = 'dsh-router-laya';
24
+ const here = path.dirname(fileURLToPath(import.meta.url));
25
+ const target = process.argv[2];
26
+ if (!target) {
27
+ console.error('usage: node routing/plugin/dsh-router-laya/install.mjs <module-root>');
28
+ process.exit(2);
29
+ }
30
+
31
+ const dest = path.join(path.resolve(target), PACKAGE);
32
+ fs.mkdirSync(dest, { recursive: true });
33
+ // `package.json` is copied rather than synthesised: it now carries `dsh.client` and the `./client`
34
+ // export, which is what makes the browser half discoverable at all.
35
+ for (const file of ['index.js', 'client.js', 'cordis.patch.yml', 'package.json']) {
36
+ fs.copyFileSync(path.join(here, file), path.join(dest, file));
37
+ }
38
+ console.log(`installed ${PACKAGE} -> ${dest}`);
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "dsh-router-laya",
3
+ "version": "2.1.0",
4
+ "description": "Per-request tier routing on the Laya judge, plus an Auto tier chip in the composer",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/HapyRain/dsh-router-laya.git",
9
+ "directory": "routing/plugin/dsh-router-laya"
10
+ },
11
+ "type": "module",
12
+ "main": "index.js",
13
+ "bin": {
14
+ "dsh-router-laya": "bin/setup.mjs"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "client.js",
19
+ "cordis.patch.yml",
20
+ "install.mjs",
21
+ "bin",
22
+ "service",
23
+ "!**/__pycache__",
24
+ "!**/*.pyc",
25
+ "weights/fetch.mjs",
26
+ "weights/manifest.json",
27
+ "README.md",
28
+ "NOTICE",
29
+ "LICENSE"
30
+ ],
31
+ "exports": {
32
+ ".": "./index.js",
33
+ "./client": "./client.js",
34
+ "./cordis.patch.yml": "./cordis.patch.yml",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "engines": {
38
+ "node": ">=18",
39
+ "dsh": ">=0.1.0-rc.6"
40
+ },
41
+ "dsh": {
42
+ "bundle": {
43
+ "patch": "./cordis.patch.yml"
44
+ },
45
+ "client": {
46
+ "platform": "web"
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,324 @@
1
+ """微调路由模型的 laya判定层:6 个 noul -> 规则引擎 -> `low|high|max`。
2
+
3
+ **两套协议,不能混用。**
4
+
5
+ | | 基座 `convaiinnovations/laya` | 微调 `training/laya_router_finetuned` |
6
+ |---|---|---|
7
+ | 加载 | `laya.load(repo)` | `laya.Agent(local_dir)` |
8
+ | 问题 | `laya.router_questions()`:4 类 `score` + `choice` + 2 `noul` | 下面这 **6 个 `noul`** |
9
+ | 输出 | `difficulty` 0–3 的**期望值** | 6 个布尔 + 规则引擎 |
10
+ | 档位 | `trivial/easy/moderate/hard`(4 档,且顶档够不到) | **直接 `low/high/max`**(3 档) |
11
+
12
+ 拿基座的问题去问微调模型(或反过来)不会报错,只会得到**看着像模像样的垃圾**——所以走哪条路必须
13
+ 由代码决定,不能靠环境变量"碰运气"。
14
+
15
+ 微调路径**不需要归一化**:它直接吐 `low|high|max`,落档就是一张 1:1 对照表。之前为基座 4 分类头
16
+ 搭的那套分位归一化(`auto_mode_sim.resolve_norm`)在这条路上没有意义。
17
+
18
+ $env:LAYA_MODEL = 'D:\\tmp\\st\\laya\\training\\laya_router_finetuned'
19
+ .venv/Scripts/python.exe routing/demo_auto_mode.py
20
+
21
+ `compute_tier()` 的规则与权重是**策略**,只有这一个定义。原先 `training/` 里散着三份拷贝,规则互不相同
22
+ (`test_finetuned.py` / `generate_dataset.py` / `merge_final.py`),现在都改成从这里导入——那三份的权重
23
+ 与阈值跟运行时并不一致,等于用一套规则生成训练数据、再用另一套规则在运行时落档。
24
+
25
+ Q7 extension (2026-09, plan `docs/knob-cross-validation-product-plan.md` §3.2/§3.3): QUESTIONS gains
26
+ a seventh noul for the session-compounding axis — success requiring multiple *independent*
27
+ sub-results to all be correct (session failure ~ 1-(1-p)^N). Q1-Q6 (single-task structural
28
+ complexity) stay byte-identical; ids are a stable contract, so Q7 is appended, never inserted. The
29
+ deployed checkpoint has NOT been trained on Q7, so its Q7 answer is gated off in judge()
30
+ (`Q7_MODEL_SOURCE_READY`) until a Q7-fine-tuned checkpoint is deployed.
31
+ """
32
+ import time
33
+
34
+ # 6 个路由问题。ids 是稳定契约:规则引擎和权重表都按它们取值,改名等于改协议。
35
+ QUESTIONS = {
36
+ "Q1": {"type": "noul", "instructions": "To complete this request, would the assistant need to "
37
+ "change anything outside the conversation (modify files, "
38
+ "run code, call services)?"},
39
+ "Q2": {"type": "noul", "instructions": "Does this involve integration or architectural changes "
40
+ "spanning multiple modules, services, or phases?"},
41
+ "Q3": {"type": "noul", "instructions": "If an early step turns out wrong, would later steps be "
42
+ "affected (some steps must finish and be verified before "
43
+ "others start)?"},
44
+ "Q4": {"type": "noul", "instructions": "Does this require multi-step reasoning, solving "
45
+ "non-trivial constraints, or open-ended "
46
+ "diagnosis/optimization without clear acceptance criteria "
47
+ "(mathematics, logic, planning, algorithms, performance "
48
+ "debugging)?"},
49
+ "Q5": {"type": "noul", "instructions": "Does this involve reading, writing, or modifying source "
50
+ "code files?"},
51
+ "Q6": {"type": "noul", "instructions": "Does this require generating new content (code, analysis, "
52
+ "report, design) rather than retrieving or summarizing "
53
+ "existing information?"},
54
+ # Q7 (session-compounding axis, plan §3.2): about N *independent* sub-results that must ALL be
55
+ # correct — deliberately not "how complex is one task" (that is Q2/Q3/Q4). Appended last;
56
+ # Q1-Q6 text and order are a frozen contract (rule engine, weight table and fine-tune training
57
+ # all key off these ids).
58
+ "Q7": {"type": "noul", "instructions": "Does success require multiple independent sub-results "
59
+ "to all be correct (a batch of changes, edits across "
60
+ "several files, or a long sequence of items each "
61
+ "checked), so that any single wrong sub-result makes "
62
+ "the whole request fail?"},
63
+ }
64
+
65
+ # 只在规则 5/6 用得上:规则 1–4 只看布尔组合。所以调准确率的杠杆在规则顺序和 Q3 的阈值上,
66
+ # 不在权重表上。
67
+ # Q7 is deliberately absent: rule 0 is a hard pre-override (Q7 -> high, never max), not a weighted
68
+ # vote — its all-or-nothing semantics carry no magnitude here, the threshold lives upstream (N x p).
69
+ WEIGHTS = {"Q1": 0.18, "Q2": 0.18, "Q3": 0.12, "Q4": 0.29, "Q5": 0.09, "Q6": 0.14}
70
+
71
+ TIERS = ["low", "high", "max"]
72
+
73
+ # Q7 model-source gate: the deployed fine-tuned checkpoint was trained on Q1-Q6 only, so its Q7
74
+ # answer is exactly the "plausible garbage" the module docstring warns about. Keep False until a
75
+ # checkpoint fine-tuned with Q7 is deployed; flip to True to let the model's own noul answer feed
76
+ # labels. While False, judge() forces labels["Q7"] = False (key kept: the seven-key shape stays
77
+ # stable for the rule engine and its tests) and tiering is identical to the pre-Q7 six-rule
78
+ # behavior.
79
+ #
80
+ # ENABLED 2026-09-25: the laya-router-7q checkpoint (383-text corpus, majority-vote Q7 labels
81
+ # under ruling B, trained locally on the RTX 4070 SUPER) answers Q7 at 99.0% accuracy /
82
+ # kappa 0.956 vs backfill gold with no Q1-Q6 regression (training/q7_validation_report.json).
83
+ Q7_MODEL_SOURCE_READY = True
84
+
85
+ # 意图解析已移到 intent_parser.py(Phase 0: 词典锚点 + 否定作用域 + 约束代数)。
86
+ # laya判定只调 parse_intent / apply_constraint,不自己做正则匹配。
87
+
88
+
89
+ def compute_tier(labels):
90
+ """六条有序规则:布尔组合 -> 档位。顺序即优先级,先命中先返回。
91
+
92
+ 实测 11 条里错的两条都由 Q3(步骤依赖)翻掉决定:`重构 microservices` 因 Q3=NO 落到规则 4、
93
+ `部署 EC2` 因 Q3=YES 命中规则 2。想提准确率先动这两条规则的边界或 Q3 的阈值。
94
+
95
+ Rule 0 (Q7, prepended 2026-09 before the six legacy rules): a YES on Q7 — success requires
96
+ multiple *independent* sub-results to all be correct (batch changes, multi-file edits, a long
97
+ checked sequence) — returns "high" immediately. Rationale (plan
98
+ `docs/knob-cross-validation-product-plan.md` §3.2): this is the session-compounding axis,
99
+ session failure ~ 1-(1-p)^N with p the single-task slip rate; whether that crosses the
100
+ escalation threshold is decided upstream from N x p, so this layer only consumes the boolean
101
+ and floors the tier at high. Q7 never yields max (§3.3: max stays the escalation ladder's
102
+ second rung; the feed-forward never predicts max). `labels.get("Q7")` keeps six-key dicts from
103
+ pre-Q7 callers behaving exactly as before.
104
+ """
105
+ q1, q2, q3, q4, q5, q6 = (labels[q] for q in ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6"])
106
+
107
+ # Rule 0: Q7 compounding trigger -> high, never max. Rationale in the docstring above.
108
+ if labels.get("Q7"):
109
+ return "high"
110
+
111
+ # 规则1: 纯推理(Q4 无代码/副作用)→ max
112
+ if q4 and not q1:
113
+ return "max"
114
+
115
+ # 规则2: 多模块依赖链(Q2+Q3)→ max
116
+ if q2 and q3:
117
+ return "max"
118
+
119
+ # 规则3: 有代码+推理但无跨模块(Q1+Q4+Q5,单文件调试/算法)→ high
120
+ if q1 and q4 and q5 and not q2:
121
+ return "high"
122
+
123
+ # 规则4: Q2 跨模块但无强依赖(部署/配置类)→ high
124
+ if q2 and not q3:
125
+ return "high"
126
+
127
+ # 规则5: 有副作用(Q1)→ 至少 high,加权够高则 max
128
+ if q1:
129
+ score = sum(WEIGHTS[q] for q in WEIGHTS if labels[q])
130
+ if score >= 0.60:
131
+ return "max"
132
+ return "high"
133
+
134
+ # 规则6: 基础加权
135
+ score = sum(WEIGHTS[q] for q in WEIGHTS if labels[q])
136
+ if score >= 0.40:
137
+ return "high"
138
+ return "low"
139
+
140
+
141
+ def load(path, device=None):
142
+ """The fine-tune is a local directory, so it loads through `Agent` rather than `laya.load`."""
143
+ import laya
144
+
145
+ return laya.Agent(path, device=device) if device else laya.Agent(path)
146
+
147
+
148
+ # ── C3 escalation (plugin v2, docs/plugin-v2-plan.md §3.1) ──────────────────────────────
149
+ # Strategy-layer concern, deliberately NOT in intent_parser: the frozen dictionary answers
150
+ # "what tier does the user want"; regenerate answers "did the previous turn fail". Two
151
+ # different constructs (Q-decisions freeze covers the dictionary only).
152
+
153
+ def escalate(tier):
154
+ """One rung up the C3 ladder, capped at max (the feed-forward never predicts max; max is
155
+ reachable only through escalation).
156
+
157
+ Non-TIERS rungs happen: prev_tier can be a qwen `medium` (see the M1 note in judge()).
158
+ Map the known one to its nearest TIERS rung; anything unknown returns UNCHANGED so the
159
+ caller's off-ladder floor check simply ignores it -- never crash, never invent a rung
160
+ (deepseek cross-review F2).
161
+ """
162
+ return {"low": "high", "high": "max", "max": "max", "medium": "high"}.get(tier, tier)
163
+
164
+
165
+ def _floor_tier(a, b):
166
+ """The higher of two TIERS rungs; an off-ladder `b` is ignored (returns `a`)."""
167
+ if b in TIERS and TIERS.index(b) > TIERS.index(a):
168
+ return b
169
+ return a
170
+
171
+
172
+ _REGENERATE_WORDS = ("重新", "再来", "重试", "again", "regenerate", "redo")
173
+ # Correction words are NOT retries on their own ("不对,我是说 X" is a clarification) -- they
174
+ # count only when followed by an explicit retry verb (deepseek cross-review F8: a mis-fired
175
+ # escalation is permanent on a monotonic ladder, so the trigger must be conservative).
176
+ _REGENERATE_CORRECTIONS = ("不对", "还是错")
177
+
178
+
179
+ def is_regenerate(text, prev_task):
180
+ """Whether THIS message retries the PREVIOUS task (C3 escalation signal).
181
+
182
+ Two criteria, either fires:
183
+ 1. similarity: normalized prev_task equals or prefixes normalized text (DSH resend and
184
+ the refine-after-dissatisfaction shape; the known bias -- an extended new task
185
+ escalates one rung -- is accepted product semantics, plan §6);
186
+ 2. retry phrasing at the head of the message, with corrections requiring an explicit
187
+ retry verb.
188
+ None/empty prev_task is always False: turn 1 must never crash into the silent fail-safe
189
+ (deepseek cross-review F3).
190
+ """
191
+ if not text or not prev_task:
192
+ return False
193
+
194
+ def norm(s):
195
+ return "".join(s[:2000].lower().split())
196
+
197
+ t, p = norm(text), norm(prev_task)
198
+ if t == p or t.startswith(p):
199
+ return True
200
+ head = t[:12]
201
+ if any(head.startswith(w) for w in _REGENERATE_WORDS):
202
+ return True
203
+ if any(head.startswith(c) for c in _REGENERATE_CORRECTIONS) and \
204
+ any(w in t for w in _REGENERATE_WORDS):
205
+ return True
206
+ return False
207
+
208
+
209
+ def judge(agent, text, prev_tier=None, prev_task=None):
210
+ """One task -> one tier plus the label booleans that produced it (Q1-Q7; Q7 model-gated by
211
+ `Q7_MODEL_SOURCE_READY`).
212
+
213
+ 三层决策:
214
+ Phase 0: 意图解析(词典锚点 + 否定作用域 + 继承检测,零延迟)
215
+ → force → 直接定档(一票否决)
216
+ → inherit → 保持 prev_tier("继续"等短指令不降档)
217
+ → exclude → 进入 Laya 判断,用约束过滤 base_tier
218
+ Phase 1: Laya 7 题判断 → 规则引擎(含规则 0)→ base_tier
219
+ C3: regenerate 检测 → base_tier 抬底到 escalate(prev_tier)(升级先行)
220
+ 融合: apply_constraint(base_tier, intent, prev_tier) —— 约束代数是最后一道闸,
221
+ 升级结果同样被 exclude 过滤("别用 max" 压得住升级;交叉评审 F1/F5)
222
+
223
+ prev_tier: 上一轮的档位,inherit 意图时使用。None = 首轮。
224
+ prev_task: 上一轮的任务文本(plugin v2 会话状态),regenerate 检测用。None = 无历史。
225
+ """
226
+ # ── Phase 0: 意图解析(零延迟,词典+否定+继承)──
227
+ from intent_parser import parse_intent, apply_constraint
228
+ intent = parse_intent(text)
229
+
230
+ # force 意图 = 一票否决,不走 Laya
231
+ if intent is not None and intent["op"] == "force":
232
+ return {
233
+ "tier": intent["tier"],
234
+ "labels": {},
235
+ "probs": {},
236
+ "yes": [],
237
+ "ms": 0,
238
+ "triggered_by": "intent_force",
239
+ "regenerate": False,
240
+ "intent": intent,
241
+ }
242
+
243
+ # inherit 意图 = 保持上一轮档位,不走 Laya。
244
+ # Echo prev_tier VERBATIM (M1): the host stores the rung's effort as prev_tier, which on a
245
+ # non-TIERS ladder (qwen `medium`, …) is outside low|high|max. Host validates against
246
+ # TIERS ∪ current-ladder efforts (tierIsLegal), not TIERS alone — do not clamp here.
247
+ if intent is not None and intent["op"] == "inherit":
248
+ return {
249
+ "tier": prev_tier or "low", # 没有上一轮 → 回退 low
250
+ "labels": {},
251
+ "probs": {},
252
+ "yes": [],
253
+ "ms": 0,
254
+ "triggered_by": "intent_inherit",
255
+ "regenerate": False,
256
+ "intent": intent,
257
+ "prev_tier": prev_tier,
258
+ }
259
+
260
+ # ── Phase 1: Laya 6 题判断 ──
261
+ t0 = time.time()
262
+ result = agent.predict(text, QUESTIONS)
263
+ labels = {}
264
+ probs = {}
265
+ for qid in QUESTIONS:
266
+ ans = result["answers"][qid]
267
+ probs[qid] = ans["noul"]
268
+ labels[qid] = ans["noul"] >= 0.5
269
+ if not Q7_MODEL_SOURCE_READY:
270
+ # Untrained Q7 answer suppressed; probs keep the raw value for diagnostics only.
271
+ labels["Q7"] = False
272
+ base_tier = compute_tier(labels)
273
+
274
+ # ── C3 escalate: a detected retry floors base_tier at escalate(prev_tier) (plan §3.1).
275
+ # Escalation runs BEFORE apply_constraint so the constraint algebra stays the LAST gate --
276
+ # an exclusion ("别用 max") must bind the escalated tier too (deepseek review F1/F5).
277
+ # Off-ladder escalate results (unknown prev_tier) are ignored by _floor_tier.
278
+ # `regenerate` is reported even when the floor does not move the tier (diagnostic: the
279
+ # plugin log should show that a retry was seen), while triggered_by names what actually
280
+ # determined the tier.
281
+ regen_detected = prev_tier is not None and is_regenerate(text, prev_task)
282
+ escalated = False
283
+ if regen_detected:
284
+ floored = _floor_tier(base_tier, escalate(prev_tier))
285
+ escalated = floored != base_tier
286
+ base_tier = floored
287
+
288
+ # ── 融合: 约束过滤(exclude 等)──
289
+ final_tier = apply_constraint(base_tier, intent, prev_tier)
290
+
291
+ # Phase 1 always ran here. Only a real `exclude` constraint filtered base_tier;
292
+ # `op=none` (and any future fall-through) is the content judgment speaking for
293
+ # itself. P0 made parse_intent always return a dict, so the old
294
+ # `"intent_exclude" if intent else "laya"` was truthy for none and mislabelled it.
295
+ op = intent.get("op") if isinstance(intent, dict) else None
296
+ if escalated:
297
+ triggered_by = "escalate_regenerate"
298
+ elif op == "exclude":
299
+ triggered_by = "intent_exclude"
300
+ else:
301
+ triggered_by = "laya"
302
+ return {
303
+ "tier": final_tier,
304
+ "base_tier": base_tier,
305
+ "labels": labels,
306
+ "probs": probs,
307
+ "yes": [q for q in QUESTIONS if labels[q]],
308
+ "ms": round((time.time() - t0) * 1000),
309
+ "triggered_by": triggered_by,
310
+ "regenerate": regen_detected,
311
+ "intent": intent,
312
+ }
313
+
314
+
315
+ def rung_index(tier, ladder):
316
+ """`low|high|max` -> rung index on the single ladder.
317
+
318
+ 单线三档(已砍掉经济/质量两线和 off 档):
319
+ low=#0, high=#1, max=#2。直接位置映射,不需要归一化。
320
+ Clamped,因为短阶梯不能超过自身长度。
321
+ """
322
+ if not ladder:
323
+ return None
324
+ return min(len(ladder) - 1, max(0, TIERS.index(tier)))