cklxx commited on
Commit
adf912b
·
verified ·
1 Parent(s): ec2cfc6

laya-browser v10 / v10s: laya fine-tuned as a browser-agent decision head + code + results

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. README.md +111 -0
  3. code/README.md +35 -0
  4. code/apps/BROWSER_AGENT.md +34 -0
  5. code/apps/browser_diag.py +56 -0
  6. code/apps/browser_suite.py +88 -0
  7. code/apps/browser_task.py +18 -0
  8. code/apps/common.py +61 -0
  9. code/apps/fast_batch.py +89 -0
  10. code/apps/hn_radar.py +44 -0
  11. code/apps/inbox_triage.py +43 -0
  12. code/apps/moderator.py +47 -0
  13. code/apps/profile_step.py +17 -0
  14. code/apps/systemone_server.py +169 -0
  15. code/apps/web_console.py +71 -0
  16. code/env.sh +6 -0
  17. code/finetune/README.md +83 -0
  18. code/finetune/build_items.py +67 -0
  19. code/finetune/calibrate.py +43 -0
  20. code/finetune/collect_pages.py +107 -0
  21. code/finetune/common_ft.py +77 -0
  22. code/finetune/convert_mind2web.py +116 -0
  23. code/finetune/dagger.py +121 -0
  24. code/finetune/eval.py +33 -0
  25. code/finetune/gen_goals.py +85 -0
  26. code/finetune/gen_step2.py +50 -0
  27. code/finetune/make_done_cases.py +47 -0
  28. code/finetune/rollouts.py +109 -0
  29. code/finetune/run_after_v6.sh +30 -0
  30. code/finetune/run_all.sh +16 -0
  31. code/finetune/run_final.sh +20 -0
  32. code/finetune/run_gated.sh +17 -0
  33. code/finetune/run_train.sh +9 -0
  34. code/finetune/run_v10.sh +32 -0
  35. code/finetune/run_v10s.sh +24 -0
  36. code/finetune/run_v2.sh +11 -0
  37. code/finetune/run_v3.sh +11 -0
  38. code/finetune/run_v4.sh +13 -0
  39. code/finetune/run_v5.sh +14 -0
  40. code/finetune/run_v6.sh +14 -0
  41. code/finetune/run_v7.sh +20 -0
  42. code/finetune/run_v8.sh +18 -0
  43. code/finetune/run_v9.sh +29 -0
  44. code/finetune/train.py +73 -0
  45. code/jev-ultrafast.patch +536 -0
  46. code/kernels/bench_fast.py +110 -0
  47. code/kernels/fast.py +195 -0
  48. code/kernels/test_fast.py +85 -0
  49. code/kernels/tl_kernels.py +214 -0
  50. results/fast_english_rtx4070.json +93 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ v10s/tokenizer/tokenizer.json filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: convaiinnovations/laya
4
+ language:
5
+ - en
6
+ - multilingual
7
+ tags:
8
+ - laya
9
+ - system-1
10
+ - browser-agent
11
+ - web-navigation
12
+ - decision-model
13
+ - modernbert
14
+ - mind2web
15
+ - tilelang
16
+ datasets:
17
+ - osunlp/Mind2Web
18
+ ---
19
+
20
+ # laya-browser — laya fine-tuned as a browser-agent decision head (drop-in replacement for TypeSafe Jev)
21
+
22
+ **laya** ([convaiinnovations/laya](https://huggingface.co/convaiinnovations/laya)) is a non-autoregressive "System 1" decision model:
23
+ one bidirectional encoder pass answers several typed questions (`choice` / `score` / `noul`) with calibrated probabilities, no text generation.
24
+ Out of the box it is near chance at browser decisions ("which element should I click for this goal?" — top-1 0.10 among ~45 candidates).
25
+
26
+ This repo is what it took to turn it into a usable decision head for [browser-use/jev-ultrafast](https://github.com/browser-use/jev-ultrafast),
27
+ whose `/v1/systemone` request format is identical to laya's `predict(state, questions)`. Everything was done locally on one RTX 4070 Ti SUPER (16 GB),
28
+ no paid API: the text helper and the DAgger teacher are a local Qwen3-8B-AWQ served by sglang.
29
+
30
+ ## What changed relative to the original laya
31
+
32
+ | | original laya (typed-decisions) | this repo |
33
+ |---|---|---|
34
+ | browser decision quality (16 real tasks × 3 runs) | 0 % | **v10: 58 %**, v10s: 50 % |
35
+ | element top-1 on held-out pages (2,734 decisions, ~45 candidates) | 0.10 | **0.66** (v10), 0.63 (v10s) |
36
+ | operation accuracy (CLICK / TYPE_TEXT / SELECT / DONE) | 0.54 | 0.88 |
37
+ | latency per browser step (3 questions, 30–65 candidates) | 50–200 ms | 41–50 ms (v10), **17–23 ms** (v10s) |
38
+ | backbone | ModernBERT-large 421M | v10: same; v10s: mmBERT-base 322M |
39
+ | input format | jev's state verbatim (element table as JSON inside the state, truncated by the 1024-token window) | **format v2/v3**: elements live only in the option list (full label + role + current value), state keeps title / URL / history / 1.2–1.5k chars of text, `head_max_len` 512 → 768 |
40
+ | training data | LocalLLaMA/typed-decisions | 5,244 reverse-generated goals on 421 crawled pages (Qwen writes "the goal a user would state to need this element"), 700 real DONE states (clicks actually executed), 659 step-2 negatives, [Mind2Web](https://huggingface.co/datasets/osunlp/Mind2Web) train (7,296 steps, candidates re-rendered as an element table), 177 on-policy DAgger corrections |
41
+ | training | — | laya's RLCD recipe (noisy-logit policy gradient + soft CE), single GPU, no gradient checkpointing, 4 epochs (~2 h for v10, ~1 h for v10s), post-hoc temperature |
42
+ | inference | HF eager + autocast | optional TileLang fast path ([PR #25 to laya](https://github.com/NandhaKishorM/laya/pull/25)): fused GEMM/GEGLU/LayerNorm/RoPE, sliding-window flash attention, bf16-resident weights, CUDA graphs — 4–5× lower per-call latency, identical answers |
43
+
44
+ ### Things that did **not** work (so you don't repeat them)
45
+ - Templated DONE goals ("Open the page titled X, stop once it is open") leak phrasing: the model learns *stop when ⇒ DONE*. DONE samples must be real landing pages after an executed action.
46
+ - If every DONE sample has exactly one prior action and every click sample has none, the model learns *any history ⇒ DONE*. Add mid-task negatives (step-2 goals on landing pages).
47
+ - Mind2Web alone kills DONE / TYPE_TEXT (no DONE there, CLICK dominates): re-weight rare operations (DONE ×4, TYPE_TEXT/SELECT ×3).
48
+ - Cutting page text to 3,000 chars saved nothing (the sequence is dominated by the head) and cost 0.04 top-1.
49
+ - `torch.compile` on variable-length batches recompiles per shape: 6× slower.
50
+ - Confidence-gated escalation to Qwen3-8B (System 2) made things *worse* (58 % → 42 %): on these pages the fine-tuned 322M/421M model is a better decider than an 8B general LLM. Use a stronger System 2 or none.
51
+ - jev's DOM reader hides password fields by design (login tasks are impossible) and never sees collapsed menus (Wikipedia's "Random article").
52
+
53
+ ### What still fails
54
+ Multi-step "type then submit / pick a suggestion" flows, anything that needs scrolling first (the training data had no scroll samples), `<select>` on unseen sites, and long flows like Google Flights. Scripted trajectories for scroll / search-submit / select are the next data step (`code/finetune/rollouts.py`).
55
+
56
+ ## Files
57
+
58
+ ```
59
+ v10/ ModernBERT-large 421M, format v2, head_max_len 768 (best accuracy)
60
+ v10s/ mmBERT-base 322M, format v3, head_max_len 768 (17–23 ms per step)
61
+ code/ finetune pipeline, laya systemone server, task suite, TileLang kernels, jev-ultrafast patch
62
+ results/ per-run suite JSONs and logs behind every number above
63
+ ```
64
+ Each checkpoint is a laya checkpoint directory (`model.safetensors`, `encoder/`, `tokenizer/`, `rl_agent_config.json`); the config records
65
+ `laya_fmt` and `head_max_len_train` so the server applies the matching input format automatically.
66
+
67
+ ## Use
68
+
69
+ ```bash
70
+ huggingface-cli download cklxx/laya-browser --local-dir laya-browser
71
+ cd laya-browser/code && uv sync --extra fast # pinned uv.lock (Python 3.12, torch 2.11, tilelang 0.1.14)
72
+ uv run python verify.py v10s # downloads v10s if needed, answers one recorded browser step
73
+ uv run python verify.py v10s --fast # same through the TileLang fast path
74
+ ```
75
+ Verified from a clean environment on 2026-09-21 (RTX 4070 Ti SUPER): `TYPE_TEXT → [2] Search Wikipedia (searchbox)`, 35 ms per step
76
+ stock / 28 ms with the fast path on a 65-option, 2.5k-token step. Extras: `--extra data` (Mind2Web conversion, dataset eval),
77
+ `--extra browser` (live suite / crawling; also needs jev-ultrafast with `code/jev-ultrafast.patch` applied and a Chromium with
78
+ `--remote-debugging-port=9222`).
79
+
80
+ ```python
81
+ import laya
82
+ agent = laya.load("laya-browser/v10s") # a local laya checkpoint dir
83
+ agent.cfg["head_max_len"] = agent.cfg["head_max_len_train"]
84
+ # state / questions exactly as jev-ultrafast's model.choose() builds them, after the format-v3 transform in code/apps/systemone_server.py
85
+ result = agent.predict(state, questions)
86
+ ```
87
+
88
+ As a TypeSafe replacement for jev-ultrafast:
89
+
90
+ ```bash
91
+ # in code/: laya systemone-compatible server (format transform + optional gating + DAgger logging)
92
+ python apps/systemone_server.py 8791 /path/to/laya-browser/v10s 999
93
+ # in jev-ultrafast (apply code/jev-ultrafast.patch): TYPESAFE_BASE_URL=http://127.0.0.1:8791
94
+ ```
95
+
96
+ `code/apps/browser_suite.py` runs the 16-task real-browser suite with automatic outcome checks (`REPEATS=3`).
97
+
98
+ ## Reproduce
99
+
100
+ `code/finetune/README.md` documents every step (crawl → reverse-generate goals → execute clicks for DONE → step-2 → Mind2Web conversion →
101
+ DAgger → build → train → calibrate → eval → suite) with the exact scripts (`run_v10.sh`, `run_v10s.sh`, `run_final.sh`) and all intermediate
102
+ numbers from v1 to v10s.
103
+
104
+ ## GPU cost
105
+
106
+ v10s: ~0.65 GB weights, ~1.5 GB VRAM resident with CUDA graphs, 17–23 ms per 3-question browser step, 3 ms for a single-question call.
107
+ v10: ~0.85 GB weights, ~1.8 GB VRAM, 41–50 ms per step. The Qwen text helper (only needed for TYPE_TEXT values) is separate.
108
+
109
+ ## License
110
+
111
+ Apache-2.0, same as laya. Mind2Web is used under its own license for training only.
code/README.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Laya 本机加速 + 应用
2
+
3
+ [convaiinnovations/laya](https://huggingface.co/convaiinnovations/laya):非自回归“System 1”决策模型(ModernBERT/mmBERT 编码器 + 决策头),
4
+ 一次前向同时回答多个 `choice / score / noul` 问题,输出校准概率,不生成文本。
5
+
6
+ ## 环境
7
+
8
+ ```fish
9
+ source env.sh # HF 镜像 + 清华 PyPI + 关闭代理
10
+ .venv/bin/python ... # Python 3.12, torch 2.11+cu130, tilelang 0.1.14
11
+ ```
12
+
13
+ ## 应用(apps/)
14
+
15
+ | 脚本 | 说明 |
16
+ |---|---|
17
+ | `apps/inbox_triage.py [正文]` | 多语言工单分诊:部门 / 紧急度 / 流失风险 / 情绪 |
18
+ | `apps/moderator.py [file]` | 评论审核台:有害 / 垃圾 / 主题 / 严重度,按风险排序 |
19
+ | `apps/hn_radar.py [N]` | 拉 Hacker News 热帖实时打标签 |
20
+ | `apps/web_console.py [port]` | 零依赖 Web 决策台,自定义问题 JSON,实时概率条 |
21
+
22
+ 在任何应用里加两行即可启用加速:
23
+ ```python
24
+ import sys; sys.path.insert(0, "kernels")
25
+ from fast_laya import accelerate; accelerate(agent)
26
+ ```
27
+
28
+ ## TileLang 加速(kernels/)
29
+
30
+ * `tl_kernels.py` — GEMM(+bias/act 融合)、GEMM+GEGLU 融合、残差+LayerNorm 融合、原地 RoPE、
31
+ 带 padding 掩码与滑动窗口的 flash attention。行数 M 为运行时符号,每个 kernel 只编译一次。
32
+ * `fast_laya.py` — 用上述 kernel 重写整个编码器 + 决策头前向,bf16 权重常驻,按 (batch, L) 桶捕获 CUDA Graph。
33
+ `accelerate(agent)` 原地替换 `agent.model.forward`。
34
+ * `test_kernels.py` / `verify_and_bench.py` — 单 kernel 对拍、端到端数值一致性与延迟对比。
35
+ * `tune.py` — tile 参数扫描。
code/apps/BROWSER_AGENT.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # laya 作为浏览器 agent 的决策模型(替代 TypeSafe Jev)
2
+
3
+ [jev-ultrafast](https://github.com/browser-use/jev-ultrafast) 的 `/v1/systemone` 请求/响应格式与 `laya.predict(state, questions)` 完全一致,
4
+ 所以本地 laya(+TileLang 加速)可以直接替代 TypeSafe API。克隆在 `../jev-ultrafast`,`model.py` 已打补丁支持
5
+ `TYPESAFE_BASE_URL` 和 `TEXT_MODEL_EXTRA_JSON`。
6
+
7
+ ```fish
8
+ # 1. 无头 Chromium(远程调试端口 9222)
9
+ chromium --headless=new --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-profile about:blank &
10
+ # 2. 本地 laya systemone 服务(typed | multilingual | english;第 3 个参数是选项分块宽度)
11
+ source env.sh; .venv/bin/python apps/systemone_server.py 8791 typed 12 &
12
+ # 3. TYPE_TEXT 用本地 sglang + Qwen(可选)
13
+ HF_HUB_OFFLINE=1 ~/sglang-venv/bin/python -m sglang.launch_server --model-path Qwen/Qwen3-8B-AWQ --port 30000 \
14
+ --mem-fraction-static 0.5 --context-length 8192 --reasoning-parser qwen3 &
15
+ # 4. 跑任务
16
+ cd ../jev-ultrafast; .venv/bin/python ../laya/apps/browser_task.py "https://en.wikipedia.org/wiki/Main_Page" "Click the 'Random article' link."
17
+ # 诊断:真实元素表上直接问 laya 该点哪个(格式 jev|compact,后面是打乱平均次数)
18
+ .venv/bin/python ../laya/apps/browser_diag.py compact 4
19
+ ```
20
+
21
+ `systemone_server.py` 做了两件适配:把 jev 的 dict 型元素描述压成一行字符串;超过 N 个选项的 choice 问题
22
+ 分块粗筛 + 决赛(两次前向),53 个元素约 150 ms。
23
+
24
+ ## 结论(2026-09-20,零样本,Wikipedia 首页 46 个可点元素)
25
+
26
+ | checkpoint | jev 原格式 top-1 | 精简格式 top-1 | 精简+4 次打乱平均 top-1 | 平均排名 |
27
+ |---|---|---|---|---|
28
+ | laya (english) | 0/3 | 0/3 | 1/3 | 2.7 / 46 |
29
+ | laya-multilingual | 0/3 | 0/3 | 0/3 | 5.3 / 46 |
30
+ | laya-typed-decisions | 0/3 | 1/3 | 1/3 | 3.7 / 46 |
31
+
32
+ 管线(Chromium → browser-harness → jev 循环 → 本地 laya)跑通且每步 60-150 ms,但三个 checkpoint 零样本都
33
+ 无法可靠选中目标元素(强烈偏向第 1 个选项),端到端任务全部失败。这与 laya 自己的说明一致:typed-decisions
34
+ 零样本 0.36,需要针对任务微调。要真正替代 Jev,下一步是用 Qwen 当 teacher 在录制的页面状态上生成决策数据,微调 laya。
code/apps/browser_diag.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Direct diagnostic: can laya pick the right element on a real Wikipedia element table, under different prompt formats?"""
2
+ import json, os, random, sys, time
3
+ import httpx
4
+ sys.path.insert(0, "/home/ckl/projects/S/jev-ultrafast")
5
+ os.environ["BU_CDP_URL"] = "http://127.0.0.1:9222"
6
+ from jev_ultrafast.browser import Browser
7
+ from jev_ultrafast.model import action_space
8
+ from jev_ultrafast.questions import NEXT_ACTION, TARGET
9
+
10
+ S1 = "http://127.0.0.1:8791/v1/systemone"
11
+ page = None
12
+ def get_page():
13
+ global page
14
+ if page is None:
15
+ b = Browser("https://en.wikipedia.org/wiki/Main_Page"); page = b.observe(screenshot=False); b.close()
16
+ return page
17
+
18
+ GOALS = [("Click the 'Random article' link in the navigation.", "Random article"),
19
+ ("Open the site's search box so a query can be typed.", "Search"),
20
+ ("Log in to Wikipedia.", "Log in"),
21
+ ("Open the Talk page for the main page.", "Talk"),
22
+ ("Read the community portal.", "Community portal")]
23
+
24
+ def ask(state, questions):
25
+ r = httpx.post(S1, json={"model": "x", "state": state, "questions": questions}, timeout=120).json()
26
+ return r["answers"]
27
+
28
+ def run(fmt, K=1):
29
+ p = get_page(); elements, targets, controls = action_space(p["actions"])
30
+ cands = targets["CLICK"]
31
+ hits, ranks = 0, []
32
+ for goal, want in GOALS:
33
+ gold = [i for i, a in cands.items() if a["label"].split(" → ")[0].strip().lower() == want.lower()]
34
+ if not gold: print(" no gold for", want); continue
35
+ if fmt == "jev":
36
+ crit = {i: {"element": f"[{i}] {a['label']}", "current_value": a.get("current_value", a.get("value", "")), **{k: a[k] for k in ("role", "checked", "selected", "expanded") if k in a}} for i, a in cands.items()}
37
+ state = {"page": {k: p[k] for k in ("url", "title", "text")}, "elements": elements, "recent_actions": []}
38
+ ins = {"goal": goal, "operation": "CLICK", "rules": [NEXT_ACTION, TARGET]}
39
+ else:
40
+ crit = {i: a["label"].split(" → ")[0][:60] for i, a in cands.items()}
41
+ state = {"goal": goal, "page_title": p["title"], "url": p["url"]}
42
+ ins = f"Goal: {goal} Which element should be clicked next?"
43
+ probs = {i: 0.0 for i in crit}
44
+ for k in range(K):
45
+ keys = list(crit); random.Random(k).shuffle(keys) if K > 1 else None
46
+ a = ask(state, {"t": {"type": "choice", "instructions": ins, "criteria": {i: crit[i] for i in keys}}})["t"]
47
+ for i, v in a["probabilities"].items(): probs[i] += v / K
48
+ order = sorted(probs, key=probs.get, reverse=True)
49
+ rank = min(order.index(g) for g in gold) + 1
50
+ ranks.append(rank); hits += rank == 1
51
+ print(f" {want:18s} gold={gold} top={order[:3]} p_gold={max(probs[g] for g in gold):.3f} rank={rank}")
52
+ print(f" => top1 {hits}/{len(ranks)} mean rank {sum(ranks)/len(ranks):.1f} of {len(cands)}")
53
+
54
+ fmt = sys.argv[1]; K = int(sys.argv[2]) if len(sys.argv) > 2 else 1
55
+ print(f"== format={fmt} permutations={K} variant={httpx.get('http://127.0.0.1:8791/').json()['variant']}")
56
+ run(fmt, K)
code/apps/browser_suite.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Real-task suite for the browser agent with automatic outcome checks (URL / title / page text).
2
+
3
+ python apps/browser_suite.py [name-filter] (services: chromium 9222, laya systemone 8791, sglang 30000)
4
+
5
+ Prints one line per task: PASS/FAIL, steps, wall time, and a summary table.
6
+ """
7
+ import json, os, re, sys, time
8
+ sys.path.insert(0, "/home/ckl/projects/S/jev-ultrafast")
9
+ os.environ.update(BU_CDP_URL="http://127.0.0.1:9222", TYPESAFE_BASE_URL="http://127.0.0.1:8791", TYPESAFE_API_KEY="local",
10
+ TEXT_MODEL_API_KEY="local", TEXT_MODEL_BASE_URL="http://127.0.0.1:30000/v1", TEXT_MODEL="Qwen/Qwen3-8B-AWQ",
11
+ TEXT_MODEL_EXTRA_JSON='{"chat_template_kwargs": {"enable_thinking": false}}')
12
+ from jev_ultrafast import Agent
13
+
14
+ # (name, url, goal, check(url, title, text) -> bool)
15
+ TASKS = [
16
+ # NOTE: Wikipedia's 'Random article' link sits in a collapsed menu that the DOM reader never observes -> search task instead
17
+ ("wiki-einstein", "https://en.wikipedia.org/wiki/Main_Page", "Open the Wikipedia article about Albert Einstein.",
18
+ lambda u, t, x: "Albert_Einstein" in u),
19
+ ("wiki-search", "https://en.wikipedia.org/wiki/Main_Page", "Search Wikipedia for 'Python programming language' and open the article about the Python language.",
20
+ lambda u, t, x: "Python" in t),
21
+ ("hn-new", "https://news.ycombinator.com/", "Open the 'new' page that lists the newest submissions.",
22
+ lambda u, t, x: u.rstrip("/").endswith("/newest")),
23
+ ("hn-login-page", "https://news.ycombinator.com/", "Go to the login page.",
24
+ lambda u, t, x: "login" in u),
25
+ ("gh-issues", "https://github.com/tile-ai/tilelang", "Open the Issues tab of this repository.",
26
+ lambda u, t, x: "/issues" in u),
27
+ ("py-downloads", "https://www.python.org/", "Go to the Downloads page.",
28
+ lambda u, t, x: "/downloads" in u),
29
+ ("books-travel", "https://books.toscrape.com/", "Open the 'Travel' category.",
30
+ lambda u, t, x: "travel" in u),
31
+ ("books-open-book", "https://books.toscrape.com/", "Open the product page of the book 'A Light in the Attic'.",
32
+ lambda u, t, x: "a-light-in-the-attic" in u),
33
+ # NOTE: jev's snapshot.js hides password fields by design, so password logins are impossible in this framework.
34
+ ("internet-dropdown", "https://the-internet.herokuapp.com/dropdown", "Select 'Option 2' in the dropdown.",
35
+ lambda u, t, x: False), # checked via page state below
36
+ ("internet-checkbox", "https://the-internet.herokuapp.com/checkboxes", "Tick the first checkbox.",
37
+ lambda u, t, x: False),
38
+ ("books-page2", "https://books.toscrape.com/", "Go to page 2 of the catalogue.",
39
+ lambda u, t, x: "page-2" in u),
40
+ ("quotes-tag-love", "https://quotes.toscrape.com/", "Show the quotes tagged 'love'.",
41
+ lambda u, t, x: "/tag/love" in u),
42
+ ("hn-past", "https://news.ycombinator.com/", "Open the 'past' page (front pages from previous days).",
43
+ lambda u, t, x: "/front" in u),
44
+ ("ddg-search", "https://duckduckgo.com/", "Search for 'tilelang github' and show the results.",
45
+ lambda u, t, x: "q=" in u and "tilelang" in u.lower()),
46
+ ("arxiv-search", "https://arxiv.org/", "Search arXiv for 'flash attention' papers and show the results list.",
47
+ lambda u, t, x: "search" in u and "flash" in u.lower()),
48
+ ("flights", "https://www.google.com/travel/flights?hl=en", "Find one-way flights from Zurich to London on September 28, 2026, for one adult in economy. Stop when matching flight options are visible.",
49
+ lambda u, t, x: ("ZRH" in x or "Zurich" in x or "Zürich" in x) and "London" in x and re.search(r"\b\d{1,2}:\d{2}\b", x) is not None and "one way" in x.lower()),
50
+ ]
51
+
52
+ def run(name, url, goal, check, max_steps=20):
53
+ t0 = time.time(); steps = 0; status = "error"; page = None
54
+ try:
55
+ with Agent(url, goal) as agent:
56
+ page = agent.state["page"]
57
+ for state in agent.run():
58
+ steps = len(state["history"]); status = state["status"]; page = state["page"]
59
+ if steps >= max_steps: break
60
+ except Exception as e:
61
+ status = f"error:{type(e).__name__}"
62
+ wall = time.time() - t0
63
+ ok = bool(page and check(page["url"], page["title"], page.get("text", "")))
64
+ if page and name == "internet-dropdown":
65
+ ok = any(a.get("kind") == "select" and a.get("current_value") == "2" for a in page["actions"]) or \
66
+ any(a.get("kind") == "select" and a.get("value") == "2" and a.get("selected") for a in page["actions"])
67
+ if page and name == "internet-checkbox":
68
+ cbs = [a for a in page["actions"] if a.get("role") == "checkbox"]
69
+ ok = bool(cbs) and bool(cbs[0].get("checked"))
70
+ return ok, steps, status, wall, (page or {}).get("url", "")
71
+
72
+ if __name__ == "__main__":
73
+ flt = sys.argv[1] if len(sys.argv) > 1 else ""
74
+ repeats = int(os.environ.get("REPEATS", "1"))
75
+ rows = []
76
+ for name, url, goal, check in TASKS:
77
+ if flt and flt not in name: continue
78
+ for rep in range(repeats):
79
+ ok, steps, status, wall, final = run(name, url, goal, check)
80
+ rows.append((name, ok, steps, status, wall))
81
+ print(f"{'PASS' if ok else 'FAIL'} {name:16s} steps={steps:2d} status={status:9s} {wall:5.1f}s {final[:70]}", flush=True)
82
+ n = sum(r[1] for r in rows)
83
+ print(f"\n== {n}/{len(rows)} passed ({100*n/len(rows):.0f}%, {repeats} run(s) per task) | median wall {sorted(r[4] for r in rows)[len(rows)//2]:.1f}s")
84
+ if repeats > 1:
85
+ per = {}
86
+ for r in rows: per.setdefault(r[0], []).append(r[1])
87
+ print(" per task: " + " ".join(f"{k}={sum(v)}/{len(v)}" for k, v in per.items()))
88
+ json.dump([dict(zip(("name", "pass", "steps", "status", "wall"), r)) for r in rows], open(os.environ.get("SUITE_OUT", "/tmp/suite.json"), "w"), indent=1)
code/apps/browser_task.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys, json, time
2
+ sys.path.insert(0, "/home/ckl/projects/S/jev-ultrafast")
3
+ os.environ.update(BU_CDP_URL="http://127.0.0.1:9222", TYPESAFE_BASE_URL="http://127.0.0.1:8791", TYPESAFE_API_KEY="local",
4
+ TEXT_MODEL_API_KEY="local", TEXT_MODEL_BASE_URL="http://127.0.0.1:30000/v1", TEXT_MODEL="Qwen/Qwen3-4B-AWQ",
5
+ TEXT_MODEL_EXTRA_JSON='{"chat_template_kwargs": {"enable_thinking": false}}')
6
+ from jev_ultrafast import Agent
7
+ url = sys.argv[1]; goal = sys.argv[2]
8
+ t = time.time()
9
+ with Agent(url, goal) as agent:
10
+ print("elements on first page:", len(agent.snapshot()["elements"]), "| title:", agent.state["page"]["title"])
11
+ for state in agent.run():
12
+ h = state["history"][-1] if state["history"] else None
13
+ d = state["decisions"][-1] if state["decisions"] else None
14
+ print(f"{state['elapsed_ms']:6d} ms status={state['status']:9s} op={d['operation'] if d else None:9s} "
15
+ f"conf={d['confidence'] if d else 0:.2f} model={d['latency_ms'] if d else 0:4d}ms "
16
+ f"action={h['action'][:60] if h else None} text={h['text'] if h else None}")
17
+ if len(state["history"]) > 25: break
18
+ print("FINAL:", state["status"], "| url:", state["page"]["url"], "| title:", state["page"]["title"], "| wall", round(time.time() - t, 1), "s")
code/apps/common.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers: model loading + pretty printing."""
2
+ import os, sys, time
3
+ os.environ.setdefault("USE_TF", "0")
4
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
5
+
6
+ _AGENTS = {}
7
+
8
+ def get_agent(variant="english"):
9
+ """variant: english | multilingual | typed"""
10
+ if variant in _AGENTS:
11
+ return _AGENTS[variant]
12
+ import laya
13
+ t = time.time()
14
+ if os.path.isdir(variant):
15
+ a = laya.load(variant)
16
+ elif variant == "english":
17
+ # download only the root checkpoint (the other subfolders are several GB each)
18
+ from huggingface_hub import snapshot_download
19
+ path = snapshot_download("convaiinnovations/laya", ignore_patterns=["multilingual/*", "typed-decisions/*"])
20
+ a = laya.load(path)
21
+ elif variant == "multilingual":
22
+ a = laya.load("convaiinnovations/laya", subfolder="multilingual")
23
+ else:
24
+ from huggingface_hub import snapshot_download
25
+ path = snapshot_download("convaiinnovations/laya", allow_patterns=["typed-decisions/*"])
26
+ a = laya.load(path, subfolder="typed-decisions")
27
+ print(f"[laya] loaded {variant} in {time.time()-t:.1f}s", file=sys.stderr)
28
+ if os.environ.get("LAYA_FAST", "1") == "1":
29
+ try:
30
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "kernels"))
31
+ try:
32
+ from fast_laya import accelerate # laya/kernels layout
33
+ accelerate(a)
34
+ except ImportError:
35
+ from fast import FastLaya # code/kernels layout (this repo)
36
+ fl = FastLaya(a.model, max_len=a.cfg.get("max_len", 1024)); a._fast = fl; a.model.forward = fl.forward
37
+ print("[laya] TileLang fast path enabled (LAYA_FAST=0 to disable)", file=sys.stderr)
38
+ except Exception as e:
39
+ print(f"[laya] fast path unavailable: {e}", file=sys.stderr)
40
+ _AGENTS[variant] = a
41
+ return a
42
+
43
+ def bar(p, width=20):
44
+ n = int(round(p * width))
45
+ return "█" * n + "░" * (width - n)
46
+
47
+ def show(result, indent=" "):
48
+ """Pretty-print a laya predict() result."""
49
+ for name, a in result["answers"].items():
50
+ t = a["type"]
51
+ if t == "choice":
52
+ print(f"{indent}{name}: {a['choice']} (conf {a['confidence']:.2f})")
53
+ for k, v in sorted(a["probabilities"].items(), key=lambda kv: -kv[1]):
54
+ print(f"{indent} {bar(v)} {v:5.2f} {k}")
55
+ elif t == "score":
56
+ print(f"{indent}{name}: score={a['score']:.2f} (conf {a['confidence']:.2f})")
57
+ for k, v in a["probabilities"].items():
58
+ print(f"{indent} {bar(v)} {v:5.2f} {a['legend'][k]}")
59
+ else:
60
+ p = a["noul"]
61
+ print(f"{indent}{name}: P(true)={p:.2f} {bar(p)}")
code/apps/fast_batch.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-shot batched predict for laya: tokenize the shared state once, build every question's sequence from cached ids,
2
+ single forward, vectorised post-processing. Same outputs as agent.predict() (up to float rounding).
3
+
4
+ from fast_batch import predict_fast, profile_step
5
+ """
6
+ import json, time
7
+ import numpy as np, torch
8
+ from laya.common import QTYPES, collate_items, confidence_from_probs, render_options, serialize_state, temp_bucket
9
+
10
+
11
+ def build_items(agent, state, questions):
12
+ tok = agent.tok
13
+ max_len, head_max_len = agent.cfg.get("max_len", 512), agent.cfg.get("head_max_len", 192)
14
+ mask_tok, mask_id = tok.mask_token, tok.mask_token_id
15
+ st_ids = None # shared state tokens, computed lazily once
16
+ items, meta = [], []
17
+ for qid, qdef in questions.items():
18
+ q = agent._to_internal(qdef)
19
+ opts = render_options(q)
20
+ ins = str(q["ins"]).replace(mask_tok, " ")
21
+ head_ids = tok("%s question: %s" % (q["t"], ins), add_special_tokens=False)["input_ids"]
22
+ opt_txt = [" " + o.replace(mask_tok, " ") for o in opts]
23
+ opt_enc = tok(opt_txt, add_special_tokens=False)["input_ids"] # one batched tokenizer call for all options
24
+ opt_ids = [[mask_id] + o[:48] for o in opt_enc]
25
+ opt_budget = head_max_len - sum(len(o) for o in opt_ids)
26
+ if opt_budget < 16:
27
+ per = max(4, (head_max_len - 16) // max(1, len(opt_ids)))
28
+ opt_ids = [o[:per] for o in opt_ids]
29
+ opt_budget = head_max_len - sum(len(o) for o in opt_ids)
30
+ head_ids = head_ids[: max(8, opt_budget)]
31
+ ids = [tok.cls_token_id] + head_ids + [tok.sep_token_id]
32
+ markers = []
33
+ for o in opt_ids:
34
+ markers.append(len(ids)); ids.extend(o)
35
+ ids.append(tok.sep_token_id)
36
+ room = max(0, max_len - len(ids) - 1)
37
+ if st_ids is None:
38
+ st_ids = tok(serialize_state(state).replace(mask_tok, " "), add_special_tokens=False)["input_ids"]
39
+ ids = (ids + st_ids[:room] + [tok.sep_token_id])[:max_len]
40
+ markers = [m for m in markers if m < max_len]
41
+ if len(markers) != len(opts):
42
+ raise ValueError("question %r options exceed head_max_len=%d" % (qid, head_max_len))
43
+ items.append({"ids": ids, "markers": markers, "qtype": QTYPES[q["t"]]})
44
+ meta.append((qid, q, len(markers)))
45
+ return items, meta
46
+
47
+
48
+ @torch.no_grad()
49
+ def predict_fast(agent, state, questions, timing=None):
50
+ t0 = time.perf_counter()
51
+ items, meta = build_items(agent, state, questions)
52
+ b = collate_items([items], agent.tok.pad_token_id)
53
+ t1 = time.perf_counter()
54
+ dev = agent.device
55
+ with torch.autocast(device_type=dev.type, dtype=agent.dtype, enabled=dev.type == "cuda"):
56
+ logits, act = agent.model(b["input_ids"].to(dev, non_blocking=True), b["attention_mask"].to(dev, non_blocking=True),
57
+ b["marker_pos"].to(dev, non_blocking=True), b["marker_mask"].to(dev, non_blocking=True), b["qtype"].to(dev, non_blocking=True))
58
+ logits = logits.float().cpu().numpy(); act = torch.softmax(act.float(), -1).cpu().numpy()
59
+ t2 = time.perf_counter()
60
+ answers = {}
61
+ for r, (qid, q, k) in enumerate(meta):
62
+ qt = QTYPES[q["t"]]
63
+ t_scale = agent.temperature_by_options.get(temp_bucket(qt, k), agent.temperature[qt])
64
+ z = logits[r, :k] / max(1e-3, float(t_scale)); p = np.exp(z - z.max()); p /= p.sum()
65
+ conf = round(confidence_from_probs(p, k), 4); ext = {"act_probability": round(float(act[r, 0]), 4)}
66
+ if q["t"] == "choice":
67
+ keys = list(q["crit"].keys())
68
+ answers[qid] = {"type": "choice", "choice": keys[int(p.argmax())], "probabilities": {kk: round(float(v), 4) for kk, v in zip(keys, p)}, "confidence": conf, "action": ext}
69
+ elif q["t"] == "score":
70
+ answers[qid] = {"type": "score", "score": round(float((np.arange(k) * p).sum()), 4), "legend": {str(i): c for i, c in enumerate(q["crit"])},
71
+ "probabilities": {str(i): round(float(v), 4) for i, v in enumerate(p)}, "confidence": conf, "action": ext}
72
+ else:
73
+ answers[qid] = {"type": "noul", "noul": round(float(p[1]), 4), "confidence": round(max(float(p[1]), 1 - float(p[1])), 4), "action": ext}
74
+ t3 = time.perf_counter()
75
+ if timing is not None:
76
+ timing.update(tokenize_ms=(t1 - t0) * 1e3, forward_ms=(t2 - t1) * 1e3, post_ms=(t3 - t2) * 1e3, tokens=int(b["attention_mask"].sum()))
77
+ return {"model": "laya-rl-agent", "answers": answers, "usage": {"input_tokens": int(b["attention_mask"].sum()), "output_tokens": 0}}
78
+
79
+
80
+ def profile_step(agent, state, questions, n=20):
81
+ """Compare agent.predict vs predict_fast on one recorded browser step."""
82
+ for _ in range(3): agent.predict(state, questions); predict_fast(agent, state, questions)
83
+ torch.cuda.synchronize(); t = time.perf_counter()
84
+ for _ in range(n): agent.predict(state, questions)
85
+ torch.cuda.synchronize(); slow = (time.perf_counter() - t) / n * 1e3
86
+ tm = {}; torch.cuda.synchronize(); t = time.perf_counter()
87
+ for _ in range(n): predict_fast(agent, state, questions, tm)
88
+ torch.cuda.synchronize(); fast = (time.perf_counter() - t) / n * 1e3
89
+ return {"predict_ms": slow, "predict_fast_ms": fast, **tm}
code/apps/hn_radar.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hacker News 雷达:拉取实时热帖标题,用 laya 给每条打 主题/是否硬核技术/是否值得读 标签。
2
+ 用法: python apps/hn_radar.py [N=30]
3
+ """
4
+ import os, sys, json, time, urllib.request
5
+ from common import get_agent, bar
6
+
7
+ QUESTIONS = {
8
+ "topic": {"type": "choice", "instructions": "What is this Hacker News story about?",
9
+ "criteria": {"ai": "machine learning, LLMs, models", "systems": "OS, compilers, databases, hardware, GPUs",
10
+ "web": "frontend, browsers, web frameworks", "security": "vulnerabilities, hacking, privacy",
11
+ "business": "startups, funding, layoffs, policy", "science": "physics, biology, space, math",
12
+ "other": "anything else"}},
13
+ "technical_depth": {"type": "score", "instructions": "How technically deep is this likely to be?",
14
+ "criteria": ["fluff", "medium", "deep dive"]},
15
+ "showhn": {"type": "noul", "instructions": "Is this a project someone built and is showing off?"},
16
+ }
17
+
18
+ def fetch(n):
19
+ """One request to the HN Algolia API (front page)."""
20
+ r = json.load(urllib.request.urlopen(f"https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage={n}", timeout=20))
21
+ return [{"title": h["title"], "url": h.get("url") or "", "score": h.get("points", 0)} for h in r["hits"] if h.get("title")]
22
+
23
+ def main():
24
+ n = int(sys.argv[1]) if len(sys.argv) > 1 else 30
25
+ print(f"fetching {n} HN top stories...")
26
+ items = fetch(n)
27
+ agent = get_agent(os.environ.get("LAYA_VARIANT", "multilingual"))
28
+ t = time.time()
29
+ res = [agent.predict({"title": it["title"], "url": it.get("url", "")}, QUESTIONS) for it in items]
30
+ dt = time.time() - t
31
+ print(f"classified {len(items)} in {dt*1000:.0f} ms\n")
32
+ by_topic = {}
33
+ for it, r in zip(items, res):
34
+ a = r["answers"]
35
+ by_topic.setdefault(a["topic"]["choice"], []).append((round(a["technical_depth"]["score"],1), a["showhn"]["noul"], it))
36
+ for topic, lst in sorted(by_topic.items(), key=lambda kv: -len(kv[1])):
37
+ print(f"## {topic} ({len(lst)})")
38
+ for depth, show_p, it in sorted(lst, key=lambda x: -(x[0] or 0)):
39
+ tag = " [show]" if show_p > 0.5 else ""
40
+ print(f" depth={depth} ↑{it.get('score',0):<4} {it['title'][:70]}{tag}")
41
+ print()
42
+
43
+ if __name__ == "__main__":
44
+ main()
code/apps/inbox_triage.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """多语言工单/邮件分诊:一次前向传播同时回答 部门 / 紧急度 / 流失风险 / 情绪。
2
+ 用法: python apps/inbox_triage.py # 跑内置样例(中/英/日/西/印地语)
3
+ python apps/inbox_triage.py "你的邮件正文"
4
+ """
5
+ import sys, json, time
6
+ from common import get_agent, show
7
+
8
+ QUESTIONS = {
9
+ "department": {"type": "choice", "instructions": "Which team should handle this message?",
10
+ "criteria": {"billing": "invoices, payments, refunds, double charge",
11
+ "technical": "bugs, crashes, outages, login failures",
12
+ "sales": "pricing, quotes, upgrades, enterprise plans",
13
+ "shipping": "delivery, tracking, lost or damaged package"}},
14
+ "urgency": {"type": "score", "instructions": "How urgent is this?",
15
+ "criteria": ["not urgent", "soon", "blocking"]},
16
+ "churn_risk": {"type": "noul", "instructions": "Does the user threaten to cancel or leave?"},
17
+ "sentiment": {"type": "choice", "instructions": "What is the customer's tone?",
18
+ "criteria": {"angry": "furious, threatening, insulting",
19
+ "frustrated": "annoyed but civil", "neutral": "matter of fact",
20
+ "happy": "grateful, positive"}},
21
+ }
22
+
23
+ SAMPLES = [
24
+ {"subject": "Duplicate charge on invoice 4411",
25
+ "body": "We were billed twice for March. Please refund the duplicate or we're moving to a competitor."},
26
+ {"subject": "登录一直失败", "body": "从昨天开始整个团队都登录不了后台,报 502,我们的上线被卡住了,急!"},
27
+ {"subject": "見積もりのお願い", "body": "エンタープライズプランの料金と年間契約の割引について教えてください。"},
28
+ {"subject": "Paquete perdido", "body": "El rastreo dice entregado pero no recibí nada. Llevo una semana esperando, estoy muy molesto."},
29
+ {"subject": "धन्यवाद", "body": "आपकी टीम ने मेरी समस्या बहुत जल्दी हल कर दी। बहुत बहुत धन्यवाद!"},
30
+ ]
31
+
32
+ def main():
33
+ agent = get_agent("multilingual")
34
+ states = [{"subject": "(cli)", "body": " ".join(sys.argv[1:])}] if len(sys.argv) > 1 else SAMPLES
35
+ for s in states:
36
+ t = time.time()
37
+ r = agent.predict(s, QUESTIONS)
38
+ dt = (time.time() - t) * 1000
39
+ print(f"\n=== {s['subject']} [{dt:.0f} ms]\n {s['body'][:80]}")
40
+ show(r)
41
+
42
+ if __name__ == "__main__":
43
+ main()
code/apps/moderator.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """内容审核台:给一批评论打 有害/垃圾/需人工复核 的校准概率,并按风险排序。
2
+ 用法: python apps/moderator.py [file.txt] # 每行一条评论;无参数用内置样例
3
+ """
4
+ import sys, time
5
+ from common import get_agent, bar
6
+
7
+ QUESTIONS = {
8
+ "toxic": {"type": "noul", "instructions": "Is this comment abusive, hateful or harassing toward someone?"},
9
+ "spam": {"type": "noul", "instructions": "Is this comment spam or unsolicited advertising?"},
10
+ "topic": {"type": "choice", "instructions": "What is the comment mainly about?",
11
+ "criteria": {"product": "the product or service itself", "politics": "political opinion",
12
+ "personal": "attacks or remarks about a person", "offtopic": "unrelated chatter"}},
13
+ "severity": {"type": "score", "instructions": "How severe is the policy violation, if any?",
14
+ "criteria": ["none", "mild", "serious", "ban-worthy"]},
15
+ }
16
+
17
+ SAMPLES = [
18
+ "This update is great, the new editor is so much faster!",
19
+ "Buy cheap followers now!!! visit my profile link, 50% off today only",
20
+ "You are a worthless idiot and everyone here knows it.",
21
+ "这个功能真的太难用了,建议回滚到上个版本。",
22
+ "滚出去,你这种垃圾不配在这发言。",
23
+ "Honestly both parties are the same, nothing will change.",
24
+ "Does anyone know if the API supports webhooks?",
25
+ ]
26
+
27
+ def main():
28
+ agent = get_agent("multilingual")
29
+ texts = [l.strip() for l in open(sys.argv[1], encoding="utf-8") if l.strip()] if len(sys.argv) > 1 else SAMPLES
30
+ t = time.time()
31
+ results = [agent.predict({"comment": c}, QUESTIONS) for c in texts]
32
+ dt = time.time() - t
33
+ rows = []
34
+ for c, r in zip(texts, results):
35
+ a = r["answers"]
36
+ risk = max(a["toxic"]["noul"], a["spam"]["noul"])
37
+ rows.append((risk, a["toxic"]["noul"], a["spam"]["noul"],
38
+ a["topic"]["choice"], round(a["severity"]["score"],1), c))
39
+ rows.sort(reverse=True)
40
+ print(f"{len(texts)} comments in {dt*1000:.0f} ms ({dt*1000/len(texts):.0f} ms each)\n")
41
+ print(f"{'risk':>5} {'toxic':>5} {'spam':>5} {'topic':9} {'sev':>4} comment")
42
+ for risk, tox, spam, topic, sev, c in rows:
43
+ flag = "🚨" if risk > 0.7 else ("⚠️ " if risk > 0.4 else " ")
44
+ print(f"{flag}{risk:5.2f} {tox:5.2f} {spam:5.2f} {topic:9} {str(sev):>4} {c[:60]}")
45
+
46
+ if __name__ == "__main__":
47
+ main()
code/apps/profile_step.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Time one real browser step (recorded in finetune/out/dagger_cases.jsonl) end to end inside the server process.
2
+ python apps/profile_step.py <checkpoint dir>"""
3
+ import json, os, sys
4
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); sys.path.insert(0, "/home/ckl/projects/S/laya/finetune")
5
+ os.environ.setdefault("LAYA_FMT", "v2")
6
+ from common import get_agent
7
+ from common_ft import build_request
8
+ from fast_batch import profile_step
9
+ agent = get_agent(sys.argv[1])
10
+ if agent.cfg.get("head_max_len_train"): agent.cfg["head_max_len"] = agent.cfg["head_max_len_train"]
11
+ cases = [json.loads(l) for l in open("/home/ckl/projects/S/laya/finetune/out/dagger_cases.jsonl")]
12
+ for c in cases[:4]:
13
+ page = c["page_obj"]; state, questions, _, _ = build_request(page, c["goal"], c.get("history", []))
14
+ n_opts = sum(len(q["criteria"]) for q in questions.values())
15
+ r = profile_step(agent, state, questions)
16
+ print(f"{len(questions)} questions / {n_opts:3d} options / {r['tokens']:4d} tokens: agent.predict {r['predict_ms']:6.1f} ms | fast {r['predict_fast_ms']:6.1f} ms "
17
+ f"(tokenize {r['tokenize_ms']:.1f} + forward {r['forward_ms']:.1f} + post {r['post_ms']:.1f})")
code/apps/systemone_server.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TypeSafe-compatible /v1/systemone endpoint backed by a local laya checkpoint (+ TileLang fast path).
2
+
3
+ python apps/systemone_server.py [port=8791] [variant=typed|multilingual|english]
4
+
5
+ Request body: {"model": ..., "state": {...}, "questions": {...}} -> {"answers": ..., "model": ..., "usage": ...}
6
+ """
7
+ import json, os, sys, time, traceback
8
+ from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
9
+ from common import get_agent
10
+ from fast_batch import predict_fast
11
+
12
+ PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8791
13
+ VARIANT = sys.argv[2] if len(sys.argv) > 2 else "typed"
14
+ MAXOPT = int(sys.argv[3]) if len(sys.argv) > 3 else 12 # laya's option budget: keep choice questions at most this wide
15
+ agent = get_agent(VARIANT)
16
+ FMT = os.environ.get("LAYA_FMT", agent.cfg.get("laya_fmt", "v1")) # fine-tuned checkpoints record their format in rl_agent_config.json
17
+ if agent.cfg.get("head_max_len_train"):
18
+ agent.cfg["head_max_len"] = agent.cfg["head_max_len_train"]
19
+ print(f"[systemone] format={FMT} head_max_len={agent.cfg.get('head_max_len')}", flush=True)
20
+ LOG = []
21
+
22
+ # ---- System 1 / System 2 gating: below ESCALATE_TAU confidence, ask the LLM teacher (same element table) and return its
23
+ # decision in laya's answer format. Every escalation is also appended to ESCALATE_LOG as a DAgger case.
24
+ TAU = float(os.environ.get("ESCALATE_TAU", "0")) # 0 = off
25
+ ESC_URL = os.environ.get("TEXT_MODEL_BASE_URL", "http://127.0.0.1:30000/v1") + "/chat/completions"
26
+ ESC_MODEL = os.environ.get("TEXT_MODEL", "Qwen/Qwen3-8B-AWQ")
27
+ ESC_LOG = os.environ.get("ESCALATE_LOG", "")
28
+ STATS = {"calls": 0, "escalated": 0}
29
+ ESC_SYS = """You are the System-2 fallback for a browser agent. Given the goal, the actions so far, the page and a numbered list of
30
+ controls (each with the operations it supports), pick the single best NEXT step. Answer JSON:
31
+ {"operation": "CLICK"|"TYPE_TEXT"|"SELECT"|"DONE"|"BLOCKED"|"WAIT"|"SCROLL_DOWN"|"SCROLL_UP", "target": "<option key of the chosen control, or null>"}
32
+ DONE only if the goal is already visibly satisfied. A field that already shows the requested value is done; do not re-type it."""
33
+
34
+
35
+ def escalate(state, questions, answers):
36
+ import httpx
37
+ ops = questions["operation"]["criteria"]
38
+ controls = []
39
+ for qid, q in questions.items():
40
+ if qid.endswith("_target"):
41
+ op = qid[:-7].upper()
42
+ for key, desc in q["criteria"].items():
43
+ controls.append({"op": op, "target": key, "control": desc})
44
+ user = {"goal": (questions["operation"]["instructions"] or {}).get("goal") if isinstance(questions["operation"]["instructions"], dict) else "",
45
+ "actions_so_far": state.get("recent_actions", [])[-8:], "page": state.get("page", {}), "operations": list(ops),
46
+ "controls": controls[:150], "system1_guess": {k: v.get("choice") for k, v in answers.items()}}
47
+ body = {"model": ESC_MODEL, "max_tokens": 80, "temperature": 0.0, "response_format": {"type": "json_object"},
48
+ "chat_template_kwargs": {"enable_thinking": False},
49
+ "messages": [{"role": "system", "content": ESC_SYS}, {"role": "user", "content": json.dumps(user, ensure_ascii=False)}]}
50
+ r = httpx.post(ESC_URL, json=body, timeout=120).json()
51
+ v = json.loads(r["choices"][0]["message"]["content"])
52
+ op = str(v.get("operation", "")).upper(); tgt = v.get("target")
53
+ if op not in ops:
54
+ return answers, False
55
+ def one_hot(keys, k, p=0.97):
56
+ rest = (1 - p) / max(1, len(keys) - 1)
57
+ return {kk: (p if kk == k else rest) for kk in keys}
58
+ answers["operation"] = {**answers["operation"], "choice": op, "probabilities": one_hot(list(ops), op), "confidence": 0.9, "system2": True}
59
+ tq = op.lower() + "_target"
60
+ if tq in questions:
61
+ keys = list(questions[tq]["criteria"]); tgt = str(tgt)
62
+ if tgt not in keys:
63
+ return answers, False
64
+ answers[tq] = {**answers[tq], "choice": tgt, "probabilities": one_hot(keys, tgt), "confidence": 0.9, "system2": True}
65
+ if ESC_LOG:
66
+ with open(ESC_LOG, "a") as f:
67
+ f.write(json.dumps({"state": state, "operation": op, "target": tgt if tq in questions else None}, ensure_ascii=False) + "\n")
68
+ return answers, True
69
+
70
+
71
+ def compact(v):
72
+ """Shrink jev-ultrafast element criteria ({'element': '[3] Search', 'role': 'button', ...}) into one short string
73
+ so more options fit laya's head token budget."""
74
+ if isinstance(v, dict) and "element" in v:
75
+ s = str(v["element"])[:50 if FMT == "v3" else 10000]
76
+ if v.get("role"):
77
+ s += f" ({v['role']})"
78
+ if v.get("current_value"):
79
+ s += f" = {str(v['current_value'])[:30]!r}"
80
+ for k in ("checked", "selected", "expanded"):
81
+ if k in v:
82
+ s += f" {k}={v[k]}"
83
+ return s
84
+ return v
85
+
86
+
87
+ def predict(state, questions):
88
+ """agent.predict with coarse-to-fine handling of wide choice questions.
89
+
90
+ A choice with more than MAXOPT options is split into interleaved chunks; every chunk is a question in the same
91
+ forward pass as the normal questions, then the chunk winners compete in a second pass.
92
+ p(option) = p_final(winner of its chunk) * p_chunk(option)."""
93
+ qs, plan = {}, {}
94
+ if isinstance(state, dict) and isinstance(state.get("page"), dict) and isinstance(state["page"].get("text"), str):
95
+ if FMT in ("v2", "v3"): # mirror finetune/common_ft.py
96
+ state = {"page": {**state["page"], "text": state["page"]["text"][:1500 if FMT == "v2" else 1200]}, "recent_actions": state.get("recent_actions", [])}
97
+ else:
98
+ state = {**state, "page": {**state["page"], "text": state["page"]["text"][:6000]}}
99
+ for qid, q in questions.items():
100
+ q = dict(q)
101
+ if isinstance(q.get("criteria"), dict):
102
+ q["criteria"] = {k: compact(v) for k, v in q["criteria"].items()}
103
+ keys = list(q["criteria"]) if q["type"] == "choice" and isinstance(q.get("criteria"), dict) else []
104
+ if len(keys) <= MAXOPT:
105
+ qs[qid] = q
106
+ continue
107
+ n = -(-len(keys) // MAXOPT)
108
+ chunks = [keys[i::n] for i in range(n)]
109
+ plan[qid] = (q, chunks)
110
+ for ci, ch in enumerate(chunks):
111
+ qs[f"{qid}__chunk{ci}"] = {**q, "criteria": {k: q["criteria"][k] for k in ch}}
112
+ r = predict_fast(agent, state, qs)
113
+ r["passes"] = 1
114
+ if plan:
115
+ chunk_ans = {qid: [r["answers"].pop(f"{qid}__chunk{ci}") for ci in range(len(chunks))] for qid, (q, chunks) in plan.items()}
116
+ finals = {qid: {**q, "criteria": {a["choice"]: q["criteria"][a["choice"]] for a in chunk_ans[qid]}} for qid, (q, _) in plan.items()}
117
+ r2 = predict_fast(agent, state, finals)
118
+ r["passes"] = 2
119
+ r["usage"]["input_tokens"] += r2["usage"]["input_tokens"]
120
+ for qid, (q, chunks) in plan.items():
121
+ fa = r2["answers"][qid]
122
+ probs = {}
123
+ for ca, ch in zip(chunk_ans[qid], chunks):
124
+ pf = fa["probabilities"][ca["choice"]]
125
+ for k in ch:
126
+ probs[k] = pf * ca["probabilities"][k]
127
+ tot = sum(probs.values()) or 1.0
128
+ probs = {k: round(v / tot, 6) for k, v in probs.items()}
129
+ choice = max(probs, key=probs.get)
130
+ r["answers"][qid] = {"type": "choice", "choice": choice, "probabilities": probs, "confidence": fa["confidence"],
131
+ "action": fa.get("action", {}), "coarse_to_fine": {"chunks": len(chunks), "winners": [a["choice"] for a in chunk_ans[qid]]}}
132
+ return r
133
+
134
+
135
+ class H(BaseHTTPRequestHandler):
136
+ def log_message(self, *a): pass
137
+ def _send(self, code, body):
138
+ data = json.dumps(body, ensure_ascii=False).encode()
139
+ self.send_response(code); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data)
140
+ def do_GET(self):
141
+ self._send(200, {"ok": True, "variant": VARIANT, "calls": len(LOG), "tau": TAU, "escalated": STATS["escalated"], "recent": LOG[-5:]})
142
+ def do_POST(self):
143
+ n = int(self.headers.get("Content-Length", 0)); req = json.loads(self.rfile.read(n) or b"{}")
144
+ try:
145
+ t = time.perf_counter()
146
+ r = predict(req["state"], req["questions"])
147
+ STATS["calls"] += 1
148
+ if TAU > 0:
149
+ a = r["answers"]; op = a["operation"]["choice"]; tq = op.lower() + "_target"
150
+ conf = min(a["operation"]["confidence"], a[tq]["confidence"] if tq in a else 1.0)
151
+ if conf < TAU:
152
+ try:
153
+ r["answers"], esc = escalate(req["state"], req["questions"], a)
154
+ STATS["escalated"] += esc
155
+ except Exception as e:
156
+ print("[escalate] failed:", str(e)[:80], flush=True)
157
+ ms = (time.perf_counter() - t) * 1000
158
+ r["model"] = f"laya-{VARIANT}"
159
+ nq = len(req["questions"]); nopt = sum(len(q.get("criteria") or []) for q in req["questions"].values())
160
+ LOG.append({"ms": round(ms, 1), "questions": nq, "options": nopt, "tokens": r["usage"]["input_tokens"], "passes": r["passes"]})
161
+ print(f"[systemone] {nq} q / {nopt} opts / {r['usage']['input_tokens']} tok / {r['passes']} pass -> {ms:.1f} ms " +
162
+ ", ".join(f"{k}={v.get('choice', v.get('score', v.get('noul')))}({v['confidence']:.2f})" for k, v in r["answers"].items()), flush=True)
163
+ self._send(200, r)
164
+ except Exception as e:
165
+ traceback.print_exc(); self._send(400, {"error": f"{type(e).__name__}: {e}"})
166
+
167
+ if __name__ == "__main__":
168
+ print(f"laya systemone server on http://127.0.0.1:{PORT}/v1/systemone ({VARIANT})", flush=True)
169
+ ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
code/apps/web_console.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """决策台 Web UI(零依赖,stdlib http.server):粘贴任意文本,自定义问题,实时看概率条。
2
+ 用法: python apps/web_console.py [port=7860] 然后打开 http://127.0.0.1:7860
3
+ """
4
+ import sys, json, time
5
+ from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
6
+ from common import get_agent
7
+
8
+ HTML = r"""<!doctype html><meta charset=utf-8><title>Laya 决策台</title>
9
+ <style>
10
+ body{font:14px system-ui;margin:0;background:#0f1115;color:#e6e6e6;display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:16px;height:100vh;box-sizing:border-box}
11
+ textarea{width:100%;box-sizing:border-box;background:#1a1d24;color:#eee;border:1px solid #333;border-radius:6px;padding:8px;font:13px ui-monospace,monospace}
12
+ button{background:#4f8cff;color:#fff;border:0;padding:8px 16px;border-radius:6px;font-size:14px;cursor:pointer}
13
+ select{background:#1a1d24;color:#eee;border:1px solid #333;padding:6px;border-radius:6px}
14
+ .q{margin:10px 0;padding:10px;background:#171a21;border-radius:8px}.q h3{margin:0 0 6px;font-size:14px}
15
+ .row{display:flex;align-items:center;gap:8px;margin:2px 0}.bar{height:10px;background:#4f8cff;border-radius:3px}
16
+ .lab{width:120px;text-align:right;color:#aaa}.p{width:40px;color:#aaa}
17
+ #out{overflow:auto}.meta{color:#888;font-size:12px}
18
+ </style>
19
+ <div><h2>Laya 决策台 <span class=meta>单次前向传播 · 不生成文本 · 校准概率</span></h2>
20
+ <label>模型 <select id=model><option value=multilingual>multilingual (100+ 语言)</option><option value=english>english</option><option value=typed>typed-decisions</option></select></label>
21
+ <p><b>状态 (JSON 或纯文本)</b><br><textarea id=state rows=8>{"subject": "登录一直失败", "body": "从昨天开始整个团队都登录不了后台,报 502,我们的上线被卡住了。再不解决就退订。"}</textarea></p>
22
+ <p><b>问题 (JSON)</b><br><textarea id=questions rows=16>{
23
+ "department": {"type": "choice", "instructions": "Which team should handle this?",
24
+ "criteria": {"billing": "invoices, refunds", "technical": "bugs, outages, login", "sales": "pricing, plans"}},
25
+ "urgency": {"type": "score", "instructions": "How urgent is this?", "criteria": ["not urgent", "soon", "blocking"]},
26
+ "churn_risk": {"type": "noul", "instructions": "Does the user threaten to cancel?"}
27
+ }</textarea></p>
28
+ <button onclick=run()>预测 (Ctrl+Enter)</button> <span id=t class=meta></span></div>
29
+ <div id=out></div>
30
+ <script>
31
+ async function run(){
32
+ const body={model:model.value,state:state.value,questions:questions.value};
33
+ t.textContent='...';
34
+ const r=await fetch('/predict',{method:'POST',body:JSON.stringify(body)});const j=await r.json();
35
+ if(j.error){out.innerHTML='<pre style="color:#f66">'+j.error+'</pre>';t.textContent='';return}
36
+ t.textContent=j.ms.toFixed(0)+' ms';
37
+ let h='';for(const [k,a] of Object.entries(j.answers)){
38
+ h+='<div class=q><h3>'+k+' → <span style=color:#8fd>'+(a.choice??(a.score!==undefined?'score '+a.score.toFixed(2):'')??'')+(a.noul!==undefined?'P(true)='+a.noul.toFixed(2):'')+'</span>'+(a.confidence!==undefined?' <span class=meta>conf '+a.confidence.toFixed(2)+'</span>':'')+'</h3>';
39
+ let d=a.probabilities||(a.noul!==undefined?{true:a.noul,false:1-a.noul}:{});if(a.legend)d=Object.fromEntries(Object.entries(d).map(([k,v])=>[k+' '+a.legend[k],v]));
40
+ if(Array.isArray(d))d=Object.fromEntries(d.map((v,i)=>[i,v]));
41
+ for(const [l,p] of Object.entries(d).sort((x,y)=>y[1]-x[1]))h+='<div class=row><span class=lab>'+l+'</span><div class=bar style=width:'+(p*300)+'px></div><span class=p>'+p.toFixed(2)+'</span></div>';
42
+ h+='</div>'}
43
+ h+='<pre class=meta>'+JSON.stringify(j.raw,null,1)+'</pre>';out.innerHTML=h}
44
+ document.addEventListener('keydown',e=>{if(e.ctrlKey&&e.key=='Enter')run()});
45
+ </script>"""
46
+
47
+ class H(BaseHTTPRequestHandler):
48
+ def log_message(self, *a): pass
49
+ def do_GET(self):
50
+ self.send_response(200); self.send_header("Content-Type", "text/html; charset=utf-8"); self.end_headers()
51
+ self.wfile.write(HTML.encode())
52
+ def do_POST(self):
53
+ n = int(self.headers.get("Content-Length", 0)); req = json.loads(self.rfile.read(n))
54
+ try:
55
+ st = req["state"].strip()
56
+ try: st = json.loads(st)
57
+ except Exception: st = {"text": st}
58
+ qs = json.loads(req["questions"])
59
+ agent = get_agent(req.get("model", "multilingual"))
60
+ t = time.time(); r = agent.predict(st, qs); ms = (time.time() - t) * 1000
61
+ body = {"answers": r["answers"], "raw": r, "ms": ms}
62
+ except Exception as e:
63
+ body = {"error": f"{type(e).__name__}: {e}"}
64
+ data = json.dumps(body, ensure_ascii=False, default=str).encode()
65
+ self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(data)
66
+
67
+ if __name__ == "__main__":
68
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 7860
69
+ get_agent("multilingual")
70
+ print(f"open http://127.0.0.1:{port}")
71
+ ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
code/env.sh ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export HF_ENDPOINT=https://hf-mirror.com
2
+ export USE_TF=0
3
+ export UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
4
+ export PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
5
+ unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY
6
+ export PATH="$HOME/.local/bin:$PATH"
code/finetune/README.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 微调 laya 做浏览器 agent 决策头(替代 TypeSafe Jev)
2
+
3
+ 流水线(全部本地、无付费 API):
4
+
5
+ | 步骤 | 脚本 | 说明 |
6
+ |---|---|---|
7
+ | 1 抓页面 | `collect_pages.py` | headless Chromium + browser-harness,90 个真实页面的元素表 + 正文 |
8
+ | 2 反向生成目标 | `gen_goals.py` | 随机选一个元素当 gold,本地 Qwen3-8B-AWQ(sglang)写出"要用它的用户目标",1121 条 |
9
+ | 3 真实 DONE 样本 | `make_done_cases.py` | 在浏览器里真的执行点击,落地页 + 历史 = DONE,250 条 |
10
+ | 4 构建样本 | `build_items.py` | 复刻 jev-ultrafast 的 systemone 请求格式(`common_ft.py`),head_max_len 512;页面按 index%5 留出 |
11
+ | 5 训练 | `train.py` | laya 官方 RLCD 配方(噪声 logit 策略梯度 + soft CE)单卡版,4 轮约 10 分钟 |
12
+ | 6 评测 | `eval.py` | 留出页面上的操作准确率 / 目标 top-1 |
13
+
14
+ ```fish
15
+ bash finetune/run_v2.sh # 4-6 步
16
+ bash finetune/run_all.sh # 含零样本基线
17
+ ```
18
+
19
+ ## 加入魔搭 Mind2Web 后(v3/v4)
20
+
21
+ `osunlp/Mind2Web`(魔搭,6.7 GB,12-25 MB/s 不走代理)→ `convert_mind2web.py`:按 backend_node_id 从 cleaned_html 还原元素文本/角色,
22
+ 正例 + 44 个负例打乱成 jev 格式元素表,历史用 action_reprs,7296 条;按网站哈希留出 20%。v4 = 自采 + 真实 DONE + 全部 Mind2Web,
23
+ DONE / TYPE_TEXT / SELECT 操作样本 3 倍过采样,16020 条训练样本,3 轮 64 分钟。`bash finetune/run_v4.sh`。
24
+
25
+ 评测 1642 条(自采留出页 232 + Mind2Web 未见网站 1410):
26
+
27
+ | 模型 | 操作准确率 | 目标 top-1 | live DONE | m2w CLICK 目标 | m2w TYPE_TEXT 操作 | m2w SELECT 操作 |
28
+ |---|---|---|---|---|---|---|
29
+ | typed-decisions 零样本 | 0.26 | 0.13 | 0.39 | 0.03 | 0.00 | 0.00 |
30
+ | v3(无过采样) | 0.790 | 0.496 | 0.58 | 0.40 | 0.04 | 0.20 |
31
+ | **v4(过采样)** | **0.795** | **0.524** | **0.82** | **0.43** | **0.80** | **0.55** |
32
+
33
+ 真实浏览器 6 任务:零样本 0/6 → v2 2/6 → **v4 3/6**(python.org Downloads 2.0 s、Travel 分类 0.3 s、Wikipedia 随机文章),
34
+ Wikipedia 搜索任务首次正确选 TYPE_TEXT 并由本地 Qwen 填入 "Python programming language",但之后重复填同一字段而没有提交——
35
+ 训练数据里缺"字段已填好 → 下一步提交"的样本。GitHub Issues 点成 Releases、HN new 提前 DONE。
36
+
37
+ ## 最终对比(2026-09-21,16 个真实任务 × 3 次,apps/browser_suite.py)
38
+
39
+ | 模型 | 底座 | 一步延迟 | 留出目标 top-1 | 真实任务通过率 | +门控 τ=0.7(Qwen3-8B 兜底) |
40
+ |---|---|---|---|---|---|
41
+ | v10 | ModernBERT-large 421M | 41–50 ms | 0.656 | **58%**(28/48) | 42%,升级率 78% |
42
+ | v10s | mmBERT-base 322M,格式 v3 | **17–23 ms** | 0.631 | 50%(24/48) | 52%,升级率 85% |
43
+
44
+ 结果高度双峰:9 个任务 3/3 稳定通过(导航、分类、勾选、HN 各页、DuckDuckGo 搜索),7 个任务 0/3 稳定失败
45
+ (Wikipedia 两个搜索任务、GitHub Issues、下拉选择、翻页需滚动、arXiv 搜索、Google Flights)。
46
+ 门控把低置信步骤交给 Qwen3-8B 反而更差:在这些页面上微调后的 laya 比 8B 通用模型判断得更准,System 2 需要更强的模型或专门提示。
47
+ 失败任务的共性是"输入后提交 / 选建议"和"需要先滚动",训练数据里几乎没有滚动样本。
48
+ v10 数据:5244 条自采目标(421 页)+ 700 真实 DONE + step-2 + Mind2Web 全量 + DAgger,格式 v2,head 768,4 轮 2.2 小时。
49
+
50
+ ## 输入格式 v2(v9 起)
51
+
52
+ `LAYA_FMT=v2 LAYA_HEAD=768`:元素表不再塞进 state(1024 token 里会被截掉),只在选项里保留完整标签 + 角色 + 已填值,
53
+ state 只留标题 / URL / 历史 / 1500 字正文,head_max_len 512→768。v9 只用 Mind2Web + 该格式,真实任务从 6/16 到 **10/16**,
54
+ Mind2Web 点击目标 0.44→0.51。checkpoint 的 `rl_agent_config.json` 记录 `laya_fmt` / `head_max_len_train`,服务端自动跟随。
55
+
56
+ 注意:jev 的 `snapshot.js` 有意隐藏密码框(`!['password','file','hidden'].includes(e.type)`),密码登录任务在该框架里不可能完成,任务集已移除。
57
+
58
+ ## 真实任务集(apps/browser_suite.py,16 个任务,自动验收)
59
+
60
+ | 版本 | 通过 | 主要失败模式 |
61
+ |---|---|---|
62
+ | v4 | 3/14 | 过早 DONE(6 个),填完不提交(2 个),点错(3 个) |
63
+ | v5(DONE 过采样 2x + 字段带已填值 + 温度 1.40) | 4/14 | 登录/搜索仍在填一个字段后 DONE |
64
+
65
+ v5 的根因:真实 DONE 样本历史恰好都是 1 步、自采点击样本历史都是 0 步,模型学到"有历史 ⇒ DONE"。
66
+ v6 加 `gen_step2.py`:在 250 个落地页上保留历史再生成 659 条新目标作为负样本。
67
+
68
+ ## 结果(v1/v2,仅自采数据)
69
+
70
+ 留出 18 个未见页面、232 条:
71
+
72
+ | 模型 | 操作准确率 | 目标 top-1 | DONE 判断 | 每条延迟 |
73
+ |---|---|---|---|---|
74
+ | typed-decisions 零样本 | 0.543 | 0.103(随机) | 0.39 | 254 ms |
75
+ | multilingual ��样本 | 0.127 | 0.124 | 0.00 | 249 ms |
76
+ | **微调 v2(1886 样本)** | **0.875** | **0.665** | 0.66 | 55 ms |
77
+
78
+ 真实浏览器 6 个任务(jev-ultrafast 循环 + 本地 laya 服务):python.org Downloads 2.4 s 完成、books.toscrape Travel
79
+ 分类 0.3 s 完成;HN "new"、GitHub Issues、Wikipedia 两个任务失败(点错元素或提前 DONE)。零样本时 6 个全失败。
80
+
81
+ 已知问题:置信度全是 1.00(未做温度校准);数据只有 90 个页面,泛化有限。下一步是把页面扩到 500+、
82
+ 每轮把线上失败样本加回训练集、拟合温度。v1 的教训:模板化的 DONE 样本会让模型学到"看到 stop when 就答 DONE",
83
+ DONE 样本必须来自真实执行后的落地页。
code/finetune/build_items.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """cases.jsonl + pages.jsonl -> tokenized training items (train split) and eval cases (held-out pages).
2
+
3
+ python finetune/build_items.py out/pages.jsonl out/cases.jsonl out/
4
+ """
5
+ import json, os, sys
6
+ import torch
7
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
8
+ from common_ft import build_request, gold_for
9
+ from transformers import AutoTokenizer
10
+ from laya.common import QTYPES, build_sequence, render_options
11
+
12
+ MAX_LEN, HEAD_MAX_LEN = 1024, int(os.environ.get('LAYA_HEAD', '512'))
13
+ EVAL_EVERY = 5 # pages with index % 5 == 0 are held out
14
+
15
+
16
+ def main():
17
+ pages_f, cases_f, out = sys.argv[1:4]
18
+ pages = [json.loads(l) for l in open(pages_f)]
19
+ cases = [json.loads(l) for l in open(cases_f)]
20
+ for extra in sys.argv[4:]:
21
+ cases += [json.loads(l) for l in open(extra)]
22
+ snap = os.environ.get("LAYA_BASE")
23
+ tok = AutoTokenizer.from_pretrained(os.path.join(snap, "tokenizer"))
24
+ items, n_skip, ev = [], 0, []
25
+ import hashlib
26
+ STOPS = [" Stop when it is open.", " Stop once that page is visible.", " Then stop.", " Finish when it has loaded.", ""]
27
+ for c in cases:
28
+ if c["kind"] == "done" and "page_obj" not in c:
29
+ continue # template DONE cases leak phrasing; real ones come from make_done_cases.py
30
+ page = c.get("page_obj") or pages[c["page"]]
31
+ h = int(hashlib.md5(c["goal"].encode()).hexdigest(), 16)
32
+ goal = c["goal"] + STOPS[h % len(STOPS)]
33
+ c = {**c, "goal": goal}
34
+ state, questions, targets, controls = build_request(page, goal, c.get("history", []))
35
+ gop, gidx = gold_for(c, targets, controls)
36
+ if gop is None:
37
+ n_skip += 1; continue
38
+ held = (hashlib.md5(c["website"].encode()).digest()[0] % EVAL_EVERY == 0) if c.get("source") == "mind2web" else (c["page"] % EVAL_EVERY == 0)
39
+ if held:
40
+ ev.append({**c, "gold_index": gidx}); continue
41
+ golds = {"operation": gop}
42
+ if gidx is not None:
43
+ golds[gop.lower() + "_target"] = gidx
44
+ for qid, gold in golds.items():
45
+ q = questions[qid]; keys = list(q["criteria"])
46
+ target = [1.0 if k == gold else 0.0 for k in keys]
47
+ qq = {"t": "choice", "ins": json.dumps(q["instructions"]), "crit": q["criteria"]}
48
+ seq, markers = build_sequence(tok, state, qq, MAX_LEN, HEAD_MAX_LEN)
49
+ if len(markers) != len(render_options(qq)):
50
+ n_skip += 1; continue
51
+ item = {"ids": seq, "markers": markers, "qtype": QTYPES["choice"], "target": target, "label": keys.index(gold), "qid": qid, "gold_op": gop}
52
+ # class balance: CLICK dominates the operation question, so repeat the rare operations
53
+ reps = {"DONE": 4, "TYPE_TEXT": 3, "SELECT": 3}.get(gop, 1) if qid == "operation" else 1
54
+ if c.get("source") == "dagger":
55
+ reps *= 5 # on-policy teacher corrections from real tasks: few but exactly where the policy fails
56
+ items.extend([item] * reps)
57
+ torch.save(items, os.path.join(out, "train_items.pt"))
58
+ with open(os.path.join(out, "eval_cases.jsonl"), "w") as f:
59
+ for c in ev: f.write(json.dumps(c, ensure_ascii=False) + "\n")
60
+ lens = [len(i["ids"]) for i in items]
61
+ import collections
62
+ print("operation label counts:", dict(collections.Counter(i["gold_op"] for i in items if i["qid"] == "operation")))
63
+ print(f"train items {len(items)} (op {sum(i['qid']=='operation' for i in items)}, target {sum(i['qid']!='operation' for i in items)}), "
64
+ f"eval cases {len(ev)}, skipped {n_skip}, seq len mean {sum(lens)/len(lens):.0f} max {max(lens)}")
65
+
66
+ if __name__ == "__main__":
67
+ main()
code/finetune/calibrate.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fit per-question-type temperatures on held-out cases (NLL), write them into rl_agent_config.json.
2
+
3
+ python finetune/calibrate.py out/pages.jsonl out/eval_cases.jsonl <checkpoint dir>
4
+ """
5
+ import json, os, sys
6
+ import numpy as np, torch
7
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); sys.path.insert(0, "/home/ckl/projects/S/laya-upstream")
8
+ from common_ft import build_request, gold_for
9
+ import laya
10
+ from laya.common import QTYPES, build_sequence, collate_items
11
+
12
+ def main():
13
+ pages = [json.loads(l) for l in open(sys.argv[1])]; cases = [json.loads(l) for l in open(sys.argv[2])]; ck = sys.argv[3]
14
+ agent = laya.load(ck); agent.cfg["max_len"], agent.cfg["head_max_len"] = 1024, int(os.environ.get("LAYA_HEAD", agent.cfg.get("head_max_len_train", 512))); agent.accelerate()
15
+ Z, T, K = [], [], []
16
+ for c in cases[::2]:
17
+ state, questions, targets, controls = build_request(c.get("page_obj") or pages[c["page"]], c["goal"], c.get("history", []))
18
+ gop, gidx = gold_for(c, targets, controls)
19
+ golds = {"operation": gop}
20
+ if gidx is not None: golds[gop.lower() + "_target"] = gidx
21
+ items, keys = [], []
22
+ for qid, gold in golds.items():
23
+ q = questions[qid]; qq = {"t": "choice", "ins": json.dumps(q["instructions"]), "crit": q["criteria"]}
24
+ seq, markers = build_sequence(agent.tok, state, qq, 1024, agent.cfg['head_max_len'])
25
+ items.append({"ids": seq, "markers": markers, "qtype": QTYPES["choice"]}); keys.append((list(q["criteria"]), gold))
26
+ b = collate_items([items], agent.tok.pad_token_id)
27
+ with torch.no_grad(), torch.autocast("cuda", dtype=agent.dtype):
28
+ logits, _ = agent.model(b["input_ids"].cuda(), b["attention_mask"].cuda(), b["marker_pos"].cuda(), b["marker_mask"].cuda(), b["qtype"].cuda())
29
+ for r, (ks, gold) in enumerate(keys):
30
+ z = logits[r, :len(ks)].float().cpu(); Z.append(z); T.append(ks.index(gold))
31
+ kmax = max(len(z) for z in Z); M = torch.full((len(Z), kmax), -1e4)
32
+ for i, z in enumerate(Z): M[i, :len(z)] = z
33
+ y = torch.tensor(T)
34
+ def nll(t): return torch.nn.functional.cross_entropy(M / t, y).item()
35
+ ts = np.exp(np.linspace(np.log(0.2), np.log(10), 200)); best = min(ts, key=nll)
36
+ acc = (M.argmax(-1) == y).float().mean().item()
37
+ conf0 = torch.softmax(M, -1).max(-1).values.mean().item(); conf1 = torch.softmax(M / best, -1).max(-1).values.mean().item()
38
+ print(f"n={len(Z)} acc={acc:.3f} T=1: nll {nll(1.0):.3f} mean conf {conf0:.3f} | T={best:.2f}: nll {nll(best):.3f} mean conf {conf1:.3f}")
39
+ cfgp = os.path.join(ck, "rl_agent_config.json"); cfg = json.load(open(cfgp)); cfg["temperature"] = [float(best), 1.0, 1.0]
40
+ json.dump(cfg, open(cfgp, "w"), indent=2); print("wrote temperature", best, "->", cfgp)
41
+
42
+ if __name__ == "__main__":
43
+ main()
code/finetune/collect_pages.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Crawl real pages with browser-harness and dump their jev-ultrafast observations (element tables + page text).
2
+
3
+ python finetune/collect_pages.py out/pages.jsonl [max_pages=80]
4
+
5
+ Starts from SEEDS, follows a few random same-site links from each page to diversify. One JSON line per page.
6
+ """
7
+ import json, os, random, sys, time
8
+ sys.path.insert(0, "/home/ckl/projects/S/jev-ultrafast")
9
+ os.environ.setdefault("BU_CDP_URL", "http://127.0.0.1:9222")
10
+ from jev_ultrafast.browser import Browser, StalePage
11
+ from jev_ultrafast.model import action_space
12
+
13
+ SEEDS = [
14
+ "https://en.wikipedia.org/wiki/Main_Page", "https://en.wikipedia.org/wiki/Special:Random", "https://en.wikipedia.org/wiki/Python_(programming_language)",
15
+ "https://news.ycombinator.com/", "https://news.ycombinator.com/newest", "https://news.ycombinator.com/login",
16
+ "https://github.com/", "https://github.com/tile-ai/tilelang", "https://github.com/browser-use/browser-use/issues", "https://github.com/login",
17
+ "https://www.python.org/", "https://docs.python.org/3/", "https://pypi.org/", "https://pypi.org/project/laya/",
18
+ "https://archlinux.org/", "https://wiki.archlinux.org/", "https://developer.mozilla.org/en-US/", "https://duckduckgo.com/",
19
+ "https://www.bing.com/", "https://stackoverflow.com/questions", "https://www.reddit.com/", "https://arxiv.org/",
20
+ "https://arxiv.org/list/cs.LG/recent", "https://huggingface.co/models", "https://huggingface.co/convaiinnovations/laya",
21
+ "https://www.saucedemo.com/", "https://the-internet.herokuapp.com/", "https://the-internet.herokuapp.com/login",
22
+ "https://demo.opencart.com/", "https://www.demoblaze.com/", "https://books.toscrape.com/", "https://quotes.toscrape.com/login",
23
+ "https://www.google.com/travel/flights?hl=en", "https://www.booking.com/", "https://www.airbnb.com/", "https://www.amazon.com/",
24
+ "https://www.ebay.com/", "https://www.imdb.com/", "https://www.nytimes.com/", "https://www.bbc.com/",
25
+ "https://www.openstreetmap.org/", "https://weather.com/", "https://www.wolframalpha.com/", "https://translate.google.com/",
26
+ "https://www.gnu.org/", "https://kernel.org/", "https://www.rust-lang.org/", "https://go.dev/", "https://nodejs.org/en",
27
+ "https://www.npmjs.com/", "https://crates.io/", "https://docs.rs/", "https://www.kaggle.com/", "https://paperswithcode.com/",
28
+ # round 2: more sites, more form-heavy pages
29
+ "https://en.wikipedia.org/wiki/Special:Search", "https://en.wikipedia.org/wiki/Portal:Current_events", "https://de.wikipedia.org/", "https://zh.wikipedia.org/",
30
+ "https://news.ycombinator.com/ask", "https://news.ycombinator.com/show", "https://news.ycombinator.com/jobs", "https://news.ycombinator.com/submit",
31
+ "https://github.com/explore", "https://github.com/trending", "https://github.com/pytorch/pytorch", "https://github.com/pytorch/pytorch/pulls",
32
+ "https://github.com/pytorch/pytorch/issues", "https://gitlab.com/explore", "https://gitee.com/explore", "https://about.gitlab.com/",
33
+ "https://www.python.org/downloads/", "https://docs.python.org/3/tutorial/", "https://docs.python.org/3/library/", "https://peps.python.org/",
34
+ "https://pypi.org/search/?q=torch", "https://pypi.org/project/torch/", "https://pypi.org/account/login/", "https://pypi.org/help/",
35
+ "https://the-internet.herokuapp.com/dropdown", "https://the-internet.herokuapp.com/checkboxes", "https://the-internet.herokuapp.com/forgot_password",
36
+ "https://the-internet.herokuapp.com/inputs", "https://the-internet.herokuapp.com/tables", "https://the-internet.herokuapp.com/javascript_alerts",
37
+ "https://demo.opencart.com/index.php?route=product/category&path=20", "https://demo.opencart.com/index.php?route=account/login",
38
+ "https://demo.opencart.com/index.php?route=account/register", "https://demo.opencart.com/index.php?route=product/search&search=mac",
39
+ "https://www.demoblaze.com/cart.html", "https://www.demoblaze.com/prod.html?idp_=1", "https://books.toscrape.com/catalogue/page-2.html",
40
+ "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html", "https://quotes.toscrape.com/tag/love/", "https://quotes.toscrape.com/page/2/",
41
+ "https://www.saucedemo.com/inventory.html", "https://automationexercise.com/", "https://automationexercise.com/login", "https://automationexercise.com/products",
42
+ "https://practicetestautomation.com/practice-test-login/", "https://demoqa.com/", "https://demoqa.com/text-box", "https://demoqa.com/select-menu",
43
+ "https://demoqa.com/webtables", "https://www.selenium.dev/selenium/web/web-form.html", "https://formy-project.herokuapp.com/", "https://formy-project.herokuapp.com/form",
44
+ "https://parabank.parasoft.com/parabank/index.htm", "https://parabank.parasoft.com/parabank/register.htm", "https://www.globalsqa.com/angularJs-protractor/BankingProject/",
45
+ "https://opensource-demo.orangehrmlive.com/", "https://magento.softwaretestingboard.com/", "https://magento.softwaretestingboard.com/women.html",
46
+ "https://www.airbnb.com/s/London/homes", "https://www.booking.com/searchresults.html?ss=Paris", "https://www.google.com/travel/hotels?hl=en",
47
+ "https://www.google.com/maps?hl=en", "https://www.google.com/search?q=tilelang&hl=en", "https://duckduckgo.com/?q=modernbert", "https://www.bing.com/search?q=laya",
48
+ "https://arxiv.org/list/cs.CL/new", "https://arxiv.org/abs/2412.13663", "https://arxiv.org/search/?query=flash+attention&searchtype=all",
49
+ "https://huggingface.co/datasets", "https://huggingface.co/spaces", "https://huggingface.co/docs", "https://huggingface.co/login", "https://huggingface.co/answerdotai/ModernBERT-base",
50
+ "https://www.modelscope.cn/models", "https://www.modelscope.cn/datasets", "https://www.kaggle.com/datasets", "https://www.kaggle.com/competitions",
51
+ "https://stackoverflow.com/", "https://stackoverflow.com/questions/tagged/python", "https://superuser.com/", "https://askubuntu.com/",
52
+ "https://www.reddit.com/r/MachineLearning/", "https://old.reddit.com/", "https://old.reddit.com/r/python/", "https://lobste.rs/",
53
+ "https://www.bbc.com/news", "https://www.bbc.com/sport", "https://www.theguardian.com/international", "https://www.reuters.com/", "https://apnews.com/",
54
+ "https://www.imdb.com/chart/top/", "https://www.imdb.com/find/?q=inception", "https://www.rottentomatoes.com/", "https://www.goodreads.com/",
55
+ "https://www.openstreetmap.org/search?query=Berlin", "https://www.wikidata.org/", "https://commons.wikimedia.org/", "https://www.wiktionary.org/",
56
+ "https://developer.mozilla.org/en-US/docs/Web/JavaScript", "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select", "https://web.dev/",
57
+ "https://www.rust-lang.org/learn", "https://doc.rust-lang.org/book/", "https://go.dev/doc/", "https://pkg.go.dev/", "https://nodejs.org/en/download",
58
+ "https://www.npmjs.com/package/react", "https://react.dev/", "https://vuejs.org/", "https://tailwindcss.com/docs", "https://getbootstrap.com/docs/",
59
+ "https://www.wolframalpha.com/input?i=2%2B2", "https://translate.google.com/?sl=en&tl=zh-CN&text=hello", "https://www.deepl.com/translator",
60
+ "https://weather.com/weather/today/l/USNY0996", "https://www.timeanddate.com/", "https://www.xe.com/currencyconverter/", "https://www.calculator.net/",
61
+ "https://archlinux.org/packages/", "https://aur.archlinux.org/", "https://wiki.archlinux.org/title/Installation_guide", "https://www.kernel.org/doc/",
62
+ "https://www.debian.org/", "https://ubuntu.com/download", "https://www.gnu.org/software/", "https://www.fsf.org/",
63
+ ]
64
+
65
+ def observe(url, timeout=25):
66
+ b = Browser(url)
67
+ try:
68
+ page = b.observe(screenshot=False)
69
+ links = b.evaluate("""(() => { const out=[]; for (const a of document.querySelectorAll('a[href]')) {
70
+ const h=a.href; if (h.startsWith(location.origin) && !h.includes('#') && h!==location.href) out.push(h); } return out.slice(0,400); })()""") or []
71
+ return page, links
72
+ finally:
73
+ b.close()
74
+
75
+ def main():
76
+ out, max_pages = sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 80
77
+ os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
78
+ seen = set()
79
+ if os.path.exists(out):
80
+ for line in open(out):
81
+ seen.add(json.loads(line)["url"])
82
+ rng = random.Random(0)
83
+ queue = list(SEEDS); rng.shuffle(queue)
84
+ n = len(seen)
85
+ with open(out, "a") as f:
86
+ while queue and n < max_pages:
87
+ url = queue.pop(0)
88
+ if url in seen:
89
+ continue
90
+ t = time.time()
91
+ try:
92
+ page, links = observe(url)
93
+ except Exception as e:
94
+ print(f"skip {url}: {type(e).__name__}: {str(e)[:80]}", flush=True); continue
95
+ elements, targets, controls = action_space(page["actions"])
96
+ if len(elements) < 5 or len(elements) > 160:
97
+ print(f"skip {url}: {len(elements)} elements", flush=True); continue
98
+ seen.add(page["url"]); n += 1
99
+ f.write(json.dumps({"url": page["url"], "title": page["title"], "text": page["text"], "actions": page["actions"],
100
+ "scroll": page.get("scroll")}, ensure_ascii=False) + "\n"); f.flush()
101
+ print(f"[{n}] {len(elements):3d} elements {time.time()-t:4.1f}s {page['title'][:60]}", flush=True)
102
+ rng.shuffle(links)
103
+ queue.extend(l for l in links[:3] if l not in seen)
104
+ print("done", n, "pages")
105
+
106
+ if __name__ == "__main__":
107
+ main()
code/finetune/common_ft.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared: build the exact jev-ultrafast systemone request for a page + goal, with the server-side compaction."""
2
+ import importlib.util, json, sys, types
3
+
4
+ JEV = "/home/ckl/projects/S/jev-ultrafast/jev_ultrafast"
5
+ # load model.py / questions.py without running the package __init__ (which pulls in browser-harness)
6
+ if "jev_ultrafast" not in sys.modules:
7
+ _pkg = types.ModuleType("jev_ultrafast"); _pkg.__path__ = [JEV]; sys.modules["jev_ultrafast"] = _pkg
8
+ for _name in ("questions", "model"):
9
+ _spec = importlib.util.spec_from_file_location(f"jev_ultrafast.{_name}", f"{JEV}/{_name}.py")
10
+ _m = importlib.util.module_from_spec(_spec); sys.modules[_spec.name] = _m; _spec.loader.exec_module(_m)
11
+ from jev_ultrafast.model import action_space # noqa: E402
12
+ from jev_ultrafast.questions import NEXT_ACTION, TARGET # noqa: E402
13
+
14
+ import os
15
+ FMT = os.environ.get("LAYA_FMT", "v1")
16
+ # v1: jev's state verbatim (page text up to 6000 chars + the whole element table as JSON) -- the 1024-token budget truncates
17
+ # most of it, so the model often never sees the candidates' context. 3000 chars was tried (v7): -0.04 top-1.
18
+ # v2: elements live only in the option list (full label + role + value); state keeps title/url/history and 1500 chars of text.
19
+ # v3: v2 + option labels capped at 50 chars and 1200 chars of text (~30% fewer tokens; for the 322M base to hit ~20 ms/step)
20
+ PAGE_TEXT_CHARS = {"v2": 1500, "v3": 1200}.get(FMT, 6000)
21
+ LABEL_CHARS = 50 if FMT == "v3" else 10000
22
+
23
+ LABELS = {
24
+ "CLICK": "Click an element, button, menu option, autocomplete suggestion, or calendar day.",
25
+ "TYPE_TEXT": "Enter or replace text in an editable field. A small LLM will supply the value from the goal.",
26
+ "SELECT": "Select an observed dropdown value.",
27
+ }
28
+
29
+
30
+ def compact(v):
31
+ if isinstance(v, dict) and "element" in v:
32
+ s = str(v["element"])[:LABEL_CHARS]
33
+ if v.get("role"):
34
+ s += f" ({v['role']})"
35
+ if v.get("current_value"):
36
+ s += f" = {str(v['current_value'])[:30]!r}"
37
+ for k in ("checked", "selected", "expanded"):
38
+ if k in v:
39
+ s += f" {k}={v[k]}"
40
+ return s
41
+ return v
42
+
43
+
44
+ def build_request(page, goal, history=()):
45
+ """Mirror of jev_ultrafast.model.choose() up to the HTTP call. Returns (state, questions, targets, controls)."""
46
+ elements, targets, controls = action_space(page["actions"])
47
+ operations = {key: LABELS[key] for key in targets}
48
+ operations.update({key: value["label"] for key, value in controls.items()})
49
+ operations.update(DONE="Every requirement is visibly satisfied.", BLOCKED="No supported operation can progress.")
50
+ questions = {"operation": {"type": "choice", "criteria": operations, "instructions": {"goal": goal, "rules": NEXT_ACTION}}}
51
+ for operation, candidates in targets.items():
52
+ questions[operation.lower() + "_target"] = {
53
+ "type": "choice",
54
+ "criteria": {index: {"element": f"[{index}] {a['label']}", "current_value": a.get("current_value", a.get("value", "")),
55
+ **{k: a[k] for k in ("role", "checked", "selected", "expanded") if k in a}} for index, a in candidates.items()},
56
+ "instructions": {"goal": goal, "operation": operation, "rules": [NEXT_ACTION, TARGET]},
57
+ }
58
+ state = {"page": {"url": page["url"], "title": page["title"], "text": page["text"][:PAGE_TEXT_CHARS]},
59
+ "recent_actions": [{k: h.get(k) for k in ("action", "kind", "text", "page_changed")} for h in list(history)[-10:]]}
60
+ if FMT not in ("v2", "v3"):
61
+ state["elements"] = elements
62
+ for q in questions.values():
63
+ q["criteria"] = {k: compact(v) for k, v in q["criteria"].items()}
64
+ return state, questions, targets, controls
65
+
66
+
67
+ def gold_for(case, targets, controls):
68
+ """(gold operation key, gold target index or None) for a case in the operation/target question vocab."""
69
+ op = case["gold_op"]
70
+ if op == "DONE":
71
+ return "DONE", None
72
+ if op in ("SCROLL_DOWN", "SCROLL_UP", "WAIT"): # page-level controls: operation question only
73
+ return (op, None) if op in {k.upper() for k in controls} else (None, None)
74
+ for index, a in targets.get(op, {}).items():
75
+ if a["id"] == case["gold_id"]:
76
+ return op, index
77
+ return None, None
code/finetune/convert_mind2web.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mind2Web (osunlp/Mind2Web from ModelScope) -> cases in this repo's format (page_obj with jev-style actions).
2
+
3
+ python finetune/convert_mind2web.py finetune/data/mind2web/data/train/*.json finetune/out/m2w_cases.jsonl
4
+
5
+ Each Mind2Web action becomes one case: goal = confirmed_task, history = previous action_reprs, gold = the positive
6
+ candidate; the element table = positive + up to MAX_NEG sampled negative candidates rendered like jev's snapshot.js
7
+ (label from text / aria-label / placeholder / alt / title / value, role from tag).
8
+ """
9
+ import json, random, re, sys
10
+ from bs4 import BeautifulSoup
11
+
12
+ MAX_NEG = 44
13
+ ROLE = {"a": "link", "button": "button", "input": "textbox", "textarea": "textbox", "select": "combobox", "option": "option",
14
+ "img": "img", "li": "listitem", "label": "label", "span": "generic", "div": "generic", "svg": "img", "h1": "heading",
15
+ "h2": "heading", "h3": "heading", "p": "text", "td": "cell", "th": "columnheader", "tr": "row", "ul": "list"}
16
+
17
+
18
+ def label_of(el):
19
+ attrs = el.attrs
20
+ for k in ("aria-label", "placeholder", "alt", "title"):
21
+ v = attrs.get(k)
22
+ if isinstance(v, list): v = " ".join(v)
23
+ if v and v.strip(): return v.strip()
24
+ txt = " ".join(el.get_text(" ", strip=True).split())
25
+ if txt: return txt[:80]
26
+ v = attrs.get("value")
27
+ if v: return str(v).strip()[:80]
28
+ return (attrs.get("name") or attrs.get("id") or el.name or "")[:60]
29
+
30
+
31
+ def kind_of(el, op):
32
+ t = el.name
33
+ typ = (el.attrs.get("type") or "").lower()
34
+ if t == "select": return "select"
35
+ if t == "textarea" or (t == "input" and typ in ("", "text", "search", "email", "password", "number", "tel", "url")) or el.attrs.get("contenteditable"):
36
+ return "fill"
37
+ return "click"
38
+
39
+
40
+ def convert_action(task, ai, action, rng):
41
+ soup = BeautifulSoup(action["cleaned_html"], "lxml")
42
+ by_id = {}
43
+ for el in soup.find_all(attrs={"backend_node_id": True}):
44
+ by_id[el.attrs["backend_node_id"]] = el
45
+ pos = action["pos_candidates"]
46
+ if not pos: return None
47
+ gold_el = by_id.get(pos[0]["backend_node_id"])
48
+ if gold_el is None: return None
49
+ negs = [c for c in action["neg_candidates"] if c["backend_node_id"] in by_id]
50
+ rng.shuffle(negs)
51
+ cands = [pos[0]] + negs[:MAX_NEG]
52
+ rng.shuffle(cands)
53
+ op = action["operation"]["op"]
54
+ # values typed/selected in earlier steps: a field that already holds its value must show it (jev's rules key on that)
55
+ filled = {}
56
+ for r in task["action_reprs"][:ai]:
57
+ m = re.match(r"\[(\w+)\]\s+(.*?)\s+->\s+(TYPE|SELECT):\s*(.*)$", r)
58
+ if m: filled[" ".join(m.group(2).split()).lower()] = m.group(4).strip()
59
+ actions, gold_id, node = [], None, 0
60
+ for c in cands:
61
+ el = by_id[c["backend_node_id"]]
62
+ lab = label_of(el)
63
+ if not lab: continue
64
+ node += 1
65
+ is_gold = c["backend_node_id"] == pos[0]["backend_node_id"]
66
+ kind = {"CLICK": "click", "TYPE": "fill", "SELECT": "select"}[op] if is_gold else kind_of(el, op)
67
+ role = ROLE.get(el.name, "generic")
68
+ base = {"node": node, "label": lab, "role": role}
69
+ if kind == "select":
70
+ opts = [o.get_text(" ", strip=True) for o in el.find_all("option")][:8] or [action["operation"]["value"] or "option"]
71
+ if is_gold and action["operation"]["value"] and action["operation"]["value"] not in opts:
72
+ opts = [action["operation"]["value"]] + opts[:7]
73
+ for oi, o in enumerate(opts):
74
+ a = {**base, "id": f"select:{node}:{oi}", "kind": "select", "value": o, "label": f"{lab} → {o}", "current_value": ""}
75
+ actions.append(a)
76
+ if is_gold and (o == action["operation"]["value"] or (oi == 0 and not action["operation"]["value"])): gold_id = a["id"]
77
+ continue
78
+ a = {**base, "id": f"{kind}:{node}", "kind": kind}
79
+ if kind == "fill":
80
+ a["value"] = filled.get(lab.lower(), "")
81
+ a["current_value"] = a["value"]
82
+ actions.append(a)
83
+ if is_gold: gold_id = a["id"]
84
+ if gold_id is None: return None
85
+ for k, lab in (("wait", "Wait for the page to update"), ("scroll_down", "Scroll down"), ("scroll_up", "Scroll up")):
86
+ actions.append({"id": k, "kind": "wait" if k == "wait" else "scroll", "label": lab, "node": None, "delta": 600 if k == "scroll_down" else -600})
87
+ text = " ".join(soup.get_text(" ", strip=True).split())[:6000]
88
+ hist = []
89
+ for r in task["action_reprs"][:ai]:
90
+ lab_, _, opv = r.rpartition(" -> ")
91
+ kind_, _, val = opv.partition(": ")
92
+ hist.append({"action": lab_.strip(), "kind": {"CLICK": "click", "TYPE": "fill", "SELECT": "select"}.get(kind_, kind_.lower()),
93
+ "text": val or None, "page_changed": kind_ == "CLICK"})
94
+ gold_op = {"CLICK": "CLICK", "TYPE": "TYPE_TEXT", "SELECT": "SELECT"}[op]
95
+ return {"page": -1, "url": f"https://{task['website']}.com/", "title": task["website"], "goal": task["confirmed_task"], "gold_op": gold_op,
96
+ "gold_id": gold_id, "kind": {"CLICK": "click", "TYPE": "fill", "SELECT": "select"}[op], "label": label_of(gold_el), "history": hist,
97
+ "source": "mind2web", "task_id": task["annotation_id"], "website": task["website"],
98
+ "page_obj": {"url": f"https://{task['website']}.com/", "title": task["website"], "text": text, "actions": actions}}
99
+
100
+
101
+ def main():
102
+ files, out = sys.argv[1:-1], sys.argv[-1]
103
+ rng = random.Random(0); n = 0; skipped = 0
104
+ with open(out, "w") as f:
105
+ for fn in files:
106
+ tasks = json.load(open(fn))
107
+ for t in tasks:
108
+ for ai, a in enumerate(t["actions"]):
109
+ c = convert_action(t, ai, a, rng)
110
+ if c is None: skipped += 1; continue
111
+ f.write(json.dumps(c, ensure_ascii=False) + "\n"); n += 1
112
+ print(f"{fn}: total {n} cases, skipped {skipped}", flush=True)
113
+ print("wrote", n)
114
+
115
+ if __name__ == "__main__":
116
+ main()
code/finetune/dagger.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DAgger-style harvesting: run real tasks with the current laya agent, and at every step ask the local Qwen teacher which
2
+ action is right given the page's element table. Disagreements (and agreements) become training cases with the *agent's*
3
+ on-policy states, so the next model learns exactly where this one goes wrong.
4
+
5
+ python finetune/dagger.py out/dagger_cases.jsonl [tasks.jsonl] (services: chromium 9222, laya 8791, sglang 30000)
6
+
7
+ tasks.jsonl lines: {"url": ..., "goal": ...}; default = apps/browser_suite.TASKS plus extra goals below.
8
+ """
9
+ import json, os, sys, time
10
+ import httpx
11
+ sys.path.insert(0, "/home/ckl/projects/S/jev-ultrafast"); sys.path.insert(0, "/home/ckl/projects/S/laya/apps")
12
+ os.environ.update(BU_CDP_URL="http://127.0.0.1:9222", TYPESAFE_BASE_URL="http://127.0.0.1:8791", TYPESAFE_API_KEY="local",
13
+ TEXT_MODEL_API_KEY="local", TEXT_MODEL_BASE_URL="http://127.0.0.1:30000/v1", TEXT_MODEL="Qwen/Qwen3-8B-AWQ",
14
+ TEXT_MODEL_EXTRA_JSON='{"chat_template_kwargs": {"enable_thinking": false}}')
15
+ from jev_ultrafast import Agent
16
+ from jev_ultrafast.model import action_space
17
+ from browser_suite import TASKS
18
+
19
+ EXTRA = [
20
+ ("https://en.wikipedia.org/wiki/Main_Page", "Open the article about Albert Einstein."),
21
+ ("https://news.ycombinator.com/", "Open the 'past' page."),
22
+ ("https://news.ycombinator.com/", "Open the comments of the first story on the front page."),
23
+ ("https://github.com/tile-ai/tilelang", "Open the Pull requests tab."),
24
+ ("https://github.com/tile-ai/tilelang", "Open the README's 'examples' folder."),
25
+ ("https://www.python.org/", "Open the documentation page."),
26
+ ("https://docs.python.org/3/", "Open the tutorial."),
27
+ ("https://books.toscrape.com/", "Open the 'Mystery' category and then open the first book in it."),
28
+ ("https://books.toscrape.com/", "Go to page 2 of the catalogue."),
29
+ ("https://the-internet.herokuapp.com/", "Open the 'Dropdown' example and select 'Option 2'."),
30
+ ("https://the-internet.herokuapp.com/", "Open the 'Checkboxes' example and tick the first checkbox."),
31
+ ("https://quotes.toscrape.com/", "Open the quotes tagged 'love'."),
32
+ ("https://quotes.toscrape.com/", "Go to the next page of quotes."),
33
+ ("https://arxiv.org/", "Open the listing of new submissions in cs.CL."),
34
+ ("https://pypi.org/", "Search PyPI for 'tilelang' and open the project page."),
35
+ ("https://duckduckgo.com/", "Search for 'ModernBERT paper'."),
36
+ ("https://www.saucedemo.com/", "Log in with username 'standard_user' and password 'secret_sauce', then add the 'Sauce Labs Backpack' to the cart."),
37
+ ("https://demo.opencart.com/", "Open the 'Desktops' category from the top menu."),
38
+ ("https://www.demoblaze.com/", "Open the 'Laptops' category."),
39
+ ("https://huggingface.co/models", "Search models for 'laya'."),
40
+ ]
41
+ TEACHER = os.environ["TEXT_MODEL_BASE_URL"] + "/chat/completions"
42
+ client = httpx.Client(timeout=180)
43
+ SYS = """You are the teacher for a browser agent. You see the user's goal, the actions taken so far, the current page (title, url, text excerpt) and a
44
+ numbered table of the controls on it. Decide the single best NEXT step:
45
+ - {"operation": "CLICK", "index": n} click control n
46
+ - {"operation": "TYPE_TEXT", "index": n} type into text control n (a separate helper supplies the value)
47
+ - {"operation": "SELECT", "index": n, "value": "..."} choose that option of select control n
48
+ - {"operation": "DONE"} every requirement of the goal is already visibly satisfied on this page
49
+ - {"operation": "WAIT"} / {"operation": "SCROLL_DOWN"} / {"operation": "SCROLL_UP"}
50
+ Do not re-do satisfied steps; a field that already shows the requested value is done. Return JSON only."""
51
+
52
+ def teach(goal, history, page, elements):
53
+ table = [{"index": e["index"], "label": e["label"][:70], "role": e.get("role"), "ops": e["operations"], **({"value": e["value"]} if e.get("value") else {})} for e in elements[:120]]
54
+ user = {"goal": goal, "actions_so_far": [{k: h.get(k) for k in ("action", "kind", "text")} for h in history[-8:]],
55
+ "page": {"title": page["title"], "url": page["url"], "text": page["text"][:2500]}, "controls": table}
56
+ body = {"model": os.environ["TEXT_MODEL"], "max_tokens": 120, "temperature": 0.0, "response_format": {"type": "json_object"},
57
+ "chat_template_kwargs": {"enable_thinking": False},
58
+ "messages": [{"role": "system", "content": SYS}, {"role": "user", "content": json.dumps(user, ensure_ascii=False)}]}
59
+ r = client.post(TEACHER, json=body).json()
60
+ return json.loads(r["choices"][0]["message"]["content"])
61
+
62
+ def to_case(goal, history, page, verdict):
63
+ elements, targets, controls = action_space(page["actions"])
64
+ op = str(verdict.get("operation", "")).upper()
65
+ if op in ("DONE",):
66
+ return {"page": -1, "url": page["url"], "title": page["title"], "goal": goal, "gold_op": "DONE", "gold_id": "DONE", "kind": "done", "label": "",
67
+ "history": history, "source": "dagger", "page_obj": {k: page[k] for k in ("url", "title", "text", "actions")}}
68
+ if op in ("CLICK", "TYPE_TEXT", "SELECT"):
69
+ idx = str(verdict.get("index"))
70
+ cands = targets.get(op, {})
71
+ if op == "SELECT":
72
+ hit = [k for k, a in cands.items() if k.split(":")[0] == idx and (a["value"] == verdict.get("value") or a["label"].endswith(str(verdict.get("value"))))]
73
+ key = hit[0] if hit else None
74
+ else:
75
+ key = idx if idx in cands else None
76
+ if key is None: return None
77
+ a = cands[key]
78
+ return {"page": -1, "url": page["url"], "title": page["title"], "goal": goal, "gold_op": op, "gold_id": a["id"], "kind": a["kind"], "label": a["label"],
79
+ "history": history, "source": "dagger", "page_obj": {k: page[k] for k in ("url", "title", "text", "actions")}}
80
+ return None
81
+
82
+ def main():
83
+ out = sys.argv[1]
84
+ tasks = [(u, g) for _, u, g, _ in TASKS] + EXTRA
85
+ if len(sys.argv) > 2:
86
+ tasks += [(json.loads(l)["url"], json.loads(l)["goal"]) for l in open(sys.argv[2])]
87
+ n_cases = n_dis = 0
88
+ with open(out, "a") as f:
89
+ for url, goal in tasks:
90
+ t0 = time.time()
91
+ try:
92
+ with Agent(url, goal) as agent:
93
+ steps = 0
94
+ while agent.state["status"] not in ("done", "blocked") and steps < 12:
95
+ page = agent.state["page"]; history = list(agent.state["history"])
96
+ elements = action_space(page["actions"])[0]
97
+ try:
98
+ verdict = teach(goal, history, page, elements)
99
+ except Exception as e:
100
+ print(" teacher fail", str(e)[:60]); break
101
+ case = to_case(goal, [{k: h.get(k) for k in ("action", "kind", "text", "page_changed")} for h in history], page, verdict)
102
+ if case:
103
+ f.write(json.dumps(case, ensure_ascii=False) + "\n"); f.flush(); n_cases += 1
104
+ # step the agent with its own policy
105
+ try:
106
+ st = agent.command("tick")
107
+ except Exception as e:
108
+ print(" tick fail", type(e).__name__, str(e)[:50]); break
109
+ d = st["decisions"][-1] if st["decisions"] else None
110
+ agent_choice = (d["operation"], d.get("target")) if d else None
111
+ teacher_choice = (str(verdict.get("operation", "")).upper(), str(verdict.get("index")) if verdict.get("index") is not None else None)
112
+ if agent_choice and agent_choice[0] != teacher_choice[0] or (agent_choice and agent_choice[1] != teacher_choice[1] and teacher_choice[0] in ("CLICK", "TYPE_TEXT")):
113
+ n_dis += 1
114
+ steps += 1
115
+ except Exception as e:
116
+ print(f" task fail {type(e).__name__}: {str(e)[:60]}")
117
+ print(f"{goal[:60]:60s} cases={n_cases} disagreements={n_dis} {time.time()-t0:.0f}s", flush=True)
118
+ print("wrote", n_cases, "cases ->", out)
119
+
120
+ if __name__ == "__main__":
121
+ main()
code/finetune/eval.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Held-out eval: operation accuracy and target top-1 (given the gold operation) on unseen pages.
2
+
3
+ python finetune/eval.py out/pages.jsonl out/eval_cases.jsonl <checkpoint dir> [subfolder]
4
+ """
5
+ import json, os, sys, time
6
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
7
+ sys.path.insert(0, "/home/ckl/projects/S/laya-upstream") # laya with Agent.accelerate()
8
+ from common_ft import build_request
9
+ import laya
10
+
11
+ def main():
12
+ pages = [json.loads(l) for l in open(sys.argv[1])]; cases = [json.loads(l) for l in open(sys.argv[2])]
13
+ agent = laya.load(sys.argv[3], subfolder=sys.argv[4] if len(sys.argv) > 4 else None)
14
+ agent.cfg["max_len"], agent.cfg["head_max_len"] = 1024, int(os.environ.get("LAYA_HEAD", agent.cfg.get("head_max_len_train", 512)))
15
+ agent.accelerate()
16
+ op_ok = tgt_ok = tgt_n = 0; ranks = []; t = time.time(); by_kind = {}
17
+ for c in cases:
18
+ state, questions, targets, controls = build_request(c.get("page_obj") or pages[c["page"]], c["goal"], c.get("history", []))
19
+ r = agent.predict(state, questions)["answers"]
20
+ op_hit = r["operation"]["choice"] == c["gold_op"]; op_ok += op_hit
21
+ k = by_kind.setdefault(f"{c.get('source', 'live'):9s} {c['gold_op']}", [0, 0, 0]); k[0] += 1; k[1] += op_hit
22
+ if c.get("gold_index") is not None:
23
+ a = r[c["gold_op"].lower() + "_target"]; probs = a["probabilities"]
24
+ order = sorted(probs, key=probs.get, reverse=True); rank = order.index(c["gold_index"]) + 1
25
+ tgt_n += 1; tgt_ok += rank == 1; ranks.append(rank / len(probs)); k[2] += rank == 1
26
+ dt = (time.time() - t) / len(cases) * 1000
27
+ print(f"{sys.argv[3]}/{sys.argv[4] if len(sys.argv) > 4 else ''}: cases {len(cases)} operation acc {op_ok/len(cases):.3f} "
28
+ f"target top-1 {tgt_ok/max(1,tgt_n):.3f} (n={tgt_n}, mean normalized rank {sum(ranks)/max(1,len(ranks)):.3f}) {dt:.0f} ms/case")
29
+ for kind, (n, o, tg) in sorted(by_kind.items()):
30
+ print(f" {kind:19s} n={n:4d} op acc {o/n:.2f}" + (f" target top-1 {tg/n:.2f}" if not kind.endswith("DONE") else ""))
31
+
32
+ if __name__ == "__main__":
33
+ main()
code/finetune/gen_goals.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reverse-generate browser goals with a local LLM: pick an element as gold, ask Qwen to write the user goal for it.
2
+
3
+ python finetune/gen_goals.py out/pages.jsonl out/cases.jsonl [per_page=12]
4
+
5
+ Each case: {url, title, goal, gold_op, gold_id, gold_node, kind, label}. Also one DONE case per page.
6
+ """
7
+ import json, os, random, sys, threading
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ import httpx
10
+ sys.path.insert(0, "/home/ckl/projects/S/jev-ultrafast")
11
+ from jev_ultrafast.model import action_space
12
+
13
+ LLM = os.environ.get("TEXT_MODEL_BASE_URL", "http://127.0.0.1:30000/v1") + "/chat/completions"
14
+ MODEL = os.environ.get("TEXT_MODEL", "Qwen/Qwen3-8B-AWQ")
15
+ client = httpx.Client(timeout=120)
16
+
17
+ SYS = """You write realistic browser-automation goals. Given a web page and ONE target control on it, write the goal a user
18
+ would give to an assistant such that the assistant's NEXT step is to use exactly that control. Rules:
19
+ - One or two sentences, natural language, from the user's perspective, mention what they want (not the UI mechanics).
20
+ - The goal must single out the target among the other listed controls; do not mention element numbers.
21
+ - For a text field, the goal must imply typing a concrete value into it (include the value).
22
+ - For a dropdown option, the goal must imply choosing that option.
23
+ - Vary phrasing: sometimes terse ("open the login page"), sometimes contextual ("I want to read about X, take me there").
24
+ Return JSON: {"goal": "..."}"""
25
+
26
+ def ask(page, target, others):
27
+ user = {"page": {"title": page["title"], "url": page["url"], "text_excerpt": page["text"][:700]},
28
+ "target": {"kind": target["kind"], "label": target["label"], "role": target.get("role"),
29
+ "value": target.get("value", target.get("current_value", ""))},
30
+ "other_controls_on_page": [o["label"][:60] for o in others]}
31
+ body = {"model": MODEL, "max_tokens": 200, "temperature": 0.9, "response_format": {"type": "json_object"},
32
+ "chat_template_kwargs": {"enable_thinking": False},
33
+ "messages": [{"role": "system", "content": SYS}, {"role": "user", "content": json.dumps(user, ensure_ascii=False)}]}
34
+ r = client.post(LLM, json=body).json()
35
+ goal = json.loads(r["choices"][0]["message"]["content"])["goal"]
36
+ return goal.strip()
37
+
38
+ def main():
39
+ src, out, per_page = sys.argv[1], sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 else 12
40
+ pages = [json.loads(l) for l in open(src)]
41
+ rng = random.Random(1)
42
+ jobs = []
43
+ for pi, page in enumerate(pages):
44
+ elements, targets, controls = action_space(page["actions"])
45
+ cands = [a for a in page["actions"] if a["kind"] in ("click", "fill", "select")]
46
+ # de-duplicate by label, prefer informative labels
47
+ seen, uniq = set(), []
48
+ for a in cands:
49
+ lab = a["label"].split(" → ")[0].strip()
50
+ if len(lab) < 2 or lab.lower() in seen: continue
51
+ seen.add(lab.lower()); uniq.append(a)
52
+ rng.shuffle(uniq)
53
+ fills = [a for a in uniq if a["kind"] == "fill"][:3]
54
+ picks = fills + [a for a in uniq if a["kind"] != "fill"][: max(0, per_page - len(fills))]
55
+ for a in picks:
56
+ others = rng.sample([o for o in uniq if o is not a], min(10, len(uniq) - 1))
57
+ jobs.append((pi, page, a, others))
58
+ print(f"{len(pages)} pages -> {len(jobs)} goal jobs", flush=True)
59
+ lock = threading.Lock(); done = [0]
60
+ def work(job):
61
+ pi, page, a, others = job
62
+ try:
63
+ goal = ask(page, a, others)
64
+ except Exception as e:
65
+ print("fail", type(e).__name__, str(e)[:60], flush=True); return None
66
+ with lock:
67
+ done[0] += 1
68
+ if done[0] % 50 == 0: print(f" {done[0]}/{len(jobs)}", flush=True)
69
+ op = {"click": "CLICK", "fill": "TYPE_TEXT", "select": "SELECT"}[a["kind"]]
70
+ return {"page": pi, "url": page["url"], "title": page["title"], "goal": goal, "gold_op": op, "gold_id": a["id"],
71
+ "gold_node": a.get("node"), "kind": a["kind"], "label": a["label"]}
72
+ with ThreadPoolExecutor(16) as ex:
73
+ cases = [c for c in ex.map(work, jobs) if c]
74
+ for pi, page in enumerate(pages): # DONE cases: the goal is already satisfied by the current page
75
+ cases.append({"page": pi, "url": page["url"], "title": page["title"], "gold_op": "DONE", "gold_id": "DONE", "kind": "done",
76
+ "label": "", "goal": rng.choice([f"Open the page titled '{page['title'][:70]}'. Stop once it is open.",
77
+ f"Go to {page['url']} and stop when it has loaded.",
78
+ f"Navigate to the '{page['title'][:50]}' page."])})
79
+ with open(out, "w") as f:
80
+ for c in cases: f.write(json.dumps(c, ensure_ascii=False) + "\n")
81
+ print("wrote", len(cases), "cases ->", out)
82
+ for c in rng.sample(cases, 8): print(f" [{c['gold_op']:9s}] {c['label'][:35]:35s} <- {c['goal'][:90]}")
83
+
84
+ if __name__ == "__main__":
85
+ main()
code/finetune/gen_step2.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step-2 negatives: on landing pages from done_cases (history = one executed click), reverse-generate NEW goals whose
2
+ next step is another element on that page. Breaks the 'any history => DONE' shortcut.
3
+
4
+ python finetune/gen_step2.py out/done_cases.jsonl out/step2_cases.jsonl [per_page=3]
5
+ """
6
+ import json, random, sys, threading
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ sys.path.insert(0, "/home/ckl/projects/S/laya/finetune")
9
+ from gen_goals import ask
10
+
11
+ def main():
12
+ src, out, per = sys.argv[1], sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 else 3
13
+ dones = [json.loads(l) for l in open(src)]
14
+ rng = random.Random(7); jobs = []
15
+ for d in dones:
16
+ page = d["page_obj"]
17
+ cands = [a for a in page["actions"] if a["kind"] in ("click", "fill", "select") and len(a["label"].split(" → ")[0].strip()) > 1
18
+ and a["label"] != d["label"]]
19
+ seen, uniq = set(), []
20
+ for a in cands:
21
+ k = a["label"].lower()
22
+ if k in seen: continue
23
+ seen.add(k); uniq.append(a)
24
+ rng.shuffle(uniq)
25
+ fills = [a for a in uniq if a["kind"] == "fill"][:1]
26
+ for a in fills + [a for a in uniq if a["kind"] != "fill"][: per - len(fills)]:
27
+ jobs.append((d, a, rng.sample([o for o in uniq if o is not a], min(10, len(uniq) - 1))))
28
+ print(f"{len(dones)} landing pages -> {len(jobs)} jobs", flush=True)
29
+ lock = threading.Lock(); n = [0]
30
+ def work(job):
31
+ d, a, others = job
32
+ try:
33
+ goal = ask(d["page_obj"], a, others)
34
+ except Exception as e:
35
+ print("fail", str(e)[:60], flush=True); return None
36
+ with lock:
37
+ n[0] += 1
38
+ if n[0] % 100 == 0: print(f" {n[0]}/{len(jobs)}", flush=True)
39
+ op = {"click": "CLICK", "fill": "TYPE_TEXT", "select": "SELECT"}[a["kind"]]
40
+ # keep the history: the previous click is unrelated to the new goal, which is what happens mid-task all the time
41
+ return {**{k: d[k] for k in ("page", "url", "title", "history", "page_obj")}, "goal": goal, "gold_op": op, "gold_id": a["id"],
42
+ "gold_node": a.get("node"), "kind": a["kind"], "label": a["label"], "source": "live"}
43
+ with ThreadPoolExecutor(16) as ex:
44
+ cases = [c for c in ex.map(work, jobs) if c]
45
+ with open(out, "w") as f:
46
+ for c in cases: f.write(json.dumps(c, ensure_ascii=False) + "\n")
47
+ print("wrote", len(cases))
48
+
49
+ if __name__ == "__main__":
50
+ main()
code/finetune/make_done_cases.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn click cases into realistic DONE cases by actually executing the click in the browser.
2
+
3
+ python finetune/make_done_cases.py out/pages.jsonl out/cases.jsonl out/done_cases.jsonl [max=250]
4
+
5
+ For a click case (page A, goal, gold element): open A, click the gold element, observe the landing page B.
6
+ If the page changed, emit {goal, gold_op: DONE, page_obj: B, history: [that click]} with the same goal phrasing.
7
+ """
8
+ import json, os, random, sys, time
9
+ sys.path.insert(0, "/home/ckl/projects/S/jev-ultrafast")
10
+ os.environ.setdefault("BU_CDP_URL", "http://127.0.0.1:9222")
11
+ from jev_ultrafast.browser import Browser
12
+
13
+ def main():
14
+ pages_f, cases_f, out = sys.argv[1:4]; mx = int(sys.argv[4]) if len(sys.argv) > 4 else 250
15
+ pages = [json.loads(l) for l in open(pages_f)]
16
+ cases = [c for c in (json.loads(l) for l in open(cases_f)) if c["kind"] == "click"]
17
+ rng = random.Random(3); rng.shuffle(cases)
18
+ n_ok = n_try = 0
19
+ with open(out, "w") as f:
20
+ for c in cases:
21
+ if n_ok >= mx: break
22
+ src = pages[c["page"]]; n_try += 1
23
+ try:
24
+ b = Browser(src["url"])
25
+ try:
26
+ page = b.observe(screenshot=False)
27
+ act = next((a for a in page["actions"] if a["kind"] == "click" and a["label"] == c["label"]), None)
28
+ if act is None:
29
+ continue
30
+ b.act(act, page); time.sleep(0.3)
31
+ dest = b.observe(screenshot=False)
32
+ finally:
33
+ b.close()
34
+ except Exception as e:
35
+ print("fail", type(e).__name__, str(e)[:60], flush=True); continue
36
+ if dest["url"] == page["url"] and dest["title"] == page["title"]:
37
+ continue
38
+ hist = [{"action": act["label"], "kind": "click", "text": None, "page_changed": True}]
39
+ f.write(json.dumps({"page": c["page"], "url": dest["url"], "title": dest["title"], "goal": c["goal"], "gold_op": "DONE",
40
+ "gold_id": "DONE", "kind": "done", "label": act["label"], "history": hist,
41
+ "page_obj": {k: dest[k] for k in ("url", "title", "text", "actions")}}, ensure_ascii=False) + "\n"); f.flush()
42
+ n_ok += 1
43
+ if n_ok % 25 == 0: print(f" {n_ok} done cases from {n_try} tries", flush=True)
44
+ print("wrote", n_ok, "DONE cases")
45
+
46
+ if __name__ == "__main__":
47
+ main()
code/finetune/rollouts.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scripted multi-step trajectories executed in the real browser, for the three skills the model lacks:
2
+ scroll : goal targets an element that is only visible after scrolling -> [SCROLL_DOWN, CLICK, DONE]
3
+ search : goal asks to search for a phrase in a text field -> [TYPE_TEXT, CLICK submit/suggestion, DONE]
4
+ select : goal asks to choose an option of a <select> -> [SELECT, DONE]
5
+
6
+ python finetune/rollouts.py out/pages.jsonl out/rollout_cases.jsonl [max_pages=300]
7
+
8
+ Goals are templated (varied phrasing); no LLM needed. Pages are the crawled ones; the suite's exact URLs are skipped.
9
+ """
10
+ import json, os, random, re, sys, time
11
+ sys.path.insert(0, "/home/ckl/projects/S/jev-ultrafast")
12
+ os.environ.setdefault("BU_CDP_URL", "http://127.0.0.1:9222")
13
+ from jev_ultrafast.browser import Browser, StalePage
14
+
15
+ SUITE_URLS = {"https://en.wikipedia.org/wiki/Main_Page", "https://news.ycombinator.com/", "https://github.com/tile-ai/tilelang", "https://www.python.org/",
16
+ "https://books.toscrape.com/", "https://the-internet.herokuapp.com/dropdown", "https://the-internet.herokuapp.com/checkboxes",
17
+ "https://quotes.toscrape.com/", "https://duckduckgo.com/", "https://arxiv.org/", "https://www.google.com/travel/flights?hl=en"}
18
+ SCROLL_T = ["Open '{x}'.", "I want to see '{x}', take me there.", "Go to {x}.", "Find and click '{x}' on this page.", "Open the '{x}' link further down the page.", "Navigate to '{x}'."]
19
+ SEARCH_T = ["Search for '{q}'.", "Look up '{q}' using the search box and show the results.", "Find results for '{q}'.", "Use the site search to find '{q}'.", "Search this site for {q}."]
20
+ SELECT_T = ["Select '{o}' in the '{f}' dropdown.", "Choose {o} for {f}.", "Set '{f}' to '{o}'.", "Pick the option '{o}'."]
21
+ QUERIES = ["python tutorial", "flash attention", "climate change", "rust async", "linear algebra", "tilelang", "modernbert", "black holes", "sourdough bread", "gpu kernels"]
22
+
23
+ def lab(a): return a["label"].split(" → ")[0].strip()
24
+ def case(page, goal, gold_op, gold_id, kind, label, hist, tag):
25
+ return {"page": -1, "url": page["url"], "title": page["title"], "goal": goal, "gold_op": gold_op, "gold_id": gold_id, "kind": kind, "label": label,
26
+ "history": hist, "source": "rollout", "skill": tag, "page_obj": {k: page[k] for k in ("url", "title", "text", "actions")}}
27
+ def hist_of(a, text=None, changed=True): return {"action": a["label"], "kind": a["kind"], "text": text, "page_changed": changed}
28
+ def find(page, pred):
29
+ return next((a for a in page["actions"] if pred(a)), None)
30
+
31
+ def do_scroll(b, page, rng, out):
32
+ if not page.get("scroll") or page["scroll"]["height"] < 1300: return 0
33
+ before = {a["label"] for a in page["actions"] if a["kind"] == "click"}
34
+ sd = find(page, lambda a: a["id"] == "scroll_down")
35
+ if not sd: return 0
36
+ b.act(sd, page); time.sleep(0.3); p2 = b.observe(screenshot=False)
37
+ new = [a for a in p2["actions"] if a["kind"] == "click" and a["label"] not in before and 3 <= len(lab(a)) <= 60 and a.get("role") in ("link", "button")]
38
+ if not new: return 0
39
+ n = 0
40
+ for tgt in rng.sample(new, min(2, len(new))):
41
+ goal = rng.choice(SCROLL_T).format(x=lab(tgt))
42
+ out.write(json.dumps(case(page, goal, "SCROLL_DOWN", "scroll_down", "scroll", "Scroll down", [], "scroll"), ensure_ascii=False) + "\n")
43
+ out.write(json.dumps(case(p2, goal, "CLICK", tgt["id"], "click", tgt["label"], [hist_of(sd, changed=False)], "scroll"), ensure_ascii=False) + "\n")
44
+ n += 2
45
+ # execute one click for a DONE state
46
+ tgt = new[0]
47
+ try:
48
+ b.act(tgt, p2); time.sleep(0.4); p3 = b.observe(screenshot=False)
49
+ if p3["url"] != p2["url"]:
50
+ goal = rng.choice(SCROLL_T).format(x=lab(tgt))
51
+ out.write(json.dumps(case(p3, goal, "DONE", "DONE", "done", "", [hist_of(sd, changed=False), hist_of(tgt)], "scroll"), ensure_ascii=False) + "\n"); n += 1
52
+ except Exception: pass
53
+ return n
54
+
55
+ def do_search(b, page, rng, out):
56
+ field = find(page, lambda a: a["kind"] == "fill" and re.search(r"search|query|find|keyword", a["label"], re.I))
57
+ if not field: return 0
58
+ q = rng.choice(QUERIES); goal = rng.choice(SEARCH_T).format(q=q)
59
+ out.write(json.dumps(case(page, goal, "TYPE_TEXT", field["id"], "fill", field["label"], [], "search"), ensure_ascii=False) + "\n")
60
+ b.act(field, page, text=q); time.sleep(0.4); p2 = b.observe(screenshot=False)
61
+ # submit: a search/go button, or a suggestion containing the query
62
+ sub = find(p2, lambda a: a["kind"] == "click" and (re.search(r"^(search|go|submit|find)\b", lab(a), re.I) or (a.get("role") in ("option", "listitem", "link") and q.split()[0].lower() in a["label"].lower())))
63
+ if not sub: return 1
64
+ out.write(json.dumps(case(p2, goal, "CLICK", sub["id"], "click", sub["label"], [hist_of(field, q, False)], "search"), ensure_ascii=False) + "\n")
65
+ try:
66
+ b.act(sub, p2); time.sleep(0.6); p3 = b.observe(screenshot=False)
67
+ if p3["url"] != p2["url"] or p3["title"] != p2["title"]:
68
+ out.write(json.dumps(case(p3, goal, "DONE", "DONE", "done", "", [hist_of(field, q, False), hist_of(sub)], "search"), ensure_ascii=False) + "\n"); return 3
69
+ except Exception: pass
70
+ return 2
71
+
72
+ def do_select(b, page, rng, out):
73
+ sels = [a for a in page["actions"] if a["kind"] == "select" and a.get("value") and a["value"] != a.get("current_value")]
74
+ if not sels: return 0
75
+ by_field = {}
76
+ for a in sels: by_field.setdefault(a["node"], []).append(a)
77
+ node, opts = rng.choice(list(by_field.items()))
78
+ opt = rng.choice(opts); f = lab(opt); o = opt["label"].split(" → ")[-1].strip()
79
+ goal = rng.choice(SELECT_T).format(o=o, f=f)
80
+ out.write(json.dumps(case(page, goal, "SELECT", opt["id"], "select", opt["label"], [], "select"), ensure_ascii=False) + "\n")
81
+ try:
82
+ b.act(opt, page); time.sleep(0.3); p2 = b.observe(screenshot=False)
83
+ out.write(json.dumps(case(p2, goal, "DONE", "DONE", "done", "", [hist_of(opt, changed=False)], "select"), ensure_ascii=False) + "\n"); return 2
84
+ except Exception: return 1
85
+
86
+ def main():
87
+ pages = [json.loads(l) for l in open(sys.argv[1])]; out_f = sys.argv[2]; mx = int(sys.argv[3]) if len(sys.argv) > 3 else 300
88
+ rng = random.Random(11); rng.shuffle(pages)
89
+ counts = {"scroll": 0, "search": 0, "select": 0}; done = 0
90
+ with open(out_f, "w") as out:
91
+ for pg in pages:
92
+ if done >= mx: break
93
+ if pg["url"] in SUITE_URLS: continue
94
+ for skill, fn in (("scroll", do_scroll), ("search", do_search), ("select", do_select)):
95
+ try:
96
+ b = Browser(pg["url"])
97
+ try:
98
+ page = b.observe(screenshot=False); n = fn(b, page, rng, out)
99
+ finally:
100
+ b.close()
101
+ counts[skill] += n
102
+ except Exception as e:
103
+ pass
104
+ done += 1
105
+ if done % 25 == 0: print(f" {done} pages {counts}", flush=True)
106
+ print("wrote", counts, "->", out_f)
107
+
108
+ if __name__ == "__main__":
109
+ main()
code/finetune/run_after_v6.sh ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # after v6: (1) real-task suite with v6, (2) training-speed benchmark of the three cheap wins, (3) v7 = v6 data with
3
+ # truncated page text + no checkpointing + torch.compile, 3 epochs, calibrate, eval, suite.
4
+ S=/tmp/claude-1000/-home-ckl-projects-S/8a7ce50a-5640-4606-837a-93df5ff48c83/scratchpad
5
+ cd /home/ckl/projects/S/laya && source env.sh
6
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
7
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
8
+ P=.venv/bin/python
9
+ until grep -qE "V6_DONE|Traceback" finetune/out/v6.log; do sleep 30; done
10
+ echo "== suite v6"
11
+ bash $S/start_infra.sh >/dev/null 2>&1
12
+ bash $S/restart_s1.sh $PWD/finetune/out/laya-browser-v6 999 >/dev/null
13
+ (cd ../jev-ultrafast && SUITE_OUT=$PWD/../laya/finetune/out/suite_v6.json timeout 1500 .venv/bin/python ../laya/apps/browser_suite.py 2>&1 | grep -vE "Warn|TileLang")
14
+ bash $S/stop_all.sh >/dev/null 2>&1; sleep 3
15
+ echo "== speed benchmark (256 items, 1 epoch)"
16
+ echo "-- old items (1016 tok), CKPT=1 COMPILE=0"; CKPT=1 COMPILE=0 LIMIT=256 $P finetune/train.py finetune/out/train_items.pt /tmp/bench_ck 1 2>&1 | grep "=== epoch"
17
+ echo "-- old items, CKPT=0 COMPILE=0"; CKPT=0 COMPILE=0 LIMIT=256 $P finetune/train.py finetune/out/train_items.pt /tmp/bench_ck 1 2>&1 | grep -E "=== epoch|OutOfMemory"
18
+ echo "-- old items, CKPT=0 COMPILE=1"; CKPT=0 COMPILE=1 LIMIT=256 $P finetune/train.py finetune/out/train_items.pt /tmp/bench_ck 1 2>&1 | grep -E "=== epoch|OutOfMemory|Error"
19
+ echo "== rebuild items with 3000-char page text"
20
+ $P finetune/build_items.py finetune/out/pages.jsonl finetune/out/cases.jsonl finetune/out/ finetune/out/done_cases.jsonl finetune/out/step2_cases.jsonl finetune/out/m2w_cases.jsonl
21
+ echo "-- new items, CKPT=0 COMPILE=1"; CKPT=0 COMPILE=1 LIMIT=256 $P finetune/train.py finetune/out/train_items.pt /tmp/bench_ck 1 2>&1 | grep -E "=== epoch|OutOfMemory|Error"
22
+ echo "== train v7 (3 epochs, fast settings)"; CKPT=0 COMPILE=1 $P finetune/train.py finetune/out/train_items.pt finetune/out/laya-browser-v7 3 2>&1 | grep -E "=== epoch|saved|Error|Traceback"
23
+ echo "== calibrate v7"; $P finetune/calibrate.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v7 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
24
+ echo "== eval v7"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v7 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
25
+ echo "== suite v7"
26
+ bash $S/start_infra.sh >/dev/null 2>&1
27
+ bash $S/restart_s1.sh $PWD/finetune/out/laya-browser-v7 999 >/dev/null
28
+ (cd ../jev-ultrafast && SUITE_OUT=$PWD/../laya/finetune/out/suite_v7.json timeout 1500 .venv/bin/python ../laya/apps/browser_suite.py 2>&1 | grep -vE "Warn|TileLang")
29
+ bash $S/stop_all.sh >/dev/null 2>&1
30
+ echo AFTER_V6_DONE
code/finetune/run_all.sh ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # build items -> zero-shot baselines -> fine-tune -> eval. Logs to finetune/out/run.log
3
+ cd /home/ckl/projects/S/laya && source env.sh
4
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
5
+ P=.venv/bin/python
6
+ for p in $(pgrep -f "sglang.launch_server"); do kill $p; done; sleep 3
7
+ uv pip install --python $P "httpx[http2]" 2>&1 | tail -1
8
+ set -e
9
+ echo "== build items"; $P finetune/build_items.py finetune/out/pages.jsonl finetune/out/cases.jsonl finetune/out/
10
+ echo "== zero-shot baselines"
11
+ $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl "$LAYA_BASE" 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
12
+ $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl convaiinnovations/laya multilingual 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
13
+ echo "== train"; $P finetune/train.py finetune/out/train_items.pt finetune/out/laya-browser 4 2>&1 | grep -vE "Warn|warn"
14
+ echo "== eval fine-tuned"
15
+ $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl finetune/out/laya-browser 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
16
+ echo RUN_ALL_DONE
code/finetune/run_final.sh ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Final comparison after v10s: v10 vs v10s, gating off vs tau=0.7, 3 runs per task; plus per-step latency profiles.
3
+ S=/tmp/claude-1000/-home-ckl-projects-S/8a7ce50a-5640-4606-837a-93df5ff48c83/scratchpad
4
+ cd /home/ckl/projects/S/laya && source env.sh
5
+ O=finetune/out; P=.venv/bin/python
6
+ until grep -q "V10S_DONE" $O/v10s.log; do sleep 60; done
7
+ echo "== per-step latency"
8
+ for ck in laya-browser-v10 laya-browser-v10s; do echo "-- $ck"; $P apps/profile_step.py $PWD/$O/$ck 2>&1 | grep -vE "TileLang|Warn|warn|Fetch|loaded"; done
9
+ bash $S/start_infra.sh >/dev/null 2>&1
10
+ for ck in laya-browser-v10 laya-browser-v10s; do
11
+ for tau in 0 0.7; do
12
+ echo "== suite $ck tau=$tau x3"
13
+ export ESCALATE_TAU=$tau ESCALATE_LOG=$PWD/$O/escalations_final_${ck}_$tau.jsonl; rm -f $ESCALATE_LOG
14
+ bash $S/restart_s1.sh $PWD/$O/$ck 999 >/dev/null
15
+ (cd ../jev-ultrafast && REPEATS=3 SUITE_OUT=$PWD/../laya/$O/suite_final_${ck}_$tau.json timeout 3000 .venv/bin/python ../laya/apps/browser_suite.py 2>&1 | grep -E "^==|per task")
16
+ curl -s http://127.0.0.1:8791/ | python3 -c "import json,sys; d=json.load(sys.stdin); print(f\" server calls={d['calls']} escalated={d['escalated']} ({100*d['escalated']/max(1,d['calls']):.0f}%)\")"
17
+ done
18
+ done
19
+ bash $S/stop_all.sh >/dev/null 2>&1
20
+ echo FINAL_DONE
code/finetune/run_gated.sh ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # after v10: real-task suite with System-1/System-2 gating at several confidence thresholds; escalations logged as DAgger cases
3
+ S=/tmp/claude-1000/-home-ckl-projects-S/8a7ce50a-5640-4606-837a-93df5ff48c83/scratchpad
4
+ cd /home/ckl/projects/S/laya && source env.sh
5
+ O=finetune/out
6
+ until grep -q "V10_DONE" $O/v10.log; do sleep 60; done
7
+ bash $S/start_infra.sh >/dev/null 2>&1
8
+ for tau in 0.5 0.7 0.9; do
9
+ echo "== suite v10 + gating tau=$tau"
10
+ export ESCALATE_TAU=$tau ESCALATE_LOG=$PWD/$O/escalations_tau$tau.jsonl
11
+ rm -f $ESCALATE_LOG
12
+ bash $S/restart_s1.sh $PWD/$O/laya-browser-v10 999 >/dev/null
13
+ (cd ../jev-ultrafast && SUITE_OUT=$PWD/../laya/$O/suite_v10_tau$tau.json timeout 1500 .venv/bin/python ../laya/apps/browser_suite.py 2>&1 | grep -vE "Warn|TileLang")
14
+ curl -s http://127.0.0.1:8791/ | python3 -c "import json,sys; d=json.load(sys.stdin); print(f\" server calls={d['calls']} escalated={d['escalated']} ({100*d['escalated']/max(1,d['calls']):.0f}%)\")"
15
+ done
16
+ bash $S/stop_all.sh >/dev/null 2>&1
17
+ echo GATED_DONE
code/finetune/run_train.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ cd /home/ckl/projects/S/laya && source env.sh
3
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
4
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
5
+ P=.venv/bin/python
6
+ echo "== train"; $P finetune/train.py finetune/out/train_items.pt finetune/out/laya-browser 4 2>&1 | grep -vE "Warn|warn"
7
+ echo "== eval fine-tuned"
8
+ $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
9
+ echo TRAIN_DONE
code/finetune/run_v10.sh ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # v10 = v9 recipe (format v2, head 768, 4 epochs) + the live data that failed silently in v9: goals on 421 pages,
3
+ # real DONE, step-2, DAgger with policy v9. Full logs kept per step under finetune/out/v10_*.log
4
+ S=/tmp/claude-1000/-home-ckl-projects-S/8a7ce50a-5640-4606-837a-93df5ff48c83/scratchpad
5
+ cd /home/ckl/projects/S/laya && source env.sh
6
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
7
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True CKPT=0 COMPILE=0
8
+ P=.venv/bin/python; O=finetune/out
9
+ bash $S/start_infra.sh
10
+ for i in 1 2 3 4 5 6; do
11
+ r=$(curl -s -m 60 http://127.0.0.1:30000/v1/chat/completions -H 'Content-Type: application/json' -d '{"model":"Qwen/Qwen3-8B-AWQ","max_tokens":20,"chat_template_kwargs":{"enable_thinking":false},"messages":[{"role":"user","content":"say ok"}]}' | head -c 300)
12
+ echo "sglang probe: $r" | cut -c1-120; echo "$r" | grep -q '"content"' && break; sleep 20
13
+ done
14
+ echo "== goals (421 pages)"; (cd ../jev-ultrafast && timeout 5400 .venv/bin/python ../laya/finetune/gen_goals.py ../laya/finetune/out/pages.jsonl ../laya/finetune/out/cases.jsonl 12 > ../laya/$O/v10_goals.log 2>&1); tail -3 $O/v10_goals.log; echo "cases: $(wc -l < $O/cases.jsonl)"
15
+ echo "== real DONE"; (cd ../jev-ultrafast && timeout 5400 .venv/bin/python ../laya/finetune/make_done_cases.py ../laya/finetune/out/pages.jsonl ../laya/finetune/out/cases.jsonl ../laya/finetune/out/done_cases.jsonl 700 > ../laya/$O/v10_done.log 2>&1); tail -1 $O/v10_done.log
16
+ echo "== step-2"; (cd ../jev-ultrafast && timeout 3600 .venv/bin/python ../laya/finetune/gen_step2.py ../laya/finetune/out/done_cases.jsonl ../laya/finetune/out/step2_cases.jsonl 2 > ../laya/$O/v10_step2.log 2>&1); tail -1 $O/v10_step2.log
17
+ echo "== dagger (policy v9)"
18
+ bash $S/restart_s1.sh $PWD/$O/laya-browser-v9 999 >/dev/null
19
+ (cd ../jev-ultrafast && timeout 3000 .venv/bin/python ../laya/finetune/dagger.py ../laya/finetune/out/dagger_cases.jsonl > ../laya/$O/v10_dagger.log 2>&1); tail -1 $O/v10_dagger.log
20
+ bash $S/stop_all.sh >/dev/null 2>&1; sleep 3
21
+ export LAYA_FMT=v2 LAYA_HEAD=768
22
+ echo "== build v10"; $P finetune/build_items.py $O/pages.jsonl $O/cases.jsonl $O/ $O/done_cases.jsonl $O/step2_cases.jsonl $O/m2w_cases.jsonl $O/dagger_cases.jsonl
23
+ echo "== train v10 (4 epochs)"; $P finetune/train.py $O/train_items.pt $O/laya-browser-v10 4 2>&1 | grep --line-buffered -E "=== epoch|saved|Error|Traceback"
24
+ echo "== calibrate"; $P finetune/calibrate.py $O/pages.jsonl $O/eval_cases.jsonl $PWD/$O/laya-browser-v10 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
25
+ echo "== eval v10"; $P finetune/eval.py $O/pages.jsonl $O/eval_cases.jsonl $PWD/$O/laya-browser-v10 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
26
+ echo "== eval v9 (same eval)"; $P finetune/eval.py $O/pages.jsonl $O/eval_cases.jsonl $PWD/$O/laya-browser-v9 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
27
+ echo "== suite v10"
28
+ bash $S/start_infra.sh >/dev/null 2>&1
29
+ bash $S/restart_s1.sh $PWD/$O/laya-browser-v10 999 >/dev/null
30
+ (cd ../jev-ultrafast && SUITE_OUT=$PWD/../laya/$O/suite_v10.json timeout 1500 .venv/bin/python ../laya/apps/browser_suite.py 2>&1 | grep -vE "Warn|TileLang")
31
+ bash $S/stop_all.sh >/dev/null 2>&1
32
+ echo V10_DONE
code/finetune/run_v10s.sh ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # v10s: same data as v10, 322M mmBERT-base (multilingual) backbone, format v3 -> target ~20 ms per browser step.
3
+ S=/tmp/claude-1000/-home-ckl-projects-S/8a7ce50a-5640-4606-837a-93df5ff48c83/scratchpad
4
+ cd /home/ckl/projects/S/laya && source env.sh
5
+ until grep -q "GATED_DONE" finetune/out/gated.log; do sleep 60; done
6
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/multilingual | head -1)
7
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True CKPT=0 COMPILE=0 LAYA_FMT=v3 LAYA_HEAD=768
8
+ P=.venv/bin/python; O=finetune/out
9
+ echo "== build v10s (format v3, mmBERT-base tokenizer)"
10
+ $P finetune/build_items.py $O/pages.jsonl $O/cases.jsonl $O/ $O/done_cases.jsonl $O/step2_cases.jsonl $O/m2w_cases.jsonl $O/dagger_cases.jsonl
11
+ echo "== train v10s (4 epochs)"; $P finetune/train.py $O/train_items.pt $O/laya-browser-v10s 4 2>&1 | grep --line-buffered -E "=== epoch|saved|Error|Traceback"
12
+ echo "== calibrate"; $P finetune/calibrate.py $O/pages.jsonl $O/eval_cases.jsonl $PWD/$O/laya-browser-v10s 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
13
+ echo "== eval v10s"; $P finetune/eval.py $O/pages.jsonl $O/eval_cases.jsonl $PWD/$O/laya-browser-v10s 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
14
+ echo "== profile step"; $P apps/profile_step.py $PWD/$O/laya-browser-v10s 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
15
+ bash $S/start_infra.sh >/dev/null 2>&1
16
+ for tau in 0 0.7; do
17
+ echo "== suite v10s tau=$tau"
18
+ export ESCALATE_TAU=$tau ESCALATE_LOG=$PWD/$O/escalations_v10s_tau$tau.jsonl; rm -f $ESCALATE_LOG
19
+ bash $S/restart_s1.sh $PWD/$O/laya-browser-v10s 999 >/dev/null
20
+ (cd ../jev-ultrafast && SUITE_OUT=$PWD/../laya/$O/suite_v10s_tau$tau.json timeout 1500 .venv/bin/python ../laya/apps/browser_suite.py 2>&1 | grep -vE "Warn|TileLang")
21
+ curl -s http://127.0.0.1:8791/ | python3 -c "import json,sys; d=json.load(sys.stdin); print(f\" server calls={d['calls']} escalated={d['escalated']} ({100*d['escalated']/max(1,d['calls']):.0f}%)\")"
22
+ done
23
+ bash $S/stop_all.sh >/dev/null 2>&1
24
+ echo V10S_DONE
code/finetune/run_v2.sh ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ cd /home/ckl/projects/S/laya && source env.sh
3
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
4
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
5
+ P=.venv/bin/python
6
+ for p in $(pgrep -f "sglang.launch_server|apps/systemone_server"); do kill $p; done; sleep 3
7
+ echo "== build"; $P finetune/build_items.py finetune/out/pages.jsonl finetune/out/cases.jsonl finetune/out/ finetune/out/done_cases.jsonl
8
+ echo "== zero-shot baseline (typed) on v2 eval"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl "$LAYA_BASE" 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
9
+ echo "== train v2"; $P finetune/train.py finetune/out/train_items.pt finetune/out/laya-browser-v2 4 2>&1 | grep -E "=== epoch|saved|Error|Traceback"
10
+ echo "== eval v2"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v2 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
11
+ echo V2_DONE
code/finetune/run_v3.sh ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ cd /home/ckl/projects/S/laya && source env.sh
3
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
4
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
5
+ P=.venv/bin/python
6
+ for p in $(pgrep -f "sglang.launch_server|apps/systemone_server"); do kill $p; done; sleep 3
7
+ echo "== build"; $P finetune/build_items.py finetune/out/pages.jsonl finetune/out/cases.jsonl finetune/out/ finetune/out/done_cases.jsonl finetune/out/m2w_cases.jsonl
8
+ echo "== zero-shot baseline (typed) on v2 eval"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl "$LAYA_BASE" 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
9
+ echo "== train v3"; $P finetune/train.py finetune/out/train_items.pt finetune/out/laya-browser-v3 4 2>&1 | grep -E "=== epoch|saved|Error|Traceback"
10
+ echo "== eval v3"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v3 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
11
+ echo V3_DONE
code/finetune/run_v4.sh ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ cd /home/ckl/projects/S/laya && source env.sh
3
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
4
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
5
+ P=.venv/bin/python
6
+ for p in $(pgrep -f "sglang.launch_server|apps/systemone_server"); do kill $p; done; sleep 3
7
+ echo "== wait for shards"; until [ "$(ls finetune/data/mind2web/data/train/train_*.json 2>/dev/null | wc -l)" -ge 11 ] && ! ls finetune/data/mind2web/data/train/*.incomplete >/dev/null 2>&1; do sleep 10; done
8
+ echo "== convert all shards"; $P finetune/convert_mind2web.py finetune/data/mind2web/data/train/train_*.json finetune/out/m2w_cases.jsonl 2>&1 | tail -1
9
+ echo "== build"; $P finetune/build_items.py finetune/out/pages.jsonl finetune/out/cases.jsonl finetune/out/ finetune/out/done_cases.jsonl finetune/out/m2w_cases.jsonl
10
+ echo "== train v4 (3 epochs)"; $P finetune/train.py finetune/out/train_items.pt finetune/out/laya-browser-v4 3 2>&1 | grep -E "=== epoch|saved|Error|Traceback"
11
+ echo "== eval v4"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v4 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
12
+ echo "== eval v3 on same eval set"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v3 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
13
+ echo V4_DONE
code/finetune/run_v5.sh ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ cd /home/ckl/projects/S/laya && source env.sh
3
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
4
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
5
+ P=.venv/bin/python
6
+ for p in $(pgrep -f "sglang.launch_server|apps/systemone_server"); do kill $p; done; sleep 3
7
+
8
+ echo "== convert all shards"; $P finetune/convert_mind2web.py finetune/data/mind2web/data/train/train_*.json finetune/out/m2w_cases.jsonl 2>&1 | tail -1
9
+ echo "== build"; $P finetune/build_items.py finetune/out/pages.jsonl finetune/out/cases.jsonl finetune/out/ finetune/out/done_cases.jsonl finetune/out/m2w_cases.jsonl
10
+ echo "== train v5 (3 epochs)"; $P finetune/train.py finetune/out/train_items.pt finetune/out/laya-browser-v5 3 2>&1 | grep -E "=== epoch|saved|Error|Traceback"
11
+ echo "== calibrate"; $P finetune/calibrate.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v5 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
12
+ echo "== eval v5"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v5 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
13
+
14
+ echo V5_DONE
code/finetune/run_v6.sh ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ cd /home/ckl/projects/S/laya && source env.sh
3
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
4
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
5
+ P=.venv/bin/python
6
+ for p in $(pgrep -f "sglang.launch_server|apps/systemone_server"); do kill $p; done; sleep 3
7
+
8
+
9
+ echo "== build"; $P finetune/build_items.py finetune/out/pages.jsonl finetune/out/cases.jsonl finetune/out/ finetune/out/done_cases.jsonl finetune/out/step2_cases.jsonl finetune/out/m2w_cases.jsonl
10
+ echo "== train v6 (3 epochs)"; $P finetune/train.py finetune/out/train_items.pt finetune/out/laya-browser-v6 3 2>&1 | grep -E "=== epoch|saved|Error|Traceback"
11
+ echo "== calibrate"; $P finetune/calibrate.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v6 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
12
+ echo "== eval v6"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v6 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
13
+
14
+ echo V6_DONE
code/finetune/run_v7.sh ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # v7: best training config (no checkpointing, no compile, 3000-char page text) + DONE oversampling x4.
3
+ S=/tmp/claude-1000/-home-ckl-projects-S/8a7ce50a-5640-4606-837a-93df5ff48c83/scratchpad
4
+ cd /home/ckl/projects/S/laya && source env.sh
5
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
6
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True CKPT=0 COMPILE=0
7
+ P=.venv/bin/python
8
+ echo "== build (3000-char text, DONE x4)"
9
+ $P finetune/build_items.py finetune/out/pages.jsonl finetune/out/cases.jsonl finetune/out/ finetune/out/done_cases.jsonl finetune/out/step2_cases.jsonl finetune/out/m2w_cases.jsonl
10
+ echo "== speed: new items, CKPT=0 COMPILE=0 (256 items)"; LIMIT=256 $P finetune/train.py finetune/out/train_items.pt /tmp/bench_ck 1 2>&1 | grep -E "=== epoch|Error"
11
+ echo "== train v7 (3 epochs)"; $P finetune/train.py finetune/out/train_items.pt finetune/out/laya-browser-v7 3 2>&1 | grep --line-buffered -E "=== epoch|saved|Error|Traceback"
12
+ echo "== calibrate v7"; $P finetune/calibrate.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v7 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
13
+ echo "== eval v7"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v7 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
14
+ echo "== eval v6 on same (truncated) eval"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v6 2>&1 | grep -vE "TileLang|Warn|warn|Fetch" | head -1
15
+ echo "== suite v7"
16
+ bash $S/start_infra.sh >/dev/null 2>&1
17
+ bash $S/restart_s1.sh $PWD/finetune/out/laya-browser-v7 999 >/dev/null
18
+ (cd ../jev-ultrafast && SUITE_OUT=$PWD/../laya/finetune/out/suite_v7.json timeout 1500 .venv/bin/python ../laya/apps/browser_suite.py 2>&1 | grep -vE "Warn|TileLang")
19
+ bash $S/stop_all.sh >/dev/null 2>&1
20
+ echo V7_DONE
code/finetune/run_v8.sh ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # v8: after v7 -> DAgger harvest on real tasks with v7 as the policy and Qwen as teacher -> retrain -> eval -> suite.
3
+ S=/tmp/claude-1000/-home-ckl-projects-S/8a7ce50a-5640-4606-837a-93df5ff48c83/scratchpad
4
+ cd /home/ckl/projects/S/laya && source env.sh
5
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
6
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True CKPT=0 COMPILE=0
7
+ P=.venv/bin/python
8
+ echo "== build v8"
9
+ $P finetune/build_items.py finetune/out/pages.jsonl finetune/out/cases.jsonl finetune/out/ finetune/out/done_cases.jsonl finetune/out/step2_cases.jsonl finetune/out/m2w_cases.jsonl finetune/out/dagger_cases.jsonl
10
+ echo "== train v8 (3 epochs)"; $P finetune/train.py finetune/out/train_items.pt finetune/out/laya-browser-v8 3 2>&1 | grep --line-buffered -E "=== epoch|saved|Error|Traceback"
11
+ echo "== calibrate v8"; $P finetune/calibrate.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v8 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
12
+ echo "== eval v8"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v8 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
13
+ echo "== suite v8"
14
+ bash $S/start_infra.sh >/dev/null 2>&1
15
+ bash $S/restart_s1.sh $PWD/finetune/out/laya-browser-v8 999 >/dev/null
16
+ (cd ../jev-ultrafast && SUITE_OUT=$PWD/../laya/finetune/out/suite_v8.json timeout 1500 .venv/bin/python ../laya/apps/browser_suite.py 2>&1 | grep -vE "Warn|TileLang")
17
+ bash $S/stop_all.sh >/dev/null 2>&1
18
+ echo V8_DONE
code/finetune/run_v9.sh ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # v9 "one shot": 420 pages -> goals -> real DONE -> step-2 -> DAgger (policy v8) -> format v2 (head 768) -> 4 epochs -> eval -> suite
3
+ S=/tmp/claude-1000/-home-ckl-projects-S/8a7ce50a-5640-4606-837a-93df5ff48c83/scratchpad
4
+ cd /home/ckl/projects/S/laya && source env.sh
5
+ export LAYA_BASE=$(ls -d ~/.cache/huggingface/hub/models--convaiinnovations--laya/snapshots/*/typed-decisions | head -1)
6
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True CKPT=0 COMPILE=0
7
+ P=.venv/bin/python; J=../jev-ultrafast/.venv/bin/python
8
+ until grep -q "V8_DONE" finetune/out/v8.log && grep -q "^done" finetune/out/collect2.log; do sleep 30; done
9
+ echo "== pages: $(wc -l < finetune/out/pages.jsonl)"
10
+ bash $S/start_infra.sh >/dev/null 2>&1
11
+ echo "== goals for all pages"; (cd ../jev-ultrafast && timeout 3600 .venv/bin/python ../laya/finetune/gen_goals.py ../laya/finetune/out/pages.jsonl ../laya/finetune/out/cases.jsonl 12 2>&1 | tail -2)
12
+ echo "== real DONE cases"; (cd ../jev-ultrafast && timeout 3600 .venv/bin/python ../laya/finetune/make_done_cases.py ../laya/finetune/out/pages.jsonl ../laya/finetune/out/cases.jsonl ../laya/finetune/out/done_cases.jsonl 700 2>&1 | tail -1)
13
+ echo "== step-2 negatives"; (cd ../jev-ultrafast && timeout 1800 .venv/bin/python ../laya/finetune/gen_step2.py ../laya/finetune/out/done_cases.jsonl ../laya/finetune/out/step2_cases.jsonl 2 2>&1 | tail -1)
14
+ echo "== dagger round 2 (policy v8)"
15
+ bash $S/restart_s1.sh $PWD/finetune/out/laya-browser-v8 999 >/dev/null
16
+ (cd ../jev-ultrafast && timeout 3000 .venv/bin/python ../laya/finetune/dagger.py ../laya/finetune/out/dagger_cases.jsonl 2>&1 | tail -1)
17
+ bash $S/stop_all.sh >/dev/null 2>&1; sleep 3
18
+ export LAYA_FMT=v2 LAYA_HEAD=768
19
+ echo "== build v9 (format v2, head 768)"
20
+ $P finetune/build_items.py finetune/out/pages.jsonl finetune/out/cases.jsonl finetune/out/ finetune/out/done_cases.jsonl finetune/out/step2_cases.jsonl finetune/out/m2w_cases.jsonl finetune/out/dagger_cases.jsonl
21
+ echo "== train v9 (4 epochs)"; $P finetune/train.py finetune/out/train_items.pt finetune/out/laya-browser-v9 4 2>&1 | grep --line-buffered -E "=== epoch|saved|Error|Traceback"
22
+ echo "== calibrate v9"; $P finetune/calibrate.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v9 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
23
+ echo "== eval v9"; $P finetune/eval.py finetune/out/pages.jsonl finetune/out/eval_cases.jsonl $PWD/finetune/out/laya-browser-v9 2>&1 | grep -vE "TileLang|Warn|warn|Fetch"
24
+ echo "== suite v9"
25
+ bash $S/start_infra.sh >/dev/null 2>&1
26
+ bash $S/restart_s1.sh $PWD/finetune/out/laya-browser-v9 999 >/dev/null
27
+ (cd ../jev-ultrafast && SUITE_OUT=$PWD/../laya/finetune/out/suite_v9.json timeout 1500 .venv/bin/python ../laya/apps/browser_suite.py 2>&1 | grep -vE "Warn|TileLang")
28
+ bash $S/stop_all.sh >/dev/null 2>&1
29
+ echo V9_DONE
code/finetune/train.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-GPU RLCD fine-tune (laya's recipe: GRPO-style noisy-logit policy gradient + soft CE), from a laya checkpoint.
2
+
3
+ LAYA_BASE=<checkpoint dir> python finetune/train.py out/train_items.pt out/laya-browser [epochs=4]
4
+ """
5
+ import json, os, random, sys, time
6
+ import torch
7
+ sys.path.insert(0, "/home/ckl/projects/S/laya-upstream")
8
+ from safetensors.torch import load_file, save_file
9
+ from transformers import AutoTokenizer
10
+ from laya.common import build_model, proper_reward
11
+
12
+ def collate(items, pad_id):
13
+ n, L = len(items), max(len(it["ids"]) for it in items); kmax = max(len(it["markers"]) for it in items)
14
+ ids = torch.full((n, L), pad_id, dtype=torch.long); att = torch.zeros((n, L), dtype=torch.long)
15
+ mpos = torch.zeros((n, kmax), dtype=torch.long); mmask = torch.zeros((n, kmax), dtype=torch.bool); target = torch.zeros((n, kmax))
16
+ for i, it in enumerate(items):
17
+ ids[i, :len(it["ids"])] = torch.tensor(it["ids"]); att[i, :len(it["ids"])] = 1; k = len(it["markers"])
18
+ mpos[i, :k] = torch.tensor(it["markers"]); mmask[i, :k] = True; target[i, :len(it["target"])] = torch.tensor(it["target"])
19
+ return dict(input_ids=ids, attention_mask=att, marker_pos=mpos, marker_mask=mmask, target=target, qtype=torch.tensor([it["qtype"] for it in items]))
20
+
21
+ def main():
22
+ items_f, out, epochs = sys.argv[1], sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 else 4
23
+ limit = int(os.environ.get("LIMIT", "0")) # LIMIT=N: time N items only (benchmarking)
24
+ base = os.environ["LAYA_BASE"]; dev = torch.device("cuda")
25
+ cfg = json.load(open(os.path.join(base, "rl_agent_config.json")))
26
+ cfg.update(max_len=1024, head_max_len=int(os.environ.get('LAYA_HEAD', '512')))
27
+ tok = AutoTokenizer.from_pretrained(os.path.join(base, "tokenizer"))
28
+ model = build_model(cfg, encoder_dir=os.path.join(base, "encoder"))
29
+ model.load_state_dict(load_file(os.path.join(base, "model.safetensors")), strict=True)
30
+ model.encoder.config.reference_compile = False
31
+ if os.environ.get("CKPT", "0") == "1": # gradient checkpointing costs ~30%; 16 GB fits micro=4 x 1024 without it
32
+ model.encoder.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
33
+ model.to(dev).train()
34
+ if os.environ.get("COMPILE", "1") == "1":
35
+ model.encoder = torch.compile(model.encoder, dynamic=True)
36
+ items = torch.load(items_f, weights_only=False)
37
+ if limit: items = items[:limit]
38
+ MICRO, ACCUM, G, LR_ENC, LR_HEAD, S0, S1 = 4, 8, 4, 2.5e-5, 1e-4, 0.4, 0.1
39
+ enc = [p for n, p in model.named_parameters() if "encoder." in n]; head = [p for n, p in model.named_parameters() if "encoder." not in n]
40
+ opt = torch.optim.AdamW([{"params": enc, "lr": LR_ENC}, {"params": head, "lr": LR_HEAD}], weight_decay=0.01)
41
+ total = max(1, (len(items) // (MICRO * ACCUM)) * epochs)
42
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=total, eta_min=1e-6)
43
+ t0 = time.time(); step = 0
44
+ for ep in range(epochs):
45
+ random.seed(42 + ep); random.shuffle(items)
46
+ sigma = S0 + (S1 - S0) * ep / max(1, epochs - 1); tot_loss, nb = 0.0, 0; opt.zero_grad(set_to_none=True)
47
+ for b in range(0, len(items), MICRO):
48
+ batch = collate(items[b:b + MICRO], tok.pad_token_id); batch = {k: v.to(dev) for k, v in batch.items()}
49
+ with torch.autocast("cuda", dtype=torch.bfloat16):
50
+ logits, act = model(batch["input_ids"], batch["attention_mask"], batch["marker_pos"], batch["marker_mask"], batch["qtype"])
51
+ logits = logits.float(); mask = batch["marker_mask"]; k = mask.sum(-1, keepdim=True).float(); target = batch["target"]
52
+ eps = torch.randn((G,) + logits.shape, device=dev) * sigma * mask; eps = (eps - eps.sum(-1, keepdim=True) / k) * mask
53
+ z = logits.detach().unsqueeze(0) + eps; q = torch.softmax(z.masked_fill(~mask, -1e4), -1)
54
+ with torch.no_grad():
55
+ r = proper_reward(q, target.unsqueeze(0), batch["qtype"], mask, w_sph=0.75, w_rps=1.0); adv = r - r.mean(0, keepdim=True); adv = adv / (adv.std() + 1e-6)
56
+ logp = -(((z - logits.unsqueeze(0)) ** 2) * mask).sum(-1) / (2 * sigma ** 2)
57
+ loss_rl = -(adv * logp).mean(); loss_ce = -(target * torch.log_softmax(logits.masked_fill(~mask, -1e4), -1)).sum(-1).mean()
58
+ loss = (loss_rl + loss_ce) / ACCUM + 0.0 * act.sum(); loss.backward(); nb += 1; tot_loss += loss.item() * ACCUM
59
+ if nb % ACCUM == 0 or b + MICRO >= len(items):
60
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0); opt.step(); sched.step(); opt.zero_grad(set_to_none=True); step += 1
61
+ if step % 20 == 0: print(f" ep {ep+1} step {step}/{total} loss {loss.item()*ACCUM:.3f} ce {loss_ce.item():.3f} reward {r.mean().item():.3f} {time.time()-t0:.0f}s", flush=True)
62
+ print(f"=== epoch {ep+1}/{epochs} avg loss {tot_loss/max(1,nb):.4f} {time.time()-t0:.0f}s", flush=True)
63
+ os.makedirs(out, exist_ok=True); model.eval()
64
+ if hasattr(model.encoder, "_orig_mod"):
65
+ model.encoder = model.encoder._orig_mod
66
+ save_file({k: v.half().contiguous().cpu() for k, v in model.state_dict().items()}, os.path.join(out, "model.safetensors"))
67
+ model.encoder.config.save_pretrained(os.path.join(out, "encoder")); tok.save_pretrained(os.path.join(out, "tokenizer"))
68
+ cfg.update(fine_tuned=True, model_name="laya-browser", temperature=[1.0, 1.0, 1.0], temperature_by_options={},
69
+ laya_fmt=os.environ.get("LAYA_FMT", "v1"), head_max_len_train=cfg["head_max_len"])
70
+ json.dump(cfg, open(os.path.join(out, "rl_agent_config.json"), "w"), indent=2); print("saved", out)
71
+
72
+ if __name__ == "__main__":
73
+ main()
code/jev-ultrafast.patch ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diff --git i/jev_ultrafast/model.py w/jev_ultrafast/model.py
2
+ index d311747..9e4a7cd 100644
3
+ --- i/jev_ultrafast/model.py
4
+ +++ w/jev_ultrafast/model.py
5
+ @@ -116,7 +116,8 @@ def choose(state, goal, history):
6
+ "questions": questions,
7
+ }
8
+ started = time.perf_counter()
9
+ - result = post_json("https://api.typesafe.ai/v1/systemone", os.environ["TYPESAFE_API_KEY"], body)
10
+ + result = post_json(os.environ.get("TYPESAFE_BASE_URL", "https://api.typesafe.ai").rstrip("/") + "/v1/systemone",
11
+ + os.environ.get("TYPESAFE_API_KEY", ""), body)
12
+ operation_answer = validate_choice(result["answers"].get("operation", {}), operations)
13
+ operation = operation_answer["choice"]
14
+ target = None
15
+ @@ -166,6 +167,8 @@ def field_text(context):
16
+ reasoning = {"thinking": {"type": "disabled"}} if "api.deepseek.com/" in base else {"reasoning": {"effort": "low"}}
17
+ if os.environ.get("TEXT_MODEL_REASONING") == "none":
18
+ reasoning = {"reasoning": {"enabled": False}}
19
+ + if os.environ.get("TEXT_MODEL_EXTRA_JSON"): # e.g. {"chat_template_kwargs": {"enable_thinking": false}} for local Qwen
20
+ + reasoning = json.loads(os.environ["TEXT_MODEL_EXTRA_JSON"])
21
+ started = time.perf_counter()
22
+ result = post_json(
23
+ base + "/chat/completions",
24
+ diff --git i/uv.lock w/uv.lock
25
+ index 3b2f12b..d6e65fc 100644
26
+ --- i/uv.lock
27
+ +++ w/uv.lock
28
+ @@ -1,133 +1,133 @@
29
+ version = 1
30
+ -revision = 2
31
+ +revision = 3
32
+ requires-python = ">=3.12"
33
+
34
+ [[package]]
35
+ name = "anyio"
36
+ version = "4.15.1"
37
+ -source = { registry = "https://pypi.org/simple" }
38
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
39
+ dependencies = [
40
+ { name = "idna" },
41
+ { name = "typing-extensions", marker = "python_full_version < '3.15'" },
42
+ ]
43
+ -sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
44
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
45
+ wheels = [
46
+ - { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
47
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
48
+ ]
49
+
50
+ [[package]]
51
+ name = "browser-harness"
52
+ version = "0.1.13"
53
+ -source = { registry = "https://pypi.org/simple" }
54
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
55
+ dependencies = [
56
+ { name = "cdp-use" },
57
+ { name = "fetch-use" },
58
+ { name = "pillow" },
59
+ { name = "websockets" },
60
+ ]
61
+ -sdist = { url = "https://files.pythonhosted.org/packages/7f/fe/59ab493e7cf76731a7f5aabae3821eea6ea82a628c14c30e7aed48adc362/browser_harness-0.1.13.tar.gz", hash = "sha256:284dc547a042c309feafd9a9f4a74b2a8651b7963ea3ac6cb2f2d64889f6a8f3", size = 114905, upload-time = "2026-09-04T02:53:23.133Z" }
62
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/fe/59ab493e7cf76731a7f5aabae3821eea6ea82a628c14c30e7aed48adc362/browser_harness-0.1.13.tar.gz", hash = "sha256:284dc547a042c309feafd9a9f4a74b2a8651b7963ea3ac6cb2f2d64889f6a8f3", size = 114905, upload-time = "2026-09-04T02:53:23.133Z" }
63
+ wheels = [
64
+ - { url = "https://files.pythonhosted.org/packages/4a/bd/f166cf9a465eff436048d25851ab28b0d798af02b56998127a4165227fc4/browser_harness-0.1.13-py3-none-any.whl", hash = "sha256:2491459e4bfc0ee8aea22dc6c4680fc0f791b7ba553446323c50d2883449d769", size = 121123, upload-time = "2026-09-04T02:53:21.841Z" },
65
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/bd/f166cf9a465eff436048d25851ab28b0d798af02b56998127a4165227fc4/browser_harness-0.1.13-py3-none-any.whl", hash = "sha256:2491459e4bfc0ee8aea22dc6c4680fc0f791b7ba553446323c50d2883449d769", size = 121123, upload-time = "2026-09-04T02:53:21.841Z" },
66
+ ]
67
+
68
+ [[package]]
69
+ name = "cdp-use"
70
+ version = "1.4.5"
71
+ -source = { registry = "https://pypi.org/simple" }
72
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
73
+ dependencies = [
74
+ { name = "httpx" },
75
+ { name = "typing-extensions" },
76
+ { name = "websockets" },
77
+ ]
78
+ -sdist = { url = "https://files.pythonhosted.org/packages/f7/7a/c549417e8c5e4dface6d5d828cd7dc72502dcea33a99f5324abf5a853ce9/cdp_use-1.4.5.tar.gz", hash = "sha256:0da3a32df46336a03ff5a22bc6bc442cd7d2f2d50a118fd4856f29d37f6d26a0", size = 193961, upload-time = "2026-02-22T04:32:50.574Z" }
79
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/7a/c549417e8c5e4dface6d5d828cd7dc72502dcea33a99f5324abf5a853ce9/cdp_use-1.4.5.tar.gz", hash = "sha256:0da3a32df46336a03ff5a22bc6bc442cd7d2f2d50a118fd4856f29d37f6d26a0", size = 193961, upload-time = "2026-02-22T04:32:50.574Z" }
80
+ wheels = [
81
+ - { url = "https://files.pythonhosted.org/packages/56/12/386d8c6bf0448c43674e24d6194c3b57d62e5361e90bca3d58108819ad32/cdp_use-1.4.5-py3-none-any.whl", hash = "sha256:8f8e2435e3a20e4009d2974144192cf3c132f6c2971338e156198814d9b91ecb", size = 350504, upload-time = "2026-02-22T04:32:49.22Z" },
82
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/12/386d8c6bf0448c43674e24d6194c3b57d62e5361e90bca3d58108819ad32/cdp_use-1.4.5-py3-none-any.whl", hash = "sha256:8f8e2435e3a20e4009d2974144192cf3c132f6c2971338e156198814d9b91ecb", size = 350504, upload-time = "2026-02-22T04:32:49.22Z" },
83
+ ]
84
+
85
+ [[package]]
86
+ name = "certifi"
87
+ version = "2026.7.22"
88
+ -source = { registry = "https://pypi.org/simple" }
89
+ -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
90
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
91
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
92
+ wheels = [
93
+ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
94
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
95
+ ]
96
+
97
+ [[package]]
98
+ name = "colorama"
99
+ version = "0.4.6"
100
+ -source = { registry = "https://pypi.org/simple" }
101
+ -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
102
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
103
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
104
+ wheels = [
105
+ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
106
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
107
+ ]
108
+
109
+ [[package]]
110
+ name = "fetch-use"
111
+ version = "0.4.0"
112
+ -source = { registry = "https://pypi.org/simple" }
113
+ -sdist = { url = "https://files.pythonhosted.org/packages/5d/2d/66784fa8b66a04f170ad8f6598688b30b3a194dad4185b36d53da4ae1505/fetch_use-0.4.0.tar.gz", hash = "sha256:9511987d4907ec6dac501e21d66946d10098f66b5d21bc2aba4189cd81ba189a", size = 8974, upload-time = "2026-04-09T04:02:44.384Z" }
114
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
115
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/2d/66784fa8b66a04f170ad8f6598688b30b3a194dad4185b36d53da4ae1505/fetch_use-0.4.0.tar.gz", hash = "sha256:9511987d4907ec6dac501e21d66946d10098f66b5d21bc2aba4189cd81ba189a", size = 8974, upload-time = "2026-04-09T04:02:44.384Z" }
116
+ wheels = [
117
+ - { url = "https://files.pythonhosted.org/packages/57/97/d4104692aa5c99a30fea22b5adffd2ce35b1ad86ae5236766cfc1ae468f1/fetch_use-0.4.0-py3-none-any.whl", hash = "sha256:b7885f2907e7920373fa75dcdb00afd6e603a25a9ee2151aa2881f5465e1a2c0", size = 9468, upload-time = "2026-04-09T04:02:43.458Z" },
118
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/97/d4104692aa5c99a30fea22b5adffd2ce35b1ad86ae5236766cfc1ae468f1/fetch_use-0.4.0-py3-none-any.whl", hash = "sha256:b7885f2907e7920373fa75dcdb00afd6e603a25a9ee2151aa2881f5465e1a2c0", size = 9468, upload-time = "2026-04-09T04:02:43.458Z" },
119
+ ]
120
+
121
+ [[package]]
122
+ name = "h11"
123
+ version = "0.16.0"
124
+ -source = { registry = "https://pypi.org/simple" }
125
+ -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
126
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
127
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
128
+ wheels = [
129
+ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
130
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
131
+ ]
132
+
133
+ [[package]]
134
+ name = "h2"
135
+ version = "4.4.1"
136
+ -source = { registry = "https://pypi.org/simple" }
137
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
138
+ dependencies = [
139
+ { name = "hpack" },
140
+ { name = "hyperframe" },
141
+ ]
142
+ -sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
143
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
144
+ wheels = [
145
+ - { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
146
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
147
+ ]
148
+
149
+ [[package]]
150
+ name = "hpack"
151
+ version = "4.2.0"
152
+ -source = { registry = "https://pypi.org/simple" }
153
+ -sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
154
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
155
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
156
+ wheels = [
157
+ - { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
158
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
159
+ ]
160
+
161
+ [[package]]
162
+ name = "httpcore"
163
+ version = "1.0.9"
164
+ -source = { registry = "https://pypi.org/simple" }
165
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
166
+ dependencies = [
167
+ { name = "certifi" },
168
+ { name = "h11" },
169
+ ]
170
+ -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
171
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
172
+ wheels = [
173
+ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
174
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
175
+ ]
176
+
177
+ [[package]]
178
+ name = "httpx"
179
+ version = "0.28.1"
180
+ -source = { registry = "https://pypi.org/simple" }
181
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
182
+ dependencies = [
183
+ { name = "anyio" },
184
+ { name = "certifi" },
185
+ { name = "httpcore" },
186
+ { name = "idna" },
187
+ ]
188
+ -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
189
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
190
+ wheels = [
191
+ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
192
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
193
+ ]
194
+
195
+ [package.optional-dependencies]
196
+ @@ -138,28 +138,28 @@ http2 = [
197
+ [[package]]
198
+ name = "hyperframe"
199
+ version = "6.1.0"
200
+ -source = { registry = "https://pypi.org/simple" }
201
+ -sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
202
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
203
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
204
+ wheels = [
205
+ - { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
206
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
207
+ ]
208
+
209
+ [[package]]
210
+ name = "idna"
211
+ version = "3.19"
212
+ -source = { registry = "https://pypi.org/simple" }
213
+ -sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
214
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
215
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
216
+ wheels = [
217
+ - { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
218
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
219
+ ]
220
+
221
+ [[package]]
222
+ name = "iniconfig"
223
+ version = "2.3.0"
224
+ -source = { registry = "https://pypi.org/simple" }
225
+ -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
226
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
227
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
228
+ wheels = [
229
+ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
230
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
231
+ ]
232
+
233
+ [[package]]
234
+ @@ -194,105 +194,105 @@ dev = [
235
+ [[package]]
236
+ name = "packaging"
237
+ version = "26.3"
238
+ -source = { registry = "https://pypi.org/simple" }
239
+ -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
240
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
241
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
242
+ wheels = [
243
+ - { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
244
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
245
+ ]
246
+
247
+ [[package]]
248
+ name = "pillow"
249
+ version = "12.3.0"
250
+ -source = { registry = "https://pypi.org/simple" }
251
+ -sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
252
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
253
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
254
+ wheels = [
255
+ - { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
256
+ - { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
257
+ - { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
258
+ - { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
259
+ - { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
260
+ - { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
261
+ - { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
262
+ - { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
263
+ - { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
264
+ - { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
265
+ - { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
266
+ - { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
267
+ - { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
268
+ - { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
269
+ - { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
270
+ - { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
271
+ - { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
272
+ - { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
273
+ - { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
274
+ - { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
275
+ - { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
276
+ - { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
277
+ - { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
278
+ - { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
279
+ - { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
280
+ - { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
281
+ - { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
282
+ - { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
283
+ - { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
284
+ - { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
285
+ - { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
286
+ - { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
287
+ - { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
288
+ - { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
289
+ - { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
290
+ - { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
291
+ - { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
292
+ - { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
293
+ - { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
294
+ - { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
295
+ - { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
296
+ - { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
297
+ - { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
298
+ - { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
299
+ - { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
300
+ - { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
301
+ - { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
302
+ - { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
303
+ - { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
304
+ - { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
305
+ - { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
306
+ - { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
307
+ - { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
308
+ - { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
309
+ - { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
310
+ - { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
311
+ - { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
312
+ - { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
313
+ - { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
314
+ - { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
315
+ - { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
316
+ - { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
317
+ - { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
318
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
319
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
320
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
321
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
322
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
323
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
324
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
325
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
326
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
327
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
328
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
329
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
330
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
331
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
332
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
333
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
334
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
335
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
336
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
337
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
338
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
339
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
340
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
341
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
342
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
343
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
344
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
345
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
346
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
347
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
348
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
349
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
350
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
351
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
352
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
353
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
354
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
355
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
356
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
357
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
358
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
359
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
360
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
361
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
362
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
363
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
364
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
365
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
366
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
367
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
368
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
369
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
370
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
371
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
372
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
373
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
374
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
375
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
376
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
377
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
378
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
379
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
380
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
381
+ ]
382
+
383
+ [[package]]
384
+ name = "pluggy"
385
+ version = "1.6.0"
386
+ -source = { registry = "https://pypi.org/simple" }
387
+ -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
388
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
389
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
390
+ wheels = [
391
+ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
392
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
393
+ ]
394
+
395
+ [[package]]
396
+ name = "pygments"
397
+ version = "2.21.0"
398
+ -source = { registry = "https://pypi.org/simple" }
399
+ -sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
400
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
401
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
402
+ wheels = [
403
+ - { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
404
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
405
+ ]
406
+
407
+ [[package]]
408
+ name = "pytest"
409
+ version = "8.4.2"
410
+ -source = { registry = "https://pypi.org/simple" }
411
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
412
+ dependencies = [
413
+ { name = "colorama", marker = "sys_platform == 'win32'" },
414
+ { name = "iniconfig" },
415
+ @@ -300,72 +300,72 @@ dependencies = [
416
+ { name = "pluggy" },
417
+ { name = "pygments" },
418
+ ]
419
+ -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
420
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
421
+ wheels = [
422
+ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
423
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
424
+ ]
425
+
426
+ [[package]]
427
+ name = "ruff"
428
+ version = "0.16.8"
429
+ -source = { registry = "https://pypi.org/simple" }
430
+ -sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
431
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
432
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
433
+ wheels = [
434
+ - { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
435
+ - { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
436
+ - { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
437
+ - { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
438
+ - { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
439
+ - { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
440
+ - { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
441
+ - { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
442
+ - { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
443
+ - { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
444
+ - { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
445
+ - { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
446
+ - { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
447
+ - { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
448
+ - { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
449
+ - { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
450
+ - { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
451
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
452
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
453
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
454
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
455
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
456
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
457
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
458
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
459
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
460
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
461
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
462
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
463
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
464
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
465
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
466
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
467
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
468
+ ]
469
+
470
+ [[package]]
471
+ name = "typing-extensions"
472
+ version = "4.16.0"
473
+ -source = { registry = "https://pypi.org/simple" }
474
+ -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
475
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
476
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
477
+ wheels = [
478
+ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
479
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
480
+ ]
481
+
482
+ [[package]]
483
+ name = "websockets"
484
+ version = "15.0.1"
485
+ -source = { registry = "https://pypi.org/simple" }
486
+ -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
487
+ +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
488
+ +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
489
+ wheels = [
490
+ - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
491
+ - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
492
+ - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
493
+ - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
494
+ - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
495
+ - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
496
+ - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
497
+ - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
498
+ - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
499
+ - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
500
+ - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
501
+ - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
502
+ - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
503
+ - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
504
+ - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
505
+ - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
506
+ - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
507
+ - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
508
+ - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
509
+ - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
510
+ - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
511
+ - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
512
+ - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
513
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
514
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
515
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
516
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
517
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
518
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
519
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
520
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
521
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
522
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
523
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
524
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
525
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
526
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
527
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
528
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
529
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
530
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
531
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
532
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
533
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
534
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
535
+ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
536
+ ]
code/kernels/bench_fast.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stock forward vs TileLang fast path: numerics, latency, and accuracy on real datasets.
2
+
3
+ python benchmarks/bench_fast.py # english checkpoint
4
+ python benchmarks/bench_fast.py --subfolder multilingual
5
+ python benchmarks/bench_fast.py --eval 1000 # + AG News / dair-ai emotion accuracy & ECE
6
+
7
+ Set HF_ENDPOINT to a mirror if huggingface.co is slow for you.
8
+ """
9
+ import argparse, json, os, sys, time
10
+ os.environ.setdefault("USE_TF", "0"); os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+ import numpy as np, torch
13
+ import laya
14
+ from laya.common import QTYPES, build_sequence, collate_items, ece_score
15
+
16
+ ap = argparse.ArgumentParser()
17
+ ap.add_argument("--model", default="convaiinnovations/laya"); ap.add_argument("--subfolder", default=None)
18
+ ap.add_argument("--eval", type=int, default=0, help="samples per dataset for the accuracy comparison (0 = skip)")
19
+ ap.add_argument("--iters", type=int, default=30); ap.add_argument("--json", default=None)
20
+ args = ap.parse_args()
21
+
22
+ agent = laya.load(args.model, subfolder=args.subfolder)
23
+ if agent.device.type != "cuda":
24
+ sys.exit("needs a CUDA device")
25
+ report = {"model": args.model, "subfolder": args.subfolder, "gpu": torch.cuda.get_device_name(0), "torch": torch.__version__}
26
+
27
+ Q = {"department": {"type": "choice", "instructions": "Which team should handle this?",
28
+ "criteria": {"billing": "invoices, refunds", "technical": "bugs, outages", "sales": "pricing", "shipping": "delivery"}},
29
+ "urgency": {"type": "score", "instructions": "How urgent is this?", "criteria": ["not urgent", "soon", "blocking"]},
30
+ "churn": {"type": "noul", "instructions": "Does the user threaten to cancel?"}}
31
+ def qs(n): return {f"{k}{i}": v for i in range(n) for k, v in Q.items()}
32
+ short = {"subject": "Duplicate charge on invoice 4411", "body": "We were billed twice for March. Please refund the duplicate or we're moving to a competitor."}
33
+ long_ = {"subject": "Outage report", "body": "Since yesterday our whole team cannot log in, the dashboard returns 502 errors and our release is blocked. " * 40}
34
+
35
+ def batch(state, q):
36
+ items = []
37
+ for qid in q:
38
+ qq = agent._to_internal(q[qid]); seq, m = build_sequence(agent.tok, state, qq, agent.cfg["max_len"], agent.cfg["head_max_len"])
39
+ items.append({"ids": seq, "markers": m, "qtype": QTYPES[qq["t"]]})
40
+ return {k: v.cuda() for k, v in collate_items([items], agent.tok.pad_token_id).items() if torch.is_tensor(v)}
41
+
42
+ def fwd(b, amp=True):
43
+ with torch.no_grad(), torch.autocast("cuda", dtype=agent.dtype, enabled=amp):
44
+ return agent.model(b["input_ids"], b["attention_mask"], b["marker_pos"], b["marker_mask"], b["qtype"])
45
+
46
+ def timeit(fn, iters=args.iters):
47
+ for _ in range(3): fn()
48
+ torch.cuda.synchronize(); t = time.perf_counter()
49
+ for _ in range(iters): fn()
50
+ torch.cuda.synchronize(); return (time.perf_counter() - t) / iters * 1000
51
+
52
+ cases = [("short x1", short, {"department": Q["department"]}), ("short x3", short, Q), ("short x30", short, qs(10)),
53
+ ("long x3", long_, Q), ("long x30", long_, qs(10))]
54
+ P = lambda l: torch.softmax(l.float(), -1)
55
+ print(f"\n== {report['gpu']} {args.model}/{args.subfolder or ''} dtype={agent.dtype}")
56
+ print("== numerics: max |p - p_fp32| over all options")
57
+ rows = []
58
+ for name, st, q in cases:
59
+ b = batch(st, q)
60
+ agent.deaccelerate()
61
+ l32, _ = fwd(b, amp=False); lo, _ = fwd(b); t_stock = timeit(lambda: fwd(b))
62
+ assert agent.accelerate(strict=True)
63
+ lf, _ = fwd(b); t_fast = timeit(lambda: fwd(b))
64
+ d_o, d_f, d_of = [(P(a) - P(c)).abs().max().item() for a, c in ((lo, l32), (lf, l32), (lf, lo))]
65
+ agree = (lf.argmax(-1) == l32.argmax(-1)).float().mean().item()
66
+ L = b["input_ids"].shape[1]
67
+ print(f"{name:10s} L={L:4d} stock-bf16={d_o:.4f} fast={d_f:.4f} fast-vs-stock={d_of:.4f} argmax agree={agree:.2f}")
68
+ rows.append(dict(case=name, L=L, stock_ms=t_stock, fast_ms=t_fast, dp_stock=d_o, dp_fast=d_f, agree=agree))
69
+ print("\n== model forward latency (ms)")
70
+ print(f"{'case':10s} {'L':>5s} {'stock':>9s} {'fast':>9s} {'speedup':>8s}")
71
+ for r in rows:
72
+ print(f"{r['case']:10s} {r['L']:5d} {r['stock_ms']:9.2f} {r['fast_ms']:9.2f} {r['stock_ms']/r['fast_ms']:7.1f}x")
73
+ print("\n== end-to-end agent.predict() incl. tokenization (ms)")
74
+ for name, st, q in cases:
75
+ agent.deaccelerate(); ts = timeit(lambda: agent.predict(st, q)); agent.accelerate(strict=True); tf = timeit(lambda: agent.predict(st, q))
76
+ print(f"{name:10s} stock={ts:8.2f} fast={tf:8.2f} {ts/tf:5.1f}x")
77
+ [r for r in rows if r["case"] == name][0].update(e2e_stock_ms=ts, e2e_fast_ms=tf)
78
+ report["latency"] = rows
79
+
80
+ if args.eval:
81
+ from datasets import load_dataset
82
+ evals = {
83
+ "ag_news": ("fancyzhx/ag_news", "test", "text", "label",
84
+ {"world": "international news, politics, conflicts", "sports": "sports, games, athletes",
85
+ "business": "companies, markets, economy", "sci/tech": "science, technology, software, space"}),
86
+ "emotion": ("dair-ai/emotion", "test", "text", "label",
87
+ {"sadness": None, "joy": None, "love": None, "anger": None, "fear": None, "surprise": None}),
88
+ }
89
+ report["eval"] = {}
90
+ print(f"\n== accuracy on real datasets ({args.eval} samples each), stock vs fast")
91
+ print(f"{'dataset':9s} {'acc stock':>10s} {'acc fast':>9s} {'ECE stock':>10s} {'ECE fast':>9s} {'agree':>6s} {'stock ms/it':>12s} {'fast ms/it':>11s}")
92
+ for name, (repo, split, tcol, lcol, crit) in evals.items():
93
+ ds = load_dataset(repo, split=split).shuffle(seed=0).select(range(args.eval))
94
+ labels = list(crit.keys())
95
+ q = {"label": {"type": "choice", "instructions": f"Which category does this {name.replace('_', ' ')} text belong to?", "criteria": crit}}
96
+ out = {}
97
+ for mode in ("stock", "fast"):
98
+ agent.deaccelerate() if mode == "stock" else agent.accelerate(strict=True)
99
+ preds, confs, correct = [], [], []
100
+ torch.cuda.synchronize(); t = time.perf_counter()
101
+ for ex in ds:
102
+ a = agent.predict({"text": ex[tcol]}, q)["answers"]["label"]
103
+ preds.append(a["choice"]); confs.append(max(a["probabilities"].values())); correct.append(labels.index(a["choice"]) == ex[lcol])
104
+ torch.cuda.synchronize(); dt = (time.perf_counter() - t) / len(ds) * 1000
105
+ out[mode] = dict(acc=float(np.mean(correct)), ece=ece_score(np.array(confs), np.array(correct, dtype=float)), ms=dt, preds=preds)
106
+ agree = float(np.mean([a == b for a, b in zip(out["stock"]["preds"], out["fast"]["preds"])]))
107
+ print(f"{name:9s} {out['stock']['acc']:10.3f} {out['fast']['acc']:9.3f} {out['stock']['ece']:10.3f} {out['fast']['ece']:9.3f} {agree:6.3f} {out['stock']['ms']:12.1f} {out['fast']['ms']:11.1f}")
108
+ report["eval"][name] = dict(n=args.eval, stock={k: v for k, v in out["stock"].items() if k != "preds"}, fast={k: v for k, v in out["fast"].items() if k != "preds"}, agreement=agree)
109
+ if args.json:
110
+ json.dump(report, open(args.json, "w"), indent=1); print("saved", args.json)
code/kernels/fast.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GPU fast path for laya: TileLang fused kernels + bf16 resident weights + CUDA graphs.
2
+
3
+ agent = laya.load("convaiinnovations/laya", fast=True) # or agent.accelerate()
4
+
5
+ Requires CUDA and `pip install laya[fast]` (tilelang). Falls back to the stock forward otherwise.
6
+ """
7
+ import sys
8
+ import torch
9
+ import tl_kernels as K
10
+
11
+ BF = torch.bfloat16
12
+
13
+
14
+ def _bucket_n(n):
15
+ return 1 << max(0, (n - 1).bit_length())
16
+
17
+
18
+ class FastLaya:
19
+ def __init__(self, model, max_len=1024, use_graphs=True, verbose=False):
20
+ self.m = model
21
+ enc = model.encoder
22
+ cfg = enc.config
23
+ dev = next(model.parameters()).device
24
+ self.dev = dev
25
+ self.use_graphs = use_graphs
26
+ self.verbose = verbose
27
+ self.H, self.Dh, self.D = cfg.num_attention_heads, cfg.hidden_size // cfg.num_attention_heads, cfg.hidden_size
28
+ self.F = cfg.intermediate_size
29
+ self.eps = cfg.norm_eps
30
+ self.max_len = max_len
31
+ f32 = lambda t: t.detach().float().contiguous()
32
+ b16 = lambda t: t.detach().to(BF).contiguous()
33
+ zeros = torch.zeros(self.D, device=dev)
34
+ self.zeros = {self.D: zeros, 3 * self.D: torch.zeros(3 * self.D, device=dev), 4 * self.D: torch.zeros(4 * self.D, device=dev),
35
+ 2 * self.F: torch.zeros(2 * self.F, device=dev)}
36
+ # --- encoder weights
37
+ self.emb_w = b16(enc.embeddings.tok_embeddings.weight)
38
+ self.emb_ln = f32(enc.embeddings.norm.weight)
39
+ self.layers = []
40
+ for i, lyr in enumerate(enc.layers):
41
+ self.layers.append(dict(
42
+ attn_ln=None if i == 0 else f32(lyr.attn_norm.weight),
43
+ wqkv=b16(lyr.attn.Wqkv.weight), wo=b16(lyr.attn.Wo.weight),
44
+ mlp_ln=f32(lyr.mlp_norm.weight), wi=b16(lyr.mlp.Wi.weight), wo2=b16(lyr.mlp.Wo.weight),
45
+ window=lyr.attn.sliding_window or 0, ltype=lyr.attention_type))
46
+ self.final_ln = f32(enc.final_norm.weight)
47
+ # --- rotary tables (rounded through bf16 exactly like HF does before applying)
48
+ rot = enc.rotary_emb
49
+ pos = torch.arange(max_len, device=dev).float()
50
+ self.rope = {}
51
+ for lt in set(cfg.layer_types):
52
+ inv = getattr(rot, f"{lt}_inv_freq").float()
53
+ scl = getattr(rot, f"{lt}_attention_scaling")
54
+ fr = torch.outer(pos, inv)
55
+ self.rope[lt] = ((fr.cos() * scl).to(BF).float().contiguous(), (fr.sin() * scl).to(BF).float().contiguous())
56
+ # --- decision head (nn.TransformerEncoderLayer, norm_first, relu)
57
+ self.type_emb = b16(model.type_emb.weight)
58
+ self.head = []
59
+ for lyr in model.head.layers:
60
+ sa = lyr.self_attn
61
+ self.head.append(dict(
62
+ n1w=f32(lyr.norm1.weight), n1b=f32(lyr.norm1.bias), n2w=f32(lyr.norm2.weight), n2b=f32(lyr.norm2.bias),
63
+ in_w=b16(sa.in_proj_weight), in_b=f32(sa.in_proj_bias), out_w=b16(sa.out_proj.weight), out_b=f32(sa.out_proj.bias),
64
+ l1w=b16(lyr.linear1.weight), l1b=f32(lyr.linear1.bias), l2w=b16(lyr.linear2.weight), l2b=f32(lyr.linear2.bias)))
65
+ # --- kernels (M is dynamic, so these compile once)
66
+ D, F = self.D, self.F
67
+ self.k_qkv = K.gemm_kernel(3 * D, D)
68
+ self.k_o = K.gemm_kernel(D, D)
69
+ self.k_geglu = K.gemm_geglu_kernel(F, D)
70
+ self.k_o2 = K.gemm_kernel(D, F)
71
+ self.k_ln = K.add_ln_kernel(D, residual=False, bias=False, eps=self.eps)
72
+ self.k_addln = K.add_ln_kernel(D, residual=True, bias=False, eps=self.eps)
73
+ self.k_addln_b = K.add_ln_kernel(D, residual=True, bias=True, eps=1e-5)
74
+ self.k_ln_b = K.add_ln_kernel(D, residual=False, bias=True, eps=1e-5)
75
+ self.k_in = K.gemm_kernel(3 * D, D, bias=True)
76
+ self.k_out = K.gemm_kernel(D, D, bias=True)
77
+ self.k_ffn1 = K.gemm_kernel(4 * D, D, bias=True, act="relu")
78
+ self.k_ffn2 = K.gemm_kernel(D, 4 * D, bias=True)
79
+ self._rope_k, self._rope_tab, self._attn_k = None, {}, {}
80
+ self.graphs = {}
81
+
82
+ # ------------------------------------------------------------------ kernels per shape
83
+ DYNAMIC_MAX_L = 256 # up to here one dynamic-shape attention kernel is as fast as a static one
84
+ LONG_BUCKET = 64 # beyond it, static kernels per (B, L) bucket of this size
85
+
86
+ def rope_k(self):
87
+ if self._rope_k is None:
88
+ self._rope_k = K.rope_kernel(self.H, self.Dh)
89
+ return self._rope_k
90
+
91
+ def rope_tab(self, ltype, L):
92
+ key = (ltype, L)
93
+ if key not in self._rope_tab:
94
+ cos, sin = self.rope[ltype]
95
+ self._rope_tab[key] = (cos[:L].contiguous(), sin[:L].contiguous())
96
+ return self._rope_tab[key]
97
+
98
+ def attn_k(self, B, L, window):
99
+ key = (None, None, window) if L <= self.DYNAMIC_MAX_L else (B, L, window)
100
+ if key not in self._attn_k:
101
+ self._attn_k[key] = K.attn_kernel(key[0], key[1], self.H, self.Dh, window=window)
102
+ return self._attn_k[key]
103
+
104
+ # ------------------------------------------------------------------ encoder + head on padded [B, L]
105
+ def _encode(self, ids, lens, qtype):
106
+ """ids [B,L] long (padded), lens [B] int32, qtype [B] long -> hidden [B, L, D] bf16"""
107
+ B, L = ids.shape
108
+ M, D = B * L, self.D
109
+ dev = self.dev
110
+ X = torch.nn.functional.embedding(ids, self.emb_w).view(M, D) # residual stream (bf16)
111
+ Y = torch.empty_like(X)
112
+ qkv = torch.empty(M, 3 * D, device=dev, dtype=BF)
113
+ O = torch.empty(M, D, device=dev, dtype=BF)
114
+ G = torch.empty(M, self.F, device=dev, dtype=BF)
115
+ z = self.zeros
116
+ self.k_ln(X, X, self.emb_ln, z[D], Y) # embeddings.norm
117
+ X, Y = Y, X # X is now the residual stream
118
+ nl = len(self.layers)
119
+ for i, ly in enumerate(self.layers):
120
+ src = X if i == 0 else Y # layer 0 has attn_norm = Identity
121
+ self.k_qkv(src, ly["wqkv"], z[3 * D], qkv)
122
+ cos, sin = self.rope_tab(ly["ltype"], L)
123
+ self.rope_k()(qkv, cos, sin)
124
+ self.attn_k(B, L, ly["window"])(qkv.view(B, L, 3, self.H, self.Dh), lens, O.view(B, L, D))
125
+ self.k_o(O, ly["wo"], z[D], Y) # Y = attn out
126
+ self.k_addln(X, Y, ly["mlp_ln"], z[D], Y) # X += Y ; Y = mlp_norm(X)
127
+ self.k_geglu(Y, ly["wi"], G)
128
+ self.k_o2(G, ly["wo2"], z[D], Y) # Y = mlp out
129
+ nxt = self.layers[i + 1]["attn_ln"] if i + 1 < nl else self.final_ln
130
+ self.k_addln(X, Y, nxt, z[D], Y) # X += Y ; Y = next norm(X)
131
+ # decision head: h = final_norm(x) + type_emb ; 2 x pre-norm transformer layers (relu ffn)
132
+ X = (Y.view(B, L, D) + self.type_emb[qtype][:, None, :]).view(M, D).contiguous()
133
+ F1 = torch.empty(M, 4 * D, device=dev, dtype=BF)
134
+ for j, h in enumerate(self.head):
135
+ self.k_ln_b(X, X, h["n1w"], h["n1b"], Y)
136
+ self.k_in(Y, h["in_w"], h["in_b"], qkv)
137
+ self.attn_k(B, L, 0)(qkv.view(B, L, 3, self.H, self.Dh), lens, O.view(B, L, D))
138
+ self.k_out(O, h["out_w"], h["out_b"], Y)
139
+ self.k_addln_b(X, Y, h["n2w"], h["n2b"], Y) # X += attn ; Y = norm2(X)
140
+ self.k_ffn1(Y, h["l1w"], h["l1b"], F1)
141
+ self.k_ffn2(F1, h["l2w"], h["l2b"], Y)
142
+ X = X + Y # residual (torch, last op)
143
+ return X.view(B, L, D)
144
+
145
+ def _encode_graphed(self, ids, lens, qtype):
146
+ key = tuple(ids.shape)
147
+ g = self.graphs.get(key)
148
+ if g is None:
149
+ s_ids, s_lens, s_q = ids.clone(), lens.clone(), qtype.clone()
150
+ st = torch.cuda.Stream()
151
+ st.wait_stream(torch.cuda.current_stream())
152
+ with torch.cuda.stream(st):
153
+ for _ in range(2):
154
+ self._encode(s_ids, s_lens, s_q) # warm-up (compiles kernels, allocs)
155
+ torch.cuda.current_stream().wait_stream(st)
156
+ graph = torch.cuda.CUDAGraph()
157
+ with torch.cuda.graph(graph):
158
+ s_out = self._encode(s_ids, s_lens, s_q)
159
+ g = self.graphs[key] = (graph, s_ids, s_lens, s_q, s_out)
160
+ if self.verbose:
161
+ print(f"[fast_laya] captured CUDA graph for shape {key}", file=sys.stderr)
162
+ graph, s_ids, s_lens, s_q, s_out = g
163
+ s_ids.copy_(ids); s_lens.copy_(lens); s_q.copy_(qtype)
164
+ graph.replay()
165
+ return s_out
166
+
167
+ # ------------------------------------------------------------------ DecisionModel.forward replacement
168
+ @torch.no_grad()
169
+ def forward(self, input_ids, attention_mask, marker_pos, marker_mask, qtype, detach_encoder=False):
170
+ m = self.m
171
+ N, L0 = input_ids.shape
172
+ g = 16 if L0 <= self.DYNAMIC_MAX_L else self.LONG_BUCKET
173
+ L = min(self.max_len, ((L0 + g - 1) // g) * g)
174
+ B = _bucket_n(N)
175
+ ids = torch.zeros(B, L, dtype=torch.long, device=self.dev)
176
+ ids[:N, :L0] = input_ids
177
+ lens = torch.zeros(B, dtype=torch.int32, device=self.dev)
178
+ lens[:N] = attention_mask.sum(1).to(torch.int32)
179
+ qt = torch.zeros(B, dtype=torch.long, device=self.dev)
180
+ qt[:N] = qtype
181
+ h = (self._encode_graphed if self.use_graphs else self._encode)(ids, lens, qt)
182
+ h = h[:N, :L0].float()
183
+ # ---- scorer / act head (tiny; identical to laya.common.DecisionModel.forward)
184
+ idx = marker_pos.clamp(min=0)[:, :, None].expand(-1, -1, h.size(-1))
185
+ mk = torch.gather(h, 1, idx)
186
+ logits = m.scorer(mk).squeeze(-1).float()
187
+ logits = logits.masked_fill(~marker_mask, -1e4)
188
+ p = torch.softmax(logits, -1)
189
+ k = marker_mask.sum(-1).clamp(min=2).float()
190
+ ent = -(p * torch.log(p.clamp_min(1e-9))).sum(-1) / torch.log(k)
191
+ top2 = p.topk(2, -1).values
192
+ feats = torch.stack([top2[:, 0], top2[:, 0] - top2[:, 1], ent, k / 255.0], -1)
193
+ pooled = h[:, 0].float()
194
+ act_logits = m.act_head(torch.cat([pooled, feats], -1))
195
+ return logits, act_logits
code/kernels/test_fast.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TileLang fast path: kernels vs torch reference, and full forward vs the stock model.
2
+
3
+ Skips unless CUDA + tilelang are available. Run: python -m pytest tests/test_fast.py -q
4
+ """
5
+ import os, sys
6
+ import pytest
7
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
+ torch = pytest.importorskip("torch")
9
+ if not torch.cuda.is_available():
10
+ pytest.skip("needs CUDA", allow_module_level=True)
11
+ pytest.importorskip("tilelang")
12
+ from laya import tl_kernels as K # noqa: E402
13
+
14
+ dev = "cuda"
15
+ def err(a, b): return (a.float() - b.float()).abs().max().item()
16
+
17
+
18
+ def test_gemm_epilogues():
19
+ M, Kd = 200, 768 # M not a multiple of the tile on purpose
20
+ A = torch.randn(M, Kd, device=dev, dtype=torch.bfloat16)
21
+ for N, bias, act in [(2304, False, "none"), (768, True, "gelu"), (3072, True, "relu")]:
22
+ W = torch.randn(N, Kd, device=dev, dtype=torch.bfloat16) * 0.02; b = torch.randn(N, device=dev)
23
+ C = torch.empty(M, N, device=dev, dtype=torch.bfloat16)
24
+ K.gemm_kernel(N, Kd, bias=bias, act=act)(A, W, b, C)
25
+ ref = A.float() @ W.float().T + (b if bias else 0)
26
+ ref = {"none": ref, "gelu": torch.nn.functional.gelu(ref), "relu": torch.relu(ref)}[act]
27
+ assert err(C, ref) < 0.05
28
+
29
+
30
+ def test_geglu():
31
+ M, Kd, F = 256, 768, 1152
32
+ A = torch.randn(M, Kd, device=dev, dtype=torch.bfloat16); Wi = torch.randn(2 * F, Kd, device=dev, dtype=torch.bfloat16) * 0.02
33
+ C = torch.empty(M, F, device=dev, dtype=torch.bfloat16); K.gemm_geglu_kernel(F, Kd)(A, Wi, C)
34
+ x = A.float() @ Wi.float().T
35
+ assert err(C, torch.nn.functional.gelu(x[:, :F]) * x[:, F:]) < 0.05
36
+
37
+
38
+ def test_add_layernorm():
39
+ M, D = 100, 768
40
+ X = torch.randn(M, D, device=dev, dtype=torch.bfloat16); R = torch.randn_like(X)
41
+ w = torch.rand(D, device=dev) + 0.5; b = torch.randn(D, device=dev)
42
+ X2 = X.clone(); Y = torch.empty_like(X)
43
+ K.add_ln_kernel(D, residual=True, bias=True)(X2, R, w, b, Y)
44
+ xr = (X.float() + R.float()).bfloat16().float()
45
+ assert err(X2, xr) < 1e-6
46
+ assert err(Y, torch.nn.functional.layer_norm(xr, (D,), w, b, 1e-5)) < 0.05
47
+
48
+
49
+ def test_attention_mask_and_window():
50
+ H, Dh, B = 12, 64, 3
51
+ for L, window, dyn in [(80, 0, True), (200, 65, True), (1024, 65, False), (1024, 0, False)]:
52
+ qkv = torch.randn(B, L, 3, H, Dh, device=dev, dtype=torch.bfloat16)
53
+ lens = torch.tensor([L, L - 7, max(1, L // 3)], device=dev, dtype=torch.int32)
54
+ O = torch.empty(B, L, H * Dh, device=dev, dtype=torch.bfloat16)
55
+ K.attn_kernel(None if dyn else B, None if dyn else L, H, Dh, window=window)(qkv, lens, O)
56
+ q, k, v = [qkv[:, :, i].transpose(1, 2).float() for i in range(3)]
57
+ idx = torch.arange(L, device=dev)
58
+ mask = (idx[None, :] < lens[:, None])[:, None, None, :].expand(B, 1, L, L)
59
+ if window:
60
+ mask = mask & ((idx[:, None] - idx[None, :]).abs() <= window)[None, None]
61
+ ref = torch.nn.functional.scaled_dot_product_attention(q, k, v, attn_mask=mask).transpose(1, 2).reshape(B, L, -1)
62
+ valid = idx[None, :] < lens[:, None]
63
+ assert torch.isfinite(O).all()
64
+ assert (O.float() - ref)[valid].abs().max().item() < 0.02
65
+
66
+
67
+ @pytest.mark.skipif(os.environ.get("LAYA_TEST_MODEL") is None, reason="set LAYA_TEST_MODEL=<repo or path> to run")
68
+ def test_full_forward_matches_stock():
69
+ import laya
70
+ from laya.common import QTYPES, build_sequence, collate_items
71
+ agent = laya.load(os.environ["LAYA_TEST_MODEL"], subfolder=os.environ.get("LAYA_TEST_SUBFOLDER"))
72
+ q = {"dept": {"type": "choice", "instructions": "Which team?", "criteria": {"billing": "refunds", "tech": "bugs", "sales": "pricing"}},
73
+ "urg": {"type": "score", "instructions": "How urgent?", "criteria": ["low", "mid", "high"]},
74
+ "churn": {"type": "noul", "instructions": "Threatens to cancel?"}}
75
+ st = {"body": "We were billed twice, refund now or we cancel. " * 30}
76
+ items = []
77
+ for qid in q:
78
+ qq = agent._to_internal(q[qid]); seq, m = build_sequence(agent.tok, st, qq, agent.cfg["max_len"], agent.cfg["head_max_len"])
79
+ items.append({"ids": seq, "markers": m, "qtype": QTYPES[qq["t"]]})
80
+ b = {k: v.cuda() for k, v in collate_items([items], agent.tok.pad_token_id).items() if torch.is_tensor(v)}
81
+ def run():
82
+ with torch.no_grad(), torch.autocast("cuda", dtype=agent.dtype):
83
+ return agent.model(b["input_ids"], b["attention_mask"], b["marker_pos"], b["marker_mask"], b["qtype"])[0]
84
+ lo = run(); assert agent.accelerate(strict=True); lf = run()
85
+ assert (torch.softmax(lo.float(), -1) - torch.softmax(lf.float(), -1)).abs().max().item() < 0.02
code/kernels/tl_kernels.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TileLang kernels for the Laya (ModernBERT + decision head) encoder.
2
+
3
+ All kernels take bf16 activations, accumulate in fp32. Row count M is a runtime
4
+ symbol so one compiled kernel serves every batch/sequence bucket; M must be a
5
+ multiple of 16 (the caller pads); out-of-bounds rows are predicated by TileLang.
6
+ """
7
+ import tilelang
8
+ import tilelang.language as T
9
+
10
+ DT, ACC = "bfloat16", "float"
11
+ FAST = {tilelang.PassConfigKey.TL_ENABLE_FAST_MATH: True}
12
+
13
+
14
+ def _act(x, kind):
15
+ if kind == "gelu": # exact erf-GELU, what HF "gelu" means
16
+ return 0.5 * x * (1.0 + T.erf(x * 0.7071067811865476))
17
+ if kind == "relu":
18
+ return T.max(x, 0.0)
19
+ return x
20
+
21
+
22
+ # ----------------------------------------------------------------------------- GEMM
23
+ @tilelang.jit(pass_configs=FAST)
24
+ def gemm_kernel(N, K, bias=False, act="none", bm=64, bn=128, bk=64, stages=3, threads=128):
25
+ """C[M,N] = act(A[M,K] @ W[N,K]^T + b)."""
26
+ M = T.dynamic("M")
27
+
28
+ @T.prim_func
29
+ def main(A: T.Tensor((M, K), DT), W: T.Tensor((N, K), DT), Bv: T.Tensor((N,), ACC), C: T.Tensor((M, N), DT)):
30
+ with T.Kernel(T.ceildiv(N, bn), T.ceildiv(M, bm), threads=threads) as (bx, by):
31
+ A_s = T.alloc_shared((bm, bk), DT)
32
+ W_s = T.alloc_shared((bn, bk), DT)
33
+ C_l = T.alloc_fragment((bm, bn), ACC)
34
+ T.clear(C_l)
35
+ for k in T.Pipelined(T.ceildiv(K, bk), num_stages=stages):
36
+ T.copy(A[by * bm, k * bk], A_s)
37
+ T.copy(W[bx * bn, k * bk], W_s)
38
+ T.gemm(A_s, W_s, C_l, transpose_B=True)
39
+ for i, j in T.Parallel(bm, bn):
40
+ v = C_l[i, j]
41
+ if bias:
42
+ v = v + Bv[bx * bn + j]
43
+ C_l[i, j] = _act(v, act)
44
+ T.copy(C_l, C[by * bm, bx * bn])
45
+ return main
46
+
47
+
48
+ @tilelang.jit(pass_configs=FAST)
49
+ def gemm_geglu_kernel(F, K, bm=64, bn=64, bk=64, stages=3, threads=128):
50
+ """ModernBERT GLU MLP up-projection, fused: C[M,F] = gelu(A @ Wi[:F]^T) * (A @ Wi[F:]^T)."""
51
+ M = T.dynamic("M")
52
+
53
+ @T.prim_func
54
+ def main(A: T.Tensor((M, K), DT), W: T.Tensor((2 * F, K), DT), C: T.Tensor((M, F), DT)):
55
+ with T.Kernel(T.ceildiv(F, bn), T.ceildiv(M, bm), threads=threads) as (bx, by):
56
+ A_s = T.alloc_shared((bm, bk), DT)
57
+ Wi_s = T.alloc_shared((bn, bk), DT)
58
+ Wg_s = T.alloc_shared((bn, bk), DT)
59
+ Ci = T.alloc_fragment((bm, bn), ACC)
60
+ Cg = T.alloc_fragment((bm, bn), ACC)
61
+ T.clear(Ci); T.clear(Cg)
62
+ for k in T.Pipelined(T.ceildiv(K, bk), num_stages=stages):
63
+ T.copy(A[by * bm, k * bk], A_s)
64
+ T.copy(W[bx * bn, k * bk], Wi_s)
65
+ T.copy(W[F + bx * bn, k * bk], Wg_s)
66
+ T.gemm(A_s, Wi_s, Ci, transpose_B=True)
67
+ T.gemm(A_s, Wg_s, Cg, transpose_B=True)
68
+ for i, j in T.Parallel(bm, bn):
69
+ Ci[i, j] = _act(Ci[i, j], "gelu") * Cg[i, j]
70
+ T.copy(Ci, C[by * bm, bx * bn])
71
+ return main
72
+
73
+
74
+ # ----------------------------------------------------------------------------- LayerNorm (+residual)
75
+ @tilelang.jit(pass_configs=FAST)
76
+ def add_ln_kernel(D, residual=True, bias=False, eps=1e-5, bm=4, threads=32):
77
+ """X = X + R (in place, if residual); Y = LN(X) * w (+ b). Stats in fp32."""
78
+ M = T.dynamic("M")
79
+
80
+ @T.prim_func
81
+ def main(X: T.Tensor((M, D), DT), R: T.Tensor((M, D), DT), Wv: T.Tensor((D,), ACC), Bv: T.Tensor((D,), ACC),
82
+ Y: T.Tensor((M, D), DT)):
83
+ with T.Kernel(T.ceildiv(M, bm), threads=threads) as bx:
84
+ x = T.alloc_fragment((bm, D), ACC)
85
+ xs = T.alloc_fragment((bm, D), ACC)
86
+ mean = T.alloc_fragment((bm,), ACC)
87
+ var = T.alloc_fragment((bm,), ACC)
88
+ Xb = T.alloc_shared((bm, D), DT)
89
+ T.copy(X[bx * bm, 0], Xb)
90
+ T.copy(Xb, x)
91
+ if residual:
92
+ T.copy(R[bx * bm, 0], Xb)
93
+ T.copy(Xb, xs)
94
+ for i, j in T.Parallel(bm, D):
95
+ x[i, j] = x[i, j] + xs[i, j]
96
+ T.copy(x, Xb)
97
+ T.copy(Xb, X[bx * bm, 0])
98
+ T.reduce_sum(x, mean, dim=1)
99
+ for i in T.Parallel(bm):
100
+ mean[i] = mean[i] / D
101
+ for i, j in T.Parallel(bm, D):
102
+ xs[i, j] = (x[i, j] - mean[i]) * (x[i, j] - mean[i])
103
+ T.reduce_sum(xs, var, dim=1)
104
+ for i in T.Parallel(bm):
105
+ var[i] = T.rsqrt(var[i] / D + eps)
106
+ for i, j in T.Parallel(bm, D):
107
+ v = (x[i, j] - mean[i]) * var[i] * Wv[j]
108
+ if bias:
109
+ v = v + Bv[j]
110
+ xs[i, j] = v
111
+ T.copy(xs, Xb)
112
+ T.copy(Xb, Y[bx * bm, 0])
113
+ return main
114
+
115
+
116
+ # ----------------------------------------------------------------------------- RoPE (in place on packed qkv)
117
+ @tilelang.jit(pass_configs=FAST)
118
+ def rope_kernel(H, Dh, bm=32, threads=128):
119
+ """QKV[M, 3*H*Dh] packed as (q|k|v)(h)(d). Rotates q and k in place (rotate-half convention, fp32 math).
120
+ cos/sin: [L, Dh/2]. Row r has position r % L. M and L are runtime symbols."""
121
+ M, L = T.dynamic("M"), T.dynamic("L")
122
+ half = Dh // 2
123
+ W = 2 * H * Dh # q and k columns
124
+
125
+ @T.prim_func
126
+ def main(QKV: T.Tensor((M, 3 * H * Dh), DT), Cos: T.Tensor((L, half), ACC), Sin: T.Tensor((L, half), ACC)):
127
+ with T.Kernel(T.ceildiv(M, bm), threads=threads) as bx:
128
+ for i, c in T.Parallel(bm, W // 2):
129
+ r = bx * bm + i
130
+ pos = r % L
131
+ hh = c // half # which (q|k, head)
132
+ d = c % half
133
+ c0 = hh * Dh + d
134
+ c1 = c0 + half
135
+ x0 = T.cast(QKV[r, c0], ACC)
136
+ x1 = T.cast(QKV[r, c1], ACC)
137
+ cs = Cos[pos, d]
138
+ sn = Sin[pos, d]
139
+ QKV[r, c0] = T.cast(x0 * cs - x1 * sn, DT)
140
+ QKV[r, c1] = T.cast(x1 * cs + x0 * sn, DT)
141
+ return main
142
+
143
+
144
+ # ----------------------------------------------------------------------------- flash attention (padding mask + sliding window)
145
+ @tilelang.jit(pass_configs=FAST)
146
+ def attn_kernel(B, L, H, Dh, window=0, bm=64, bn=64, stages=1, threads=128):
147
+ """QKV: [B, L, 3, H, Dh] bf16 (a view of the packed [M, 3*H*Dh] buffer). Lens: [B] int32 valid length.
148
+ O: [B, L, H*Dh]. window>0 => bidirectional sliding window |i-j| <= window. Masked scores use a large
149
+ finite negative so fully-masked (padding) rows stay finite.
150
+
151
+ B and/or L may be None: they then become runtime symbols (one compile serves every shape, at the
152
+ cost of predicated loads -- ~4x slower for full attention at L=1024, free for short inputs)."""
153
+ scale = (1.0 / Dh) ** 0.5 * 1.44269504 # log2(e)
154
+ if B is None:
155
+ B = T.dynamic("B")
156
+ if L is None:
157
+ L = T.dynamic("L")
158
+ NEG = -1e9
159
+
160
+ @T.prim_func
161
+ def main(QKV: T.Tensor((B, L, 3, H, Dh), DT), Lens: T.Tensor((B,), "int32"), O: T.Tensor((B, L, H * Dh), DT)):
162
+ with T.Kernel(T.ceildiv(L, bm), H, B, threads=threads) as (bx, by, bz):
163
+ Q_s = T.alloc_shared((bm, Dh), DT)
164
+ K_s = T.alloc_shared((bn, Dh), DT)
165
+ V_s = T.alloc_shared((bn, Dh), DT)
166
+ O_s = T.alloc_shared((bm, Dh), DT)
167
+ s = T.alloc_fragment((bm, bn), ACC)
168
+ s_c = T.alloc_fragment((bm, bn), DT)
169
+ o = T.alloc_fragment((bm, Dh), ACC)
170
+ m = T.alloc_fragment((bm,), ACC)
171
+ m_prev = T.alloc_fragment((bm,), ACC)
172
+ sc = T.alloc_fragment((bm,), ACC)
173
+ rs = T.alloc_fragment((bm,), ACC)
174
+ l = T.alloc_fragment((bm,), ACC)
175
+ T.annotate_layout({Q_s: tilelang.layout.make_swizzled_layout(Q_s)})
176
+ T.copy(QKV[bz, bx * bm:(bx + 1) * bm, 0, by, :], Q_s)
177
+ T.fill(o, 0); T.fill(l, 0); T.fill(m, NEG)
178
+ n = Lens[bz]
179
+ if window > 0:
180
+ k_lo = T.max(0, (bx * bm - window) // bn)
181
+ k_hi = T.min(T.ceildiv(L, bn), T.ceildiv(T.min(n, (bx + 1) * bm + window), bn))
182
+ else:
183
+ k_lo = 0
184
+ k_hi = T.ceildiv(n, bn)
185
+ for k in T.Pipelined(k_lo, k_hi, num_stages=stages):
186
+ T.copy(QKV[bz, k * bn:(k + 1) * bn, 1, by, :], K_s)
187
+ for i, j in T.Parallel(bm, bn):
188
+ qi = bx * bm + i
189
+ kj = k * bn + j
190
+ if window > 0:
191
+ ok = (kj < n) & (qi - kj <= window) & (kj - qi <= window)
192
+ else:
193
+ ok = kj < n
194
+ s[i, j] = T.if_then_else(ok, 0.0, NEG)
195
+ T.gemm(Q_s, K_s, s, transpose_B=True, policy=T.GemmWarpPolicy.FullRow)
196
+ T.copy(QKV[bz, k * bn:(k + 1) * bn, 2, by, :], V_s)
197
+ T.copy(m, m_prev)
198
+ T.reduce_max(s, m, dim=1, clear=False)
199
+ for i in T.Parallel(bm):
200
+ sc[i] = T.exp2(m_prev[i] * scale - m[i] * scale)
201
+ for i, j in T.Parallel(bm, bn):
202
+ s[i, j] = T.exp2(s[i, j] * scale - m[i] * scale)
203
+ T.reduce_sum(s, rs, dim=1)
204
+ for i in T.Parallel(bm):
205
+ l[i] = l[i] * sc[i] + rs[i]
206
+ T.copy(s, s_c)
207
+ for i, j in T.Parallel(bm, Dh):
208
+ o[i, j] = o[i, j] * sc[i]
209
+ T.gemm(s_c, V_s, o, policy=T.GemmWarpPolicy.FullRow)
210
+ for i, j in T.Parallel(bm, Dh):
211
+ o[i, j] = o[i, j] / T.max(l[i], 1e-30)
212
+ T.copy(o, O_s)
213
+ T.copy(O_s, O[bz, bx * bm:(bx + 1) * bm, by * Dh:(by + 1) * Dh])
214
+ return main
results/fast_english_rtx4070.json ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "convaiinnovations/laya",
3
+ "subfolder": null,
4
+ "gpu": "NVIDIA GeForce RTX 4070 Ti SUPER",
5
+ "torch": "2.11.0+cu130",
6
+ "latency": [
7
+ {
8
+ "case": "short x1",
9
+ "L": 72,
10
+ "stock_ms": 17.02346319998469,
11
+ "fast_ms": 4.294897766643166,
12
+ "dp_stock": 1.704692840576172e-05,
13
+ "dp_fast": 0.00010764598846435547,
14
+ "agree": 1.0,
15
+ "e2e_stock_ms": 17.660162200021052,
16
+ "e2e_fast_ms": 4.639072833318399
17
+ },
18
+ {
19
+ "case": "short x3",
20
+ "L": 72,
21
+ "stock_ms": 18.0833204666366,
22
+ "fast_ms": 5.4828341333632125,
23
+ "dp_stock": 0.013189524412155151,
24
+ "dp_fast": 0.004427850246429443,
25
+ "agree": 1.0,
26
+ "e2e_stock_ms": 18.868332833335444,
27
+ "e2e_fast_ms": 6.6060417999930605
28
+ },
29
+ {
30
+ "case": "short x30",
31
+ "L": 72,
32
+ "stock_ms": 35.470120733346754,
33
+ "fast_ms": 28.24384649999653,
34
+ "dp_stock": 0.0020374655723571777,
35
+ "dp_fast": 0.00442880392074585,
36
+ "agree": 1.0,
37
+ "e2e_stock_ms": 43.21606490005555,
38
+ "e2e_fast_ms": 35.695823633310894
39
+ },
40
+ {
41
+ "case": "long x3",
42
+ "L": 512,
43
+ "stock_ms": 27.119640500010668,
44
+ "fast_ms": 23.355661833375052,
45
+ "dp_stock": 0.004268214106559753,
46
+ "dp_fast": 0.0036762356758117676,
47
+ "agree": 1.0,
48
+ "e2e_stock_ms": 31.515020933329655,
49
+ "e2e_fast_ms": 27.594624333323736
50
+ },
51
+ {
52
+ "case": "long x30",
53
+ "L": 512,
54
+ "stock_ms": 287.79868293337734,
55
+ "fast_ms": 187.11691290003122,
56
+ "dp_stock": 0.0016844123601913452,
57
+ "dp_fast": 0.003676295280456543,
58
+ "agree": 1.0,
59
+ "e2e_stock_ms": 327.53421153332357,
60
+ "e2e_fast_ms": 232.13719066661724
61
+ }
62
+ ],
63
+ "eval": {
64
+ "ag_news": {
65
+ "n": 1000,
66
+ "stock": {
67
+ "acc": 0.922,
68
+ "ece": 0.03231210000000012,
69
+ "ms": 19.09175434700046
70
+ },
71
+ "fast": {
72
+ "acc": 0.925,
73
+ "ece": 0.03202170000000001,
74
+ "ms": 14.999506231999476
75
+ },
76
+ "agreement": 0.995
77
+ },
78
+ "emotion": {
79
+ "n": 1000,
80
+ "stock": {
81
+ "acc": 0.588,
82
+ "ece": 0.3144150999999999,
83
+ "ms": 17.40303504700023
84
+ },
85
+ "fast": {
86
+ "acc": 0.588,
87
+ "ece": 0.3146924999999999,
88
+ "ms": 4.41586683299829
89
+ },
90
+ "agreement": 0.996
91
+ }
92
+ }
93
+ }