File size: 12,667 Bytes
e379257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# Glint Decision Model PoC

Screenshot + natural-language questions + options → probability per option, computed in one forward pass
by a **Clef-style Joint Schema Head** on top of a **Qwen3.5 VLM backbone with LoRA** (no text generation).
The student is distilled from a teacher's option distributions (Clef / Clef-Flash) with a temperature KL loss,
plus cross-entropy (+ Brier) when a gold label exists.

> `glint/joint_schema_model.py` is Cloudflare's Clef-Flash code (Apache-2.0), see `NOTICE.md` and
> `LICENSE-CLEF-APACHE-2.0.txt`. The student head is freshly initialised (no Clef weights in the student). The released Clef-Flash weights
> (`Cloudflare/clef-flash`, Apache-2.0) are only run locally as the *teacher* by `scripts/label_local_clef.py`.

## Layout

| Path | What |
| --- | --- |
| `glint/joint_schema_model.py` | Clef `JointSchemaHead`, `ClefModel`, `encode_record`, `collate_records`, `systemone_answer` (Apache-2.0, unmodified) |
| `glint/model.py` | build student (Qwen3.5 + LoRA + head), `max_pixels` control, save/load checkpoint |
| `glint/data.py` | JSONL loader, teacher normalisation (Workers AI `noul` = p(true) is expanded), augmentation |
| `glint/losses.py` | `α·T²·KL(teacher_T‖student_T) + (1−α)·CE_ls(gold) + β·Brier` (+ box loss, see below) |
| `glint/box.py` | Jev-style `box` questions: `BoxHead`, DFL bins, IoU/GIoU, box loss, metrics, answer formatting |
| `glint/schema.py` | `encode_glint_record`: Clef `encode_record` + box fields (identical output when there are none) |
| `glint/glint_model.py` | `GlintModel(ClefModel)`: joint head + optional box head on one backbone pass |
| `make_synthetic.py` | tiny PIL dataset of iPhone-sized dialog screenshots, ja/en mixed, **DUMMY teacher** |
| `train.py` | training loop (batch 1 + grad accumulation, cosine LR, JSON log lines) |
| `infer.py` | load checkpoint → JSON with per-option probabilities + latency |
| `bench.py` | train step time / inference latency / peak memory for several `max_pixels` |
| `scripts/evaluate.py` | accuracy, NLL, Brier, ECE(15 bins), teacher agreement |
| `scripts/label_workers_ai.py` | replace dummy teacher with Clef via Workers AI (**billable**, dry-run by default) |
| `scripts/label_local_clef.py` | replace dummy teacher with locally-run Clef-Flash (open weights, bf16 on the iGPU, no cost); stores probs + logits + latency/memory stats |
| `scripts/teacher_report.py` | teacher-label accuracy / NLL / ECE vs gold, per-question accuracy, errors, example distributions |
| `tests/` | CPU unit tests (no model download): `python -m pytest tests` |
| `RESULTS.md` | measured numbers on the Windows + ROCm PC |

## Environment (Windows + ROCm, AMD Ryzen AI 9 HX 370 iGPU)

```powershell
$env:HF_HOME = "<HF_CACHE_DIR>"
$py = "<VENV>\Scripts\python.exe"
cd <REPO_DIR>
$env:HF_HUB_OFFLINE = "1"   # models are already cached
# TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 is set by default inside the scripts (faster SDPA, see RESULTS.md).
# To disable: $env:TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL = "0"
```

Never `pip install` anything that pulls in `torch` into `venv-rocm` (it would replace the ROCm build).
Use `pip install --no-deps <pkg>`. No extra packages are required beyond what the venv already has
(`transformers 5.20.2`, `peft 0.21.2`, `safetensors`, `pillow`, `requests`).

## Quick start

```powershell
& $py make_synthetic.py --out data\synthetic --n-train 60 --n-eval 20
& $py train.py --data data\synthetic\train.jsonl --out checkpoints\syn-2b --model Qwen/Qwen3.5-2B --max-pixels 430080 --steps 200
& $py infer.py --ckpt checkpoints\syn-2b --jsonl data\synthetic\eval.jsonl --limit 3
& $py scripts\evaluate.py --ckpt checkpoints\syn-2b --data data\synthetic\eval.jsonl
& $py bench.py --data data\synthetic\eval.jsonl --pixels 430080,1048576
```

Single screenshot:

```powershell
& $py infer.py --ckpt checkpoints\syn-2b --image shot.png --questions questions.json --state '{"app":"MyApp"}'
```

`questions.json` uses the Clef / SystemOne schema:

```json
{"has_error": {"type": "noul", "instructions": "エラーダイアログが表示されているか?"},
 "screen": {"type": "choice", "instructions": "What is the current screen state?",
            "criteria": {"error_dialog": "An error alert is shown", "home": "Normal home screen", "loading": "Loading"}},
 "severity": {"type": "score", "instructions": "How severe?", "criteria": ["none", "minor", "major"]}}
```

### Image token budget (`--max-pixels`)

Qwen3.5 processor: patch 16, merge 2 → 1 token per 32×32 px after resizing to ≤ `max_pixels`.
For a 1179×2556 iPhone portrait screenshot: `430080` → 390 tokens, `1048576` → 987 tokens, `244608` → 220 tokens.
The value is stored in the checkpoint (`glint_config.json`) and can be overridden at inference with `--max-pixels`.

### Main training options

`--temperature 2.0` (KD temperature), `--alpha 0.7` (KD weight when both teacher and gold exist; teacher-only → pure KD,
gold-only → pure CE), `--beta 0.1` (Brier), `--lora-r 32`, `--lr-lora 2e-4`, `--lr-head 5e-4`, `--accum 1`,
`--grad-ckpt` (gradient checkpointing for larger images), `--dtype bf16|fp16|fp32`.

LoRA targets only the language model (`q/k/v/o_proj`, MLP, and Gated-DeltaNet `in_proj_qkv/in_proj_z/out_proj`);
the vision encoder is frozen.

## Data format (JSONL, one record per line)

```json
{"id": "syn-000001",
 "images": ["images/syn-000001.png"],
 "state": {"app": "SampleApp", "locale": "ja-JP", "a11y": ["Alert: 通信エラー", "Button: OK"]},
 "questions": {"screen": {"type": "choice", "instructions": "現在の画面状態は?",
                          "criteria": {"home": "ホーム画面", "error_dialog": "エラーダイアログ", "loading": "読み込み中"}},
               "has_error": {"type": "noul", "instructions": "エラーメッセージが見えているか?"}},
 "teacher": {"screen": {"error_dialog": 0.91, "home": 0.06, "loading": 0.03}, "has_error": 0.97},
 "gold": {"screen": "error_dialog", "has_error": "true"},
 "meta": {"source": "synthetic", "teacher_model": "@cf/cloudflare/clef-flash", "split_group": "SampleApp"}}
```

* `images` paths are relative to the JSONL file. `state` may be any string/JSON (put a11y tree / OCR text here).
* `teacher[qid]` is `{option_id: prob}`; for `noul` a bare float p(true) is accepted (Workers AI response shape).
  SystemOne answer objects (`{"probabilities": ...}` / `{"noul": p}`) are accepted too.
* `gold` is optional; `teacher` is optional. Option ids for `noul` are `"true"/"false"`, for `score` `"0","1",...`.
* **The synthetic data's `teacher` is a DUMMY** (gold + noise, `meta.teacher_is_dummy=true`).

## Box questions

Like Jev 1.14, a question of type `box` asks for one rectangle in the (first) screenshot:

```json
{"primary_button_box": {"type": "box", "instructions": "the primary button in `screen`"},
 "primary_button_box_visible": {"type": "noul", "instructions": "Is the primary button visible?"}}
```

Answer (`infer.py`), edges as fractions of image height (`top`/`bottom`) and width (`left`/`right`) from the top-left,
always `top < bottom`, `left < right`:

```json
{"primary_button_box": {"type": "box", "box": {"top": 0.534, "bottom": 0.589, "left": 0.500, "right": 0.859}, "confidence": 0.83}}
```

There is no "not found" answer: ask presence as a separate `noul` question (convention: `<qid>_visible`).

* **Data.** Box gold comes from ground truth (capture / annotation), never from the teacher; `teacher` entries for box
  questions are ignored and the label scripts do not send box questions to Clef.
  `gold[qid] = {"top": .., "bottom": .., "left": .., "right": .., "present": true}` (0–1; `{"box": {...}}` is accepted too;
  inverted edges are swapped, values clamped). `{"present": false}` skips the box loss for that record, and if a
  `<qid>_visible` noul question exists without gold it gets `"false"` (`"true"` when present).
* **Head.** `BoxHead` reads the same backbone hidden states as the joint head: the question span is pooled and 4 edge
  queries (top/bottom/left/right) cross-attend to the record's tokens; image tokens get a 2D Fourier encoding of their
  patch centre. Each edge outputs logits over `--box-bins 64` bins (DFL/GFL style); the edge is the expectation over
  bin centres `i/(K−1)`, then top/bottom and left/right are sorted. `confidence` is a separate sigmoid head trained to
  predict the IoU of its own box (BCE against the detached IoU); `evaluate.py` also reports the bin max-prob alternative.
* **Loss.** `box = dfl·DFL + l1·L1 + giou·(1−GIoU) + conf·BCE(conf, IoU)` (`--box-dfl 1 --box-l1 5 --box-giou 2 --box-conf 1`),
  added to the total as `--box-weight 1.0 · box`. `--box-head auto` (default) builds the head only when the training data
  contains box questions; `on`/`off` force it. Without box questions nothing changes (same encoding, losses, logs, checkpoint).
* **Eval.** `scripts/evaluate.py` adds a `box` block: `mean_iou`, `iou_ge_0.5`, `iou_ge_0.75`, `mean_confidence`,
  `ece_15bin_conf_vs_iou50` (confidence vs. IoU≥0.5) and `ece_15bin_maxprob_vs_iou50`.
  `bench.py` builds the box head (and adds the box loss) only when its data contains box questions.
* **Synthetic.** `make_synthetic.py --box` adds `primary_button_box` + `primary_button_box_visible` with gold from the drawn
  button rectangle (absent on loading/home). Without `--box` the output is byte-identical to before.

```powershell
& $py make_synthetic.py --out data\synthetic-box --n-train 60 --n-eval 20 --box
& $py train.py --data data\synthetic-box\train.jsonl --out checkpoints\syn-box-2b --steps 200
& $py scripts\evaluate.py --ckpt checkpoints\syn-box-2b --data data\synthetic-box\eval.jsonl
```

## Swapping in real teacher labels

```powershell
# dry run (no requests, no cost): prints request sizes
& $py scripts\label_workers_ai.py data\synthetic\train.jsonl data\synthetic\train.clef.jsonl --model clef-flash
# real (billable) run
$env:CLOUDFLARE_ACCOUNT_ID = "..."; $env:CLOUDFLARE_API_TOKEN = "..."
& $py scripts\label_workers_ai.py data\real\train.jsonl data\real\train.clef.jsonl --model clef-flash --run
```

Image files are re-encoded to JPEG ≤ ~190 KB and sent as base64 data URLs (max 4 images per request).
Alternatively run the open-weight teacher locally (tested on the Windows + ROCm PC, see RESULTS.md; ~3.1 s/image at 390 image tokens, 18.0 GB peak):

```powershell
$env:HF_HUB_OFFLINE = "0"   # first run only: downloads Cloudflare/clef-flash (~19.1 GB) into HF_HOME
& $py scripts\label_local_clef.py data\synthetic\train.jsonl data\synthetic\train.clef.jsonl --max-pixels 430080 --stats runs\clef-label-train-390.json
& $py scripts\label_local_clef.py data\synthetic\eval.jsonl  data\synthetic\eval.clef.jsonl  --max-pixels 430080
& $py scripts\teacher_report.py data\synthetic\train.clef.jsonl data\synthetic\eval.clef.jsonl
& $py train.py --data data\synthetic\train.clef.jsonl --out checkpoints\clef-2b-390 --steps 200 --max-pixels 430080
& $py scripts\evaluate.py --ckpt checkpoints\clef-2b-390 --data data\synthetic\eval.clef.jsonl
```

Output records get `teacher` (`{qid: {option: prob}}`), `teacher_logits`, and `meta.teacher_model/teacher_max_pixels/teacher_image_tokens`,
with `meta.teacher_is_dummy=false`. Options: `--limit N` (smoke test), `--max-pixels` (Clef-Flash release default is
`longest_edge=16777216`, i.e. effectively native resolution, ~2.9k tokens for 1179x2556), `--offload` (accelerate
`device_map=auto` CPU-offload fallback; not needed on that PC, untested).

## Checkpoint layout

```
checkpoints/<run>/adapter/            # PEFT LoRA adapter (adapter_config.json, adapter_model.safetensors)
checkpoints/<run>/head.safetensors    # JointSchemaHead weights
checkpoints/<run>/box_head.safetensors  # BoxHead weights (only when trained with box questions)
checkpoints/<run>/glint_config.json   # base model, max_pixels, dtype, head/LoRA config, train args, loss history
```

`infer.py` merges LoRA into the backbone by default (`--no-merge` to keep it separate).

## Known limitations (Windows + ROCm)

* `flash-linear-attention` needs Triton; `triton-windows` targets NVIDIA only, so the Gated-DeltaNet layers run the reference PyTorch path (slow). Not installed.
* Training is batch 1 (+ `--accum`); `collate_records` supports batching but is untested here.
* Synthetic data ships with a DUMMY teacher (`train.jsonl`/`eval.jsonl`); real Clef-Flash labels are in `*.clef.jsonl`
  (generated by `scripts/label_local_clef.py`; `data/` is git-ignored, so regenerate them on another machine).
* The synthetic task is easy: Clef-Flash is 100% correct on it, so it does not yet tell us much about real iOS screens.