EryriLabs commited on
Commit
b4e5b8b
·
verified ·
1 Parent(s): c3a4009

Card: compare with the leaderboard's specialist rows; Jev and Decider 1 are zero-shot generalists there. LF line endings.

Browse files
Files changed (13) hide show
  1. README.md +115 -115
  2. cache.py +185 -185
  3. client.py +104 -104
  4. config.json +312 -312
  5. configuration_hev.py +47 -47
  6. data.py +216 -216
  7. eval/results.json +222 -222
  8. features.py +345 -345
  9. head.py +119 -119
  10. hydra_torch.py +156 -156
  11. modeling_hev.py +163 -163
  12. prompting.py +118 -118
  13. schema.py +104 -104
README.md CHANGED
@@ -1,116 +1,116 @@
1
- ---
2
- license: apache-2.0
3
- base_model: google/diffusiongemma-26B-A4B-it
4
- language: [en]
5
- library_name: transformers
6
- tags: [diffusion, hydra, state-space-model, system-one, decision-model, calibrated, jev, typed-decisions]
7
- datasets: [LocalLLaMA/typed-decisions, Praveenrajus/jev-bench]
8
- ---
9
-
10
- # Hev: DiffusionGemma with a Hydra decision head
11
-
12
- By Dwain Barnes. First published 2026-09-24.
13
-
14
- ## What it does
15
-
16
- Give Hev a lump of text (a support ticket, an agent log, a security alert, whatever your program is looking at) and a list of typed questions, and it gives you back probabilities rather than prose. "Which team should handle this?" comes back as `billing 0.88, technical 0.12, sales 0.00`. "Is this urgent?" comes back as `0.67`. That is the job TypeSafe's Jev does; Hev does it with open weights you can run yourself.
17
-
18
- There are two parts:
19
-
20
- 1. DiffusionGemma, Google's open text-diffusion model, frozen. I never fine-tuned it. One pass of its encoder reads your text and one pass of its bidirectional denoiser reads a small JSON answer canvas.
21
- 2. A Hydra head I trained on top. Hydra is a bidirectional state-space model, the two-way cousin of Mamba. It reads every token of your text and every answer slot together, in linear time, and turns that into calibrated probabilities. It is an ensemble of 3 heads (seeds 42, 43, 44) whose probabilities are averaged, 44,476,866 parameters in total, and it trains in minutes.
22
-
23
- ## What is new here
24
-
25
- As far as I can tell (checked 2026-09-24), nobody had done any of these three things before:
26
-
27
- - put Hydra on top of a diffusion language model's representations;
28
- - used a state-space model anywhere inside a System-One or Jev-style decision model. Every open clone I found is an autoregressive Qwen or Gemma with LoRA, or a BERT-style encoder with a linear head;
29
- - published an open-weights entry that scores above Jev on the typed-decisions benchmark (specialist setting, fitted on the train split): clearly on Brier, and by a margin within the noise on accuracy, as the results below spell out.
30
-
31
- The idea, the name and the direction are mine. If you build on it, please cite this repo; the commit history is the timestamp.
32
-
33
- ## Results
34
-
35
- Scored on the `test` split of [LocalLLaMA/typed-decisions](https://huggingface.co/datasets/LocalLLaMA/typed-decisions) (400 cases, 2,000 decisions) in the specialist setting (the head is fitted on the train split), using the metrics defined on that dataset's card (see the evaluation notes under Limits). The "zero-shot canvas read" row is DiffusionGemma with no head, reading its own option probabilities off the canvas, which is what the vLLM structured-diffusion mode does.
36
-
37
- | Model | n | acc | soft_acc | macro_f1 | kl | tv | brier | nll | ece | score_mae | within_1 |
38
- |---|---|---|---|---|---|---|---|---|---|---|---|
39
- | DiffusionGemma zero-shot canvas read | 2000 | 0.708 | 0.573 | 0.551 | 1.333 | 0.362 | 0.304 | 2.100 | 0.213 | 0.411 | 0.934 |
40
- | Hev | 2000 | 0.731 | 0.584 | 0.626 | 0.185 | 0.213 | 0.094 | 0.952 | 0.162 | 0.303 | 0.976 |
41
-
42
- For reference, the dataset's own leaderboard lists TypeSafe Jev 1.13.0 in the specialist setting (fitted on the train split) at accuracy 0.727 and Brier 0.148. The dataset card says specialist and generalist numbers are not comparable, so compare Hev only with specialist entries. That accuracy lead is 0.4 points on 2,000 decisions, less than one standard error (1.0 points), so it is a point estimate ahead of Jev rather than a settled result; the Brier lead of 0.054 is well outside noise. Hev's head lifts the frozen backbone's zero-shot accuracy from 0.708 to 0.731 and reaches Brier 0.094.
43
-
44
- ## How to run it
45
-
46
- You need a GPU with 80 GB of memory for the bf16 backbone. The head itself is tiny and runs anywhere; the backbone is the heavy part. Loading with `load_in_4bit=True` is untested: DiffusionGemma's fused expert parameters are not `nn.Linear`, so bitsandbytes leaves most weights in bf16 and the memory saving is small.
47
-
48
- Install:
49
-
50
- ```bash
51
- pip install "transformers>=5.17" accelerate datasets safetensors
52
- ```
53
-
54
- ```python
55
- from transformers import AutoModel
56
-
57
- model = AutoModel.from_pretrained("EryriLabs/DiffusionGemma-26B-A4B-Hev", trust_remote_code=True)
58
- answer = model.decide(
59
- state="Help! My payouts have been failing for 3 days.",
60
- questions={
61
- "department": {"type": "choice", "instructions": "Which team should handle this?",
62
- "criteria": {"billing": "Payments, invoicing, refunds",
63
- "technical": "Bugs, outages, integrations",
64
- "sales": "Pricing, upgrades, new accounts"}},
65
- "urgent": {"type": "noul", "instructions": "This needs attention today."},
66
- "severity": {"type": "score", "instructions": "How bad is the customer impact?",
67
- "criteria": ["none", "minor", "major", "critical"]},
68
- },
69
- )
70
- print(answer["answers"])
71
- ```
72
-
73
- The request and response shapes match Jev's `/v1/systemone` API, so existing client code needs no changes. To serve that endpoint locally, `pip install fastapi uvicorn` and run:
74
-
75
- ```python
76
- import uvicorn
77
- from transformers import AutoModel
78
- from transformers.dynamic_module_utils import get_class_from_dynamic_module
79
-
80
- model = AutoModel.from_pretrained("EryriLabs/DiffusionGemma-26B-A4B-Hev", trust_remote_code=True)
81
- build_app = get_class_from_dynamic_module("client.build_app", "EryriLabs/DiffusionGemma-26B-A4B-Hev")
82
- uvicorn.run(build_app(model), host="127.0.0.1", port=8080)
83
- ```
84
-
85
- The `.py` files in this repo are a flattened copy of the `hev` package and use relative imports, so they cannot be run as scripts.
86
-
87
- ## How it was trained
88
-
89
- - Features: one forward pass per example through the frozen backbone, canvas length 64, slot seed mode `pad`, hidden states from the final encoder and decoder norms, plus the unit-RMS outputs of layers 14 and 22 of the encoder for the prompt rows and of the decoder for the answer rows, so each row carries 8448 features.
90
- - Head: linear projection 8448 to 512, 3 Hydra blocks, pointer readout (each answer slot scored against each option's description span), plus a learned residual on the backbone's own option log-probabilities, plus fitted temperatures per question type for each member (seed 42: [1.339, 1.776, 1.816]; seed 43: [1.401, 1.899, 1.832]; seed 44: [1.39, 1.616, 1.888]).
91
- - Loss: KL to the gold distribution plus 0.1 times Brier. AdamW, cosine schedule, early stopping on validation log loss.
92
- - Data: typed-decisions train split plus samples from 19 jev-bench sources. Dataset licences are as declared by each source on the Hub.
93
- - Hydra is implemented in plain PyTorch in `hydra_torch.py`. Check against the official CUDA kernels: skipped (session 4 reuses session 2's kernel check).
94
-
95
- ## Limits
96
-
97
- - Accuracy on typed-decisions measures agreement with a synthetic teacher, not ground truth. The teacher's own self-agreement is 0.735.
98
- - Version one supports 2 to 64 options per choice question, 2 to 10 score levels, and prompts up to 1,024 tokens. English only. Text only.
99
- - Request size: every question takes a few canvas tokens (its id plus the JSON around the answer slot), so the 64-token answer canvas holds about 8 questions with short ids. A longer request fails with a clear error (HTTP 422 from the server).
100
- - Evaluation notes: `ece` in the table is top-label expected calibration error with 15 equal-width bins, and is not comparable with the ECE column on the dataset card. `acc` counts a hit when the top option matches the card's discrete `label` for each typed-decisions question, so ties in the gold distribution are broken the way the dataset breaks them; `soft_acc` is the gold probability of the top option.
101
- - No GGUF yet. llama.cpp's DiffusionGemma support is still a draft branch and cannot expose the hidden states the head needs, and there is no Hydra kernel in ggml. If that changes I will port it. Until then this ships as safetensors.
102
- - On fastino/fast-decisions, 17 domains the head never saw, it scores 0.575 exact match on the 2,600 single-label tasks against 0.611 for the backbone's own zero-shot read and 0.670 for GLiNER2.5-Decide run the same way, so as a generalist the head is a step back from the backbone; it is a specialist for the schema it was trained on.
103
- - The backbone is Google's, unchanged, Apache-2.0. Everything else here is Apache-2.0 too.
104
-
105
- ## Citation
106
-
107
- ```bibtex
108
- @misc{barnes2026hev,
109
- title = {Hev: A Hydra decision head on frozen DiffusionGemma for calibrated System-One decisions},
110
- author = {Barnes, Dwain},
111
- year = {2026},
112
- url = {https://huggingface.co/EryriLabs/DiffusionGemma-26B-A4B-Hev}
113
- }
114
- ```
115
-
116
  Thanks to Google DeepMind for DiffusionGemma, to Hwang, Lahoti, Dao and Gu for Hydra, to Matt Mastracci for showing that a diffusion model's canvas is a decision engine, and to the LocalLLaMA community for the typed-decisions benchmark.
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: google/diffusiongemma-26B-A4B-it
4
+ language: [en]
5
+ library_name: transformers
6
+ tags: [diffusion, hydra, state-space-model, system-one, decision-model, calibrated, jev, typed-decisions]
7
+ datasets: [LocalLLaMA/typed-decisions, Praveenrajus/jev-bench]
8
+ ---
9
+
10
+ # Hev: DiffusionGemma with a Hydra decision head
11
+
12
+ By Dwain Barnes. First published 2026-09-24.
13
+
14
+ ## What it does
15
+
16
+ Give Hev a lump of text (a support ticket, an agent log, a security alert, whatever your program is looking at) and a list of typed questions, and it gives you back probabilities rather than prose. "Which team should handle this?" comes back as `billing 0.88, technical 0.12, sales 0.00`. "Is this urgent?" comes back as `0.67`. That is the job TypeSafe's Jev does; Hev does it with open weights you can run yourself.
17
+
18
+ There are two parts:
19
+
20
+ 1. DiffusionGemma, Google's open text-diffusion model, frozen. I never fine-tuned it. One pass of its encoder reads your text and one pass of its bidirectional denoiser reads a small JSON answer canvas.
21
+ 2. A Hydra head I trained on top. Hydra is a bidirectional state-space model, the two-way cousin of Mamba. It reads every token of your text and every answer slot together, in linear time, and turns that into calibrated probabilities. It is an ensemble of 3 heads (seeds 42, 43, 44) whose probabilities are averaged, 44,476,866 parameters in total, and it trains in minutes.
22
+
23
+ ## What is new here
24
+
25
+ As far as I can tell (checked 2026-09-24), nobody had done any of these three things before:
26
+
27
+ - put Hydra on top of a diffusion language model's representations;
28
+ - used a state-space model anywhere inside a System-One or Jev-style decision model. Every open clone I found is an autoregressive Qwen or Gemma with LoRA, or a BERT-style encoder with a linear head;
29
+ - published an open-weights specialist that leads the specialist rows of the typed-decisions leaderboard (fitted on the train split, as they are). How it stands next to the zero-shot generalists, Jev included, is spelled out under Results, with the dataset's own warning that the two kinds of number are not comparable.
30
+
31
+ The idea, the name and the direction are mine. If you build on it, please cite this repo; the commit history is the timestamp.
32
+
33
+ ## Results
34
+
35
+ Scored on the `test` split of [LocalLLaMA/typed-decisions](https://huggingface.co/datasets/LocalLLaMA/typed-decisions) (400 cases, 2,000 decisions) in the specialist setting (the head is fitted on the train split), using the metrics defined on that dataset's card (see the evaluation notes under Limits). The "zero-shot canvas read" row is DiffusionGemma with no head, reading its own option probabilities off the canvas, which is what the vLLM structured-diffusion mode does.
36
+
37
+ | Model | n | acc | soft_acc | macro_f1 | kl | tv | brier | nll | ece | score_mae | within_1 |
38
+ |---|---|---|---|---|---|---|---|---|---|---|---|
39
+ | DiffusionGemma zero-shot canvas read | 2000 | 0.708 | 0.573 | 0.551 | 1.333 | 0.362 | 0.304 | 2.100 | 0.213 | 0.411 | 0.934 |
40
+ | Hev | 2000 | 0.731 | 0.584 | 0.626 | 0.185 | 0.213 | 0.094 | 0.952 | 0.162 | 0.303 | 0.976 |
41
+
42
+ Hev is a specialist: its head was fitted on the train split of the same four workflows it is scored on, and it cannot answer a question schema it was not fitted for. On the dataset's leaderboard (read 2026-09-25) the comparable rows are the specialists fitted per workflow: ModernBERT-base at accuracy 0.646 and Brier 0.119, and MiniLM-L6 at 0.587 and 0.143. Hev is 8.5 points above the best of them on accuracy and its Brier is 0.025 lower. The generalist rows, scored zero-shot on workflows they never saw, are TypeSafe Jev 1.13.0 at 0.727 and 0.148 and meraGPT Decider 1 at 0.768 and 0.052. Hev's accuracy is 0.4 points above Jev's (less than one standard error, 1.0 points, on 2,000 decisions) and its Brier is 0.054 lower, but the dataset card is explicit that a specialist number next to a generalist one is not a ranking, and Decider 1 leads both on accuracy and on Brier. Hev's head lifts the frozen backbone's zero-shot accuracy from 0.708 to 0.731 and reaches Brier 0.094.
43
+
44
+ ## How to run it
45
+
46
+ You need a GPU with 80 GB of memory for the bf16 backbone. The head itself is tiny and runs anywhere; the backbone is the heavy part. Loading with `load_in_4bit=True` is untested: DiffusionGemma's fused expert parameters are not `nn.Linear`, so bitsandbytes leaves most weights in bf16 and the memory saving is small.
47
+
48
+ Install:
49
+
50
+ ```bash
51
+ pip install "transformers>=5.17" accelerate datasets safetensors
52
+ ```
53
+
54
+ ```python
55
+ from transformers import AutoModel
56
+
57
+ model = AutoModel.from_pretrained("EryriLabs/DiffusionGemma-26B-A4B-Hev", trust_remote_code=True)
58
+ answer = model.decide(
59
+ state="Help! My payouts have been failing for 3 days.",
60
+ questions={
61
+ "department": {"type": "choice", "instructions": "Which team should handle this?",
62
+ "criteria": {"billing": "Payments, invoicing, refunds",
63
+ "technical": "Bugs, outages, integrations",
64
+ "sales": "Pricing, upgrades, new accounts"}},
65
+ "urgent": {"type": "noul", "instructions": "This needs attention today."},
66
+ "severity": {"type": "score", "instructions": "How bad is the customer impact?",
67
+ "criteria": ["none", "minor", "major", "critical"]},
68
+ },
69
+ )
70
+ print(answer["answers"])
71
+ ```
72
+
73
+ The request and response shapes match Jev's `/v1/systemone` API, so existing client code needs no changes. To serve that endpoint locally, `pip install fastapi uvicorn` and run:
74
+
75
+ ```python
76
+ import uvicorn
77
+ from transformers import AutoModel
78
+ from transformers.dynamic_module_utils import get_class_from_dynamic_module
79
+
80
+ model = AutoModel.from_pretrained("EryriLabs/DiffusionGemma-26B-A4B-Hev", trust_remote_code=True)
81
+ build_app = get_class_from_dynamic_module("client.build_app", "EryriLabs/DiffusionGemma-26B-A4B-Hev")
82
+ uvicorn.run(build_app(model), host="127.0.0.1", port=8080)
83
+ ```
84
+
85
+ The `.py` files in this repo are a flattened copy of the `hev` package and use relative imports, so they cannot be run as scripts.
86
+
87
+ ## How it was trained
88
+
89
+ - Features: one forward pass per example through the frozen backbone, canvas length 64, slot seed mode `pad`, hidden states from the final encoder and decoder norms, plus the unit-RMS outputs of layers 14 and 22 of the encoder for the prompt rows and of the decoder for the answer rows, so each row carries 8448 features.
90
+ - Head: linear projection 8448 to 512, 3 Hydra blocks, pointer readout (each answer slot scored against each option's description span), plus a learned residual on the backbone's own option log-probabilities, plus fitted temperatures per question type for each member (seed 42: [1.339, 1.776, 1.816]; seed 43: [1.401, 1.899, 1.832]; seed 44: [1.39, 1.616, 1.888]).
91
+ - Loss: KL to the gold distribution plus 0.1 times Brier. AdamW, cosine schedule, early stopping on validation log loss.
92
+ - Data: typed-decisions train split plus samples from 19 jev-bench sources. Dataset licences are as declared by each source on the Hub.
93
+ - Hydra is implemented in plain PyTorch in `hydra_torch.py`. Check against the official CUDA kernels: skipped (session 4 reuses session 2's kernel check).
94
+
95
+ ## Limits
96
+
97
+ - Accuracy on typed-decisions measures agreement with a synthetic teacher, not ground truth. The teacher's own self-agreement is 0.735.
98
+ - Version one supports 2 to 64 options per choice question, 2 to 10 score levels, and prompts up to 1,024 tokens. English only. Text only.
99
+ - Request size: every question takes a few canvas tokens (its id plus the JSON around the answer slot), so the 64-token answer canvas holds about 8 questions with short ids. A longer request fails with a clear error (HTTP 422 from the server).
100
+ - Evaluation notes: `ece` in the table is top-label expected calibration error with 15 equal-width bins, and is not comparable with the ECE column on the dataset card. `acc` counts a hit when the top option matches the card's discrete `label` for each typed-decisions question, so ties in the gold distribution are broken the way the dataset breaks them; `soft_acc` is the gold probability of the top option.
101
+ - No GGUF yet. llama.cpp's DiffusionGemma support is still a draft branch and cannot expose the hidden states the head needs, and there is no Hydra kernel in ggml. If that changes I will port it. Until then this ships as safetensors.
102
+ - On fastino/fast-decisions, 17 domains the head never saw, it scores 0.575 exact match on the 2,600 single-label tasks against 0.611 for the backbone's own zero-shot read and 0.670 for GLiNER2.5-Decide run the same way, so as a generalist the head is a step back from the backbone; it is a specialist for the schema it was trained on.
103
+ - The backbone is Google's, unchanged, Apache-2.0. Everything else here is Apache-2.0 too.
104
+
105
+ ## Citation
106
+
107
+ ```bibtex
108
+ @misc{barnes2026hev,
109
+ title = {Hev: A Hydra decision head on frozen DiffusionGemma for calibrated System-One decisions},
110
+ author = {Barnes, Dwain},
111
+ year = {2026},
112
+ url = {https://huggingface.co/EryriLabs/DiffusionGemma-26B-A4B-Hev}
113
+ }
114
+ ```
115
+
116
  Thanks to Google DeepMind for DiffusionGemma, to Hwang, Lahoti, Dao and Gu for Hydra, to Matt Mastracci for showing that a diffusion model's canvas is a decision engine, and to the LocalLLaMA community for the typed-decisions benchmark.
cache.py CHANGED
@@ -1,185 +1,185 @@
1
- """On-disk feature cache: one safetensors file per example plus a JSONL manifest."""
2
- from __future__ import annotations
3
-
4
- import json
5
- from dataclasses import asdict, dataclass, field
6
- from pathlib import Path
7
- from typing import Optional
8
-
9
- import torch
10
- from safetensors.torch import load_file, save_file
11
-
12
- from .schema import TYPE_INDEX
13
-
14
-
15
- @dataclass
16
- class Decision:
17
- qid: str
18
- type: str
19
- slot: int
20
- spans: list[list[int]]
21
- alias: list[int]
22
- prior: list[float]
23
- gold: Optional[list[float]]
24
-
25
-
26
- @dataclass
27
- class Record:
28
- id: str
29
- source: str
30
- path: str
31
- n_prompt: int
32
- n_canvas: int
33
- decisions: list[Decision] = field(default_factory=list)
34
-
35
- def to_json(self) -> str:
36
- return json.dumps(asdict(self))
37
-
38
- @classmethod
39
- def from_json(cls, line: str) -> "Record":
40
- d = json.loads(line)
41
- d["decisions"] = [Decision(**x) for x in d["decisions"]]
42
- return cls(**d)
43
-
44
-
45
- class CacheWriter:
46
- """Writes one safetensors file per example and a JSONL manifest.
47
-
48
- A writer always starts a fresh manifest, so re-running into the same folder does not duplicate records.
49
- """
50
-
51
- def __init__(self, out_dir):
52
- self.dir = Path(out_dir)
53
- self.dir.mkdir(parents=True, exist_ok=True)
54
- self._f = (self.dir / "manifest.jsonl").open("w", encoding="utf-8")
55
-
56
- def write(self, record: Record, feats: torch.Tensor) -> None:
57
- save_file({"feats": feats.detach().to("cpu", torch.float16).contiguous()}, str(self.dir / record.path))
58
- self._f.write(record.to_json() + "\n")
59
- self._f.flush()
60
-
61
- def close(self) -> None:
62
- self._f.close()
63
-
64
-
65
- class FeatureCache:
66
- def __init__(self, cache_dir):
67
- self.dir = Path(cache_dir)
68
- with (self.dir / "manifest.jsonl").open("r", encoding="utf-8") as f:
69
- self.records = [Record.from_json(line) for line in f if line.strip()]
70
-
71
- def __len__(self) -> int:
72
- return len(self.records)
73
-
74
- def __getitem__(self, i: int) -> tuple[Record, torch.Tensor]:
75
- rec = self.records[i]
76
- return rec, load_file(str(self.dir / rec.path))["feats"]
77
-
78
-
79
- @dataclass
80
- class Batch:
81
- """A padded feature batch plus two views of its decisions.
82
-
83
- `decisions` is the per-decision list (metadata, spans, prior, gold) that evaluation, serving and the tests
84
- read. The flat tensors are what the head and the losses compute on: options are numbered decision by
85
- decision in that same order, so one gather, one index_add and one padded log-softmax cover the whole batch
86
- instead of a Python loop issuing hundreds of tiny ops per decision (session 3's CPU-bound trainer).
87
- """
88
- feats: torch.Tensor
89
- seg: torch.Tensor
90
- mask: torch.Tensor
91
- decisions: list[dict]
92
- dec_b: torch.Tensor # (n_dec,) batch row of each decision
93
- dec_slot: torch.Tensor # (n_dec,) answer-slot row of each decision
94
- dec_type: torch.Tensor # (n_dec,) question type id
95
- dec_has_gold: torch.Tensor # (n_dec,) bool
96
- opt_dec: torch.Tensor # (n_opt,) decision index of each option
97
- opt_pos: torch.Tensor # (n_opt,) position of each option within its decision
98
- opt_prior: torch.Tensor # (n_opt,) backbone log-prior per option
99
- opt_gold: torch.Tensor # (n_opt,) gold probability per option, 0 where the decision has no gold
100
- span_rows: torch.Tensor # (n_span_rows,) row index into the padded (B * L) feature rows, option by option
101
- span_opt: torch.Tensor # (n_span_rows,) option index of each span row
102
- span_len: torch.Tensor # (n_opt,) span length per option, float
103
- counts: list[int] # options per decision; plain Python so torch.split never syncs on it
104
- n_gold: int # decisions with gold; plain Python so the losses never sync on it
105
-
106
- _FLAT = ("dec_b", "dec_slot", "dec_type", "dec_has_gold", "opt_dec", "opt_pos", "opt_prior", "opt_gold",
107
- "span_rows", "span_opt", "span_len")
108
-
109
- def to(self, device) -> "Batch":
110
- # spans stay on the CPU: consumers read them back as Python ints, so a device copy would only add a
111
- # device-to-host sync per decision.
112
- return Batch(self.feats.to(device), self.seg.to(device), self.mask.to(device),
113
- [{**d, "prior": d["prior"].to(device),
114
- "gold": None if d["gold"] is None else d["gold"].to(device)} for d in self.decisions],
115
- *(getattr(self, name).to(device) for name in self._FLAT), self.counts, self.n_gold)
116
-
117
-
118
- def _check_record(rec: Record, n_rows: int) -> None:
119
- """Raise a ValueError naming the record and question when a record does not fit its feature rows.
120
-
121
- Rows [0, n_prompt) hold the prompt and [n_prompt, n_prompt + n_canvas) the canvas. Without this check a bad
122
- record would be read silently from padding or from the wrong segment.
123
- """
124
- if n_rows != rec.n_prompt + rec.n_canvas:
125
- raise ValueError(f"record {rec.id!r}: {n_rows} feature rows, but n_prompt {rec.n_prompt} + n_canvas "
126
- f"{rec.n_canvas} = {rec.n_prompt + rec.n_canvas}")
127
- for d in rec.decisions:
128
- where = f"record {rec.id!r}, question {d.qid!r}"
129
- if not rec.n_prompt <= d.slot < n_rows:
130
- raise ValueError(f"{where}: slot {d.slot} is outside the canvas rows [{rec.n_prompt}, {n_rows})")
131
- for s, e in d.spans:
132
- if not 0 <= s < e <= rec.n_prompt:
133
- raise ValueError(f"{where}: option span [{s}, {e}) is empty or outside the prompt rows "
134
- f"[0, {rec.n_prompt})")
135
- if len(d.prior) != len(d.spans):
136
- raise ValueError(f"{where}: {len(d.spans)} option spans but {len(d.prior)} prior values")
137
- if d.gold is not None and len(d.gold) != len(d.spans):
138
- raise ValueError(f"{where}: {len(d.spans)} option spans but {len(d.gold)} gold values")
139
-
140
-
141
- def collate(items: list[tuple[Record, torch.Tensor]]) -> Batch:
142
- lmax = max(f.shape[0] for _, f in items)
143
- dim = items[0][1].shape[1]
144
- feats = torch.zeros(len(items), lmax, dim, dtype=torch.float16)
145
- seg = torch.zeros(len(items), lmax, dtype=torch.long)
146
- mask = torch.zeros(len(items), lmax, dtype=torch.bool)
147
- decisions = []
148
- dec_b, dec_slot, dec_type, dec_has_gold, counts = [], [], [], [], []
149
- opt_dec, opt_pos, opt_prior, opt_gold, span_rows, span_opt, span_len = [], [], [], [], [], [], []
150
- for b, (rec, f) in enumerate(items):
151
- n = f.shape[0]
152
- _check_record(rec, n)
153
- feats[b, :n] = f.to(torch.float16)
154
- mask[b, :n] = True
155
- seg[b, rec.n_prompt:n] = 1
156
- for d in rec.decisions:
157
- i = len(decisions)
158
- decisions.append({
159
- "b": b, "slot": d.slot, "type_id": TYPE_INDEX[d.type], "qid": d.qid, "type": d.type,
160
- "spans": torch.tensor(d.spans, dtype=torch.long),
161
- "prior": torch.tensor(d.prior, dtype=torch.float32),
162
- "gold": None if d.gold is None else torch.tensor(d.gold, dtype=torch.float32),
163
- })
164
- dec_b.append(b)
165
- dec_slot.append(d.slot)
166
- dec_type.append(TYPE_INDEX[d.type])
167
- dec_has_gold.append(d.gold is not None)
168
- counts.append(len(d.spans))
169
- for j, (s, e) in enumerate(d.spans):
170
- o = len(opt_dec)
171
- opt_dec.append(i)
172
- opt_pos.append(j)
173
- opt_prior.append(d.prior[j])
174
- opt_gold.append(0.0 if d.gold is None else d.gold[j])
175
- span_rows.extend(range(b * lmax + s, b * lmax + e))
176
- span_opt.extend([o] * (e - s))
177
- span_len.append(float(e - s))
178
- long, f32 = torch.long, torch.float32
179
- return Batch(feats=feats, seg=seg, mask=mask, decisions=decisions,
180
- dec_b=torch.tensor(dec_b, dtype=long), dec_slot=torch.tensor(dec_slot, dtype=long),
181
- dec_type=torch.tensor(dec_type, dtype=long), dec_has_gold=torch.tensor(dec_has_gold, dtype=torch.bool),
182
- opt_dec=torch.tensor(opt_dec, dtype=long), opt_pos=torch.tensor(opt_pos, dtype=long),
183
- opt_prior=torch.tensor(opt_prior, dtype=f32), opt_gold=torch.tensor(opt_gold, dtype=f32),
184
- span_rows=torch.tensor(span_rows, dtype=long), span_opt=torch.tensor(span_opt, dtype=long),
185
- span_len=torch.tensor(span_len, dtype=f32), counts=counts, n_gold=sum(dec_has_gold))
 
1
+ """On-disk feature cache: one safetensors file per example plus a JSONL manifest."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import asdict, dataclass, field
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import torch
10
+ from safetensors.torch import load_file, save_file
11
+
12
+ from .schema import TYPE_INDEX
13
+
14
+
15
+ @dataclass
16
+ class Decision:
17
+ qid: str
18
+ type: str
19
+ slot: int
20
+ spans: list[list[int]]
21
+ alias: list[int]
22
+ prior: list[float]
23
+ gold: Optional[list[float]]
24
+
25
+
26
+ @dataclass
27
+ class Record:
28
+ id: str
29
+ source: str
30
+ path: str
31
+ n_prompt: int
32
+ n_canvas: int
33
+ decisions: list[Decision] = field(default_factory=list)
34
+
35
+ def to_json(self) -> str:
36
+ return json.dumps(asdict(self))
37
+
38
+ @classmethod
39
+ def from_json(cls, line: str) -> "Record":
40
+ d = json.loads(line)
41
+ d["decisions"] = [Decision(**x) for x in d["decisions"]]
42
+ return cls(**d)
43
+
44
+
45
+ class CacheWriter:
46
+ """Writes one safetensors file per example and a JSONL manifest.
47
+
48
+ A writer always starts a fresh manifest, so re-running into the same folder does not duplicate records.
49
+ """
50
+
51
+ def __init__(self, out_dir):
52
+ self.dir = Path(out_dir)
53
+ self.dir.mkdir(parents=True, exist_ok=True)
54
+ self._f = (self.dir / "manifest.jsonl").open("w", encoding="utf-8")
55
+
56
+ def write(self, record: Record, feats: torch.Tensor) -> None:
57
+ save_file({"feats": feats.detach().to("cpu", torch.float16).contiguous()}, str(self.dir / record.path))
58
+ self._f.write(record.to_json() + "\n")
59
+ self._f.flush()
60
+
61
+ def close(self) -> None:
62
+ self._f.close()
63
+
64
+
65
+ class FeatureCache:
66
+ def __init__(self, cache_dir):
67
+ self.dir = Path(cache_dir)
68
+ with (self.dir / "manifest.jsonl").open("r", encoding="utf-8") as f:
69
+ self.records = [Record.from_json(line) for line in f if line.strip()]
70
+
71
+ def __len__(self) -> int:
72
+ return len(self.records)
73
+
74
+ def __getitem__(self, i: int) -> tuple[Record, torch.Tensor]:
75
+ rec = self.records[i]
76
+ return rec, load_file(str(self.dir / rec.path))["feats"]
77
+
78
+
79
+ @dataclass
80
+ class Batch:
81
+ """A padded feature batch plus two views of its decisions.
82
+
83
+ `decisions` is the per-decision list (metadata, spans, prior, gold) that evaluation, serving and the tests
84
+ read. The flat tensors are what the head and the losses compute on: options are numbered decision by
85
+ decision in that same order, so one gather, one index_add and one padded log-softmax cover the whole batch
86
+ instead of a Python loop issuing hundreds of tiny ops per decision (session 3's CPU-bound trainer).
87
+ """
88
+ feats: torch.Tensor
89
+ seg: torch.Tensor
90
+ mask: torch.Tensor
91
+ decisions: list[dict]
92
+ dec_b: torch.Tensor # (n_dec,) batch row of each decision
93
+ dec_slot: torch.Tensor # (n_dec,) answer-slot row of each decision
94
+ dec_type: torch.Tensor # (n_dec,) question type id
95
+ dec_has_gold: torch.Tensor # (n_dec,) bool
96
+ opt_dec: torch.Tensor # (n_opt,) decision index of each option
97
+ opt_pos: torch.Tensor # (n_opt,) position of each option within its decision
98
+ opt_prior: torch.Tensor # (n_opt,) backbone log-prior per option
99
+ opt_gold: torch.Tensor # (n_opt,) gold probability per option, 0 where the decision has no gold
100
+ span_rows: torch.Tensor # (n_span_rows,) row index into the padded (B * L) feature rows, option by option
101
+ span_opt: torch.Tensor # (n_span_rows,) option index of each span row
102
+ span_len: torch.Tensor # (n_opt,) span length per option, float
103
+ counts: list[int] # options per decision; plain Python so torch.split never syncs on it
104
+ n_gold: int # decisions with gold; plain Python so the losses never sync on it
105
+
106
+ _FLAT = ("dec_b", "dec_slot", "dec_type", "dec_has_gold", "opt_dec", "opt_pos", "opt_prior", "opt_gold",
107
+ "span_rows", "span_opt", "span_len")
108
+
109
+ def to(self, device) -> "Batch":
110
+ # spans stay on the CPU: consumers read them back as Python ints, so a device copy would only add a
111
+ # device-to-host sync per decision.
112
+ return Batch(self.feats.to(device), self.seg.to(device), self.mask.to(device),
113
+ [{**d, "prior": d["prior"].to(device),
114
+ "gold": None if d["gold"] is None else d["gold"].to(device)} for d in self.decisions],
115
+ *(getattr(self, name).to(device) for name in self._FLAT), self.counts, self.n_gold)
116
+
117
+
118
+ def _check_record(rec: Record, n_rows: int) -> None:
119
+ """Raise a ValueError naming the record and question when a record does not fit its feature rows.
120
+
121
+ Rows [0, n_prompt) hold the prompt and [n_prompt, n_prompt + n_canvas) the canvas. Without this check a bad
122
+ record would be read silently from padding or from the wrong segment.
123
+ """
124
+ if n_rows != rec.n_prompt + rec.n_canvas:
125
+ raise ValueError(f"record {rec.id!r}: {n_rows} feature rows, but n_prompt {rec.n_prompt} + n_canvas "
126
+ f"{rec.n_canvas} = {rec.n_prompt + rec.n_canvas}")
127
+ for d in rec.decisions:
128
+ where = f"record {rec.id!r}, question {d.qid!r}"
129
+ if not rec.n_prompt <= d.slot < n_rows:
130
+ raise ValueError(f"{where}: slot {d.slot} is outside the canvas rows [{rec.n_prompt}, {n_rows})")
131
+ for s, e in d.spans:
132
+ if not 0 <= s < e <= rec.n_prompt:
133
+ raise ValueError(f"{where}: option span [{s}, {e}) is empty or outside the prompt rows "
134
+ f"[0, {rec.n_prompt})")
135
+ if len(d.prior) != len(d.spans):
136
+ raise ValueError(f"{where}: {len(d.spans)} option spans but {len(d.prior)} prior values")
137
+ if d.gold is not None and len(d.gold) != len(d.spans):
138
+ raise ValueError(f"{where}: {len(d.spans)} option spans but {len(d.gold)} gold values")
139
+
140
+
141
+ def collate(items: list[tuple[Record, torch.Tensor]]) -> Batch:
142
+ lmax = max(f.shape[0] for _, f in items)
143
+ dim = items[0][1].shape[1]
144
+ feats = torch.zeros(len(items), lmax, dim, dtype=torch.float16)
145
+ seg = torch.zeros(len(items), lmax, dtype=torch.long)
146
+ mask = torch.zeros(len(items), lmax, dtype=torch.bool)
147
+ decisions = []
148
+ dec_b, dec_slot, dec_type, dec_has_gold, counts = [], [], [], [], []
149
+ opt_dec, opt_pos, opt_prior, opt_gold, span_rows, span_opt, span_len = [], [], [], [], [], [], []
150
+ for b, (rec, f) in enumerate(items):
151
+ n = f.shape[0]
152
+ _check_record(rec, n)
153
+ feats[b, :n] = f.to(torch.float16)
154
+ mask[b, :n] = True
155
+ seg[b, rec.n_prompt:n] = 1
156
+ for d in rec.decisions:
157
+ i = len(decisions)
158
+ decisions.append({
159
+ "b": b, "slot": d.slot, "type_id": TYPE_INDEX[d.type], "qid": d.qid, "type": d.type,
160
+ "spans": torch.tensor(d.spans, dtype=torch.long),
161
+ "prior": torch.tensor(d.prior, dtype=torch.float32),
162
+ "gold": None if d.gold is None else torch.tensor(d.gold, dtype=torch.float32),
163
+ })
164
+ dec_b.append(b)
165
+ dec_slot.append(d.slot)
166
+ dec_type.append(TYPE_INDEX[d.type])
167
+ dec_has_gold.append(d.gold is not None)
168
+ counts.append(len(d.spans))
169
+ for j, (s, e) in enumerate(d.spans):
170
+ o = len(opt_dec)
171
+ opt_dec.append(i)
172
+ opt_pos.append(j)
173
+ opt_prior.append(d.prior[j])
174
+ opt_gold.append(0.0 if d.gold is None else d.gold[j])
175
+ span_rows.extend(range(b * lmax + s, b * lmax + e))
176
+ span_opt.extend([o] * (e - s))
177
+ span_len.append(float(e - s))
178
+ long, f32 = torch.long, torch.float32
179
+ return Batch(feats=feats, seg=seg, mask=mask, decisions=decisions,
180
+ dec_b=torch.tensor(dec_b, dtype=long), dec_slot=torch.tensor(dec_slot, dtype=long),
181
+ dec_type=torch.tensor(dec_type, dtype=long), dec_has_gold=torch.tensor(dec_has_gold, dtype=torch.bool),
182
+ opt_dec=torch.tensor(opt_dec, dtype=long), opt_pos=torch.tensor(opt_pos, dtype=long),
183
+ opt_prior=torch.tensor(opt_prior, dtype=f32), opt_gold=torch.tensor(opt_gold, dtype=f32),
184
+ span_rows=torch.tensor(span_rows, dtype=long), span_opt=torch.tensor(span_opt, dtype=long),
185
+ span_len=torch.tensor(span_len, dtype=f32), counts=counts, n_gold=sum(dec_has_gold))
client.py CHANGED
@@ -1,104 +1,104 @@
1
- """Jev-shaped request/response wrapper around HevForDecisions, plus an optional FastAPI server."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import json
6
- import threading
7
-
8
- from .schema import Question
9
-
10
- MAX_QUESTIONS = 32
11
- # A question's canvas entry (its id, the JSON around it and the answer slot) takes 6 or more DiffusionGemma tokens,
12
- # so 4 is a safe floor for the early check below.
13
- MIN_CANVAS_TOKENS_PER_QUESTION = 4
14
-
15
-
16
- def max_questions(model) -> int:
17
- """Upper bound on questions per request for `model`'s answer canvas (32 when the model has no canvas_length).
18
-
19
- Every question needs more than MIN_CANVAS_TOKENS_PER_QUESTION canvas tokens, so this never rejects a request that
20
- would fit. With short ids a 64-token canvas holds about 8 questions (9 one-letter ids, 7 word ids); the exact
21
- check happens when the canvas is built and raises ValueError, which the server turns into a 422.
22
- """
23
- canvas = getattr(getattr(model, "config", None), "canvas_length", None)
24
- if not isinstance(canvas, int) or isinstance(canvas, bool) or canvas <= 0:
25
- return MAX_QUESTIONS
26
- return max(1, min(MAX_QUESTIONS, canvas // MIN_CANVAS_TOKENS_PER_QUESTION))
27
-
28
-
29
- def validate_request(request: dict, max_q: int = MAX_QUESTIONS) -> None:
30
- if not isinstance(request, dict) or "state" not in request:
31
- raise ValueError("request needs a 'state' (string, object or array)")
32
- qs = request.get("questions")
33
- if not isinstance(qs, dict) or not qs:
34
- raise ValueError("request needs a non-empty 'questions' mapping")
35
- for qid, spec in qs.items():
36
- if not isinstance(spec, dict):
37
- raise ValueError(f"question {qid!r} must be an object")
38
- try:
39
- Question.from_jev(qid, spec)
40
- except ValueError as err:
41
- raise ValueError(f"question {qid!r}: {err}") from err
42
- n = len(qs)
43
- if n > max_q:
44
- raise ValueError(f"at most {max_q} questions per request fit this model's answer canvas, got {n}")
45
-
46
-
47
- def _state_text(state) -> str:
48
- return state if isinstance(state, str) else json.dumps(state, ensure_ascii=False)
49
-
50
-
51
- def decide(model, request: dict) -> dict:
52
- validate_request(request, max_questions(model))
53
- resp = model.decide(_state_text(request["state"]), request["questions"])
54
- resp["model"] = request.get("model", "hev")
55
- return resp
56
-
57
-
58
- def build_app(model):
59
- """FastAPI app serving `model` at POST /v1/systemone and GET /health.
60
-
61
- FastAPI runs plain `def` endpoints in a thread pool, and HevForDecisions.decide() is not
62
- thread-safe (it shares one hook-capture buffer on the backbone), so calls to the model are
63
- serialised with a lock: concurrent requests queue and are answered one at a time.
64
- """
65
- from fastapi import FastAPI, HTTPException
66
-
67
- app = FastAPI(title="Hev", version="0.1.0")
68
- model_lock = threading.Lock()
69
-
70
- @app.get("/health")
71
- def health():
72
- return {"ok": True}
73
-
74
- @app.post("/v1/systemone")
75
- def systemone(request: dict):
76
- try:
77
- with model_lock:
78
- return decide(model, request)
79
- except ValueError as err:
80
- raise HTTPException(status_code=422, detail=str(err)) from err
81
-
82
- return app
83
-
84
-
85
- def main():
86
- ap = argparse.ArgumentParser()
87
- ap.add_argument("--repo", required=True)
88
- ap.add_argument("--port", type=int, default=8080)
89
- ap.add_argument("--host", default="127.0.0.1")
90
- ap.add_argument("--load-in-4bit", action="store_true")
91
- ap.add_argument("--request", default=None, help="JSON file: answer once and exit")
92
- args = ap.parse_args()
93
- from .modeling_hev import HevForDecisions
94
- model = HevForDecisions.from_pretrained(args.repo, load_in_4bit=args.load_in_4bit)
95
- if args.request:
96
- with open(args.request, "r", encoding="utf-8") as f:
97
- print(json.dumps(decide(model, json.load(f)), indent=2))
98
- return
99
- import uvicorn
100
- uvicorn.run(build_app(model), host=args.host, port=args.port)
101
-
102
-
103
- if __name__ == "__main__":
104
- main()
 
1
+ """Jev-shaped request/response wrapper around HevForDecisions, plus an optional FastAPI server."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import threading
7
+
8
+ from .schema import Question
9
+
10
+ MAX_QUESTIONS = 32
11
+ # A question's canvas entry (its id, the JSON around it and the answer slot) takes 6 or more DiffusionGemma tokens,
12
+ # so 4 is a safe floor for the early check below.
13
+ MIN_CANVAS_TOKENS_PER_QUESTION = 4
14
+
15
+
16
+ def max_questions(model) -> int:
17
+ """Upper bound on questions per request for `model`'s answer canvas (32 when the model has no canvas_length).
18
+
19
+ Every question needs more than MIN_CANVAS_TOKENS_PER_QUESTION canvas tokens, so this never rejects a request that
20
+ would fit. With short ids a 64-token canvas holds about 8 questions (9 one-letter ids, 7 word ids); the exact
21
+ check happens when the canvas is built and raises ValueError, which the server turns into a 422.
22
+ """
23
+ canvas = getattr(getattr(model, "config", None), "canvas_length", None)
24
+ if not isinstance(canvas, int) or isinstance(canvas, bool) or canvas <= 0:
25
+ return MAX_QUESTIONS
26
+ return max(1, min(MAX_QUESTIONS, canvas // MIN_CANVAS_TOKENS_PER_QUESTION))
27
+
28
+
29
+ def validate_request(request: dict, max_q: int = MAX_QUESTIONS) -> None:
30
+ if not isinstance(request, dict) or "state" not in request:
31
+ raise ValueError("request needs a 'state' (string, object or array)")
32
+ qs = request.get("questions")
33
+ if not isinstance(qs, dict) or not qs:
34
+ raise ValueError("request needs a non-empty 'questions' mapping")
35
+ for qid, spec in qs.items():
36
+ if not isinstance(spec, dict):
37
+ raise ValueError(f"question {qid!r} must be an object")
38
+ try:
39
+ Question.from_jev(qid, spec)
40
+ except ValueError as err:
41
+ raise ValueError(f"question {qid!r}: {err}") from err
42
+ n = len(qs)
43
+ if n > max_q:
44
+ raise ValueError(f"at most {max_q} questions per request fit this model's answer canvas, got {n}")
45
+
46
+
47
+ def _state_text(state) -> str:
48
+ return state if isinstance(state, str) else json.dumps(state, ensure_ascii=False)
49
+
50
+
51
+ def decide(model, request: dict) -> dict:
52
+ validate_request(request, max_questions(model))
53
+ resp = model.decide(_state_text(request["state"]), request["questions"])
54
+ resp["model"] = request.get("model", "hev")
55
+ return resp
56
+
57
+
58
+ def build_app(model):
59
+ """FastAPI app serving `model` at POST /v1/systemone and GET /health.
60
+
61
+ FastAPI runs plain `def` endpoints in a thread pool, and HevForDecisions.decide() is not
62
+ thread-safe (it shares one hook-capture buffer on the backbone), so calls to the model are
63
+ serialised with a lock: concurrent requests queue and are answered one at a time.
64
+ """
65
+ from fastapi import FastAPI, HTTPException
66
+
67
+ app = FastAPI(title="Hev", version="0.1.0")
68
+ model_lock = threading.Lock()
69
+
70
+ @app.get("/health")
71
+ def health():
72
+ return {"ok": True}
73
+
74
+ @app.post("/v1/systemone")
75
+ def systemone(request: dict):
76
+ try:
77
+ with model_lock:
78
+ return decide(model, request)
79
+ except ValueError as err:
80
+ raise HTTPException(status_code=422, detail=str(err)) from err
81
+
82
+ return app
83
+
84
+
85
+ def main():
86
+ ap = argparse.ArgumentParser()
87
+ ap.add_argument("--repo", required=True)
88
+ ap.add_argument("--port", type=int, default=8080)
89
+ ap.add_argument("--host", default="127.0.0.1")
90
+ ap.add_argument("--load-in-4bit", action="store_true")
91
+ ap.add_argument("--request", default=None, help="JSON file: answer once and exit")
92
+ args = ap.parse_args()
93
+ from .modeling_hev import HevForDecisions
94
+ model = HevForDecisions.from_pretrained(args.repo, load_in_4bit=args.load_in_4bit)
95
+ if args.request:
96
+ with open(args.request, "r", encoding="utf-8") as f:
97
+ print(json.dumps(decide(model, json.load(f)), indent=2))
98
+ return
99
+ import uvicorn
100
+ uvicorn.run(build_app(model), host=args.host, port=args.port)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
config.json CHANGED
@@ -1,312 +1,312 @@
1
- {
2
- "aliases": [
3
- "A",
4
- "B",
5
- "C",
6
- "D",
7
- "E",
8
- "F",
9
- "G",
10
- "H",
11
- "I",
12
- "J",
13
- "K",
14
- "L",
15
- "M",
16
- "N",
17
- "O",
18
- "P",
19
- "Q",
20
- "R",
21
- "S",
22
- "T",
23
- "U",
24
- "V",
25
- "W",
26
- "X",
27
- "Y",
28
- "Z",
29
- "a",
30
- "b",
31
- "c",
32
- "d",
33
- "e",
34
- "f",
35
- "g",
36
- "h",
37
- "i",
38
- "j",
39
- "k",
40
- "l",
41
- "m",
42
- "n",
43
- "o",
44
- "p",
45
- "q",
46
- "r",
47
- "s",
48
- "t",
49
- "u",
50
- "v",
51
- "w",
52
- "x",
53
- "y",
54
- "z",
55
- "AA",
56
- "AB",
57
- "AC",
58
- "AD",
59
- "AE",
60
- "AF",
61
- "AG",
62
- "AH",
63
- "AI",
64
- "AJ",
65
- "AK",
66
- "AL"
67
- ],
68
- "auto_map": {
69
- "AutoConfig": "configuration_hev.HevConfig",
70
- "AutoModel": "modeling_hev.HevForDecisions"
71
- },
72
- "backbone_config": {
73
- "_name_or_path": "google/diffusiongemma-26B-A4B-it",
74
- "architectures": [
75
- "DiffusionGemmaForBlockDiffusion"
76
- ],
77
- "boi_token_id": 255999,
78
- "canvas_length": 256,
79
- "chunk_size_feed_forward": 0,
80
- "dtype": "bfloat16",
81
- "eoi_token_id": 258882,
82
- "eos_token_id": [
83
- 1,
84
- 106
85
- ],
86
- "id2label": {
87
- "0": "LABEL_0",
88
- "1": "LABEL_1"
89
- },
90
- "image_token_id": 258880,
91
- "initializer_range": 0.02,
92
- "is_encoder_decoder": false,
93
- "label2id": {
94
- "LABEL_0": 0,
95
- "LABEL_1": 1
96
- },
97
- "model_type": "diffusion_gemma",
98
- "output_attentions": false,
99
- "output_hidden_states": false,
100
- "problem_type": null,
101
- "return_dict": true,
102
- "text_config": {
103
- "_name_or_path": "",
104
- "architectures": null,
105
- "attention_bias": false,
106
- "attention_dropout": 0.0,
107
- "bos_token_id": 2,
108
- "chunk_size_feed_forward": 0,
109
- "dtype": "bfloat16",
110
- "eos_token_id": 1,
111
- "final_logit_softcapping": 30.0,
112
- "head_dim": 256,
113
- "hidden_activation": "gelu_pytorch_tanh",
114
- "hidden_size": 2816,
115
- "id2label": {
116
- "0": "LABEL_0",
117
- "1": "LABEL_1"
118
- },
119
- "initializer_range": 0.02,
120
- "intermediate_size": 2112,
121
- "is_encoder_decoder": false,
122
- "label2id": {
123
- "LABEL_0": 0,
124
- "LABEL_1": 1
125
- },
126
- "layer_types": [
127
- "sliding_attention",
128
- "sliding_attention",
129
- "sliding_attention",
130
- "sliding_attention",
131
- "sliding_attention",
132
- "full_attention",
133
- "sliding_attention",
134
- "sliding_attention",
135
- "sliding_attention",
136
- "sliding_attention",
137
- "sliding_attention",
138
- "full_attention",
139
- "sliding_attention",
140
- "sliding_attention",
141
- "sliding_attention",
142
- "sliding_attention",
143
- "sliding_attention",
144
- "full_attention",
145
- "sliding_attention",
146
- "sliding_attention",
147
- "sliding_attention",
148
- "sliding_attention",
149
- "sliding_attention",
150
- "full_attention",
151
- "sliding_attention",
152
- "sliding_attention",
153
- "sliding_attention",
154
- "sliding_attention",
155
- "sliding_attention",
156
- "full_attention"
157
- ],
158
- "max_position_embeddings": 262144,
159
- "model_type": "diffusion_gemma_text",
160
- "moe_intermediate_size": 704,
161
- "num_attention_heads": 16,
162
- "num_experts": 128,
163
- "num_hidden_layers": 30,
164
- "num_key_value_heads": 8,
165
- "output_attentions": false,
166
- "output_hidden_states": false,
167
- "pad_token_id": 0,
168
- "per_layer_config": {
169
- "05": {
170
- "head_dim": 512,
171
- "num_key_value_heads": 2
172
- },
173
- "11": {
174
- "head_dim": 512,
175
- "num_key_value_heads": 2
176
- },
177
- "17": {
178
- "head_dim": 512,
179
- "num_key_value_heads": 2
180
- },
181
- "23": {
182
- "head_dim": 512,
183
- "num_key_value_heads": 2
184
- },
185
- "29": {
186
- "head_dim": 512,
187
- "num_key_value_heads": 2
188
- }
189
- },
190
- "problem_type": null,
191
- "return_dict": true,
192
- "rms_norm_eps": 1e-06,
193
- "rope_parameters": {
194
- "full_attention": {
195
- "partial_rotary_factor": 0.25,
196
- "rope_theta": 1000000.0,
197
- "rope_type": "proportional"
198
- },
199
- "sliding_attention": {
200
- "rope_theta": 10000.0,
201
- "rope_type": "default"
202
- }
203
- },
204
- "sliding_window": 1024,
205
- "tie_word_embeddings": true,
206
- "top_k_experts": 8,
207
- "use_bidirectional_attention": "vision",
208
- "vocab_size": 262144
209
- },
210
- "tie_word_embeddings": true,
211
- "transformers_version": "5.17.0",
212
- "vision_config": {
213
- "_name_or_path": "",
214
- "architectures": null,
215
- "attention_bias": false,
216
- "attention_dropout": 0.0,
217
- "chunk_size_feed_forward": 0,
218
- "default_output_length": 280,
219
- "dtype": "bfloat16",
220
- "global_head_dim": 72,
221
- "head_dim": 72,
222
- "hidden_activation": "gelu_pytorch_tanh",
223
- "hidden_size": 1152,
224
- "id2label": {
225
- "0": "LABEL_0",
226
- "1": "LABEL_1"
227
- },
228
- "initializer_range": 0.02,
229
- "intermediate_size": 4304,
230
- "is_encoder_decoder": false,
231
- "label2id": {
232
- "LABEL_0": 0,
233
- "LABEL_1": 1
234
- },
235
- "max_position_embeddings": 131072,
236
- "model_type": "gemma4_vision",
237
- "num_attention_heads": 16,
238
- "num_hidden_layers": 27,
239
- "num_key_value_heads": 16,
240
- "output_attentions": false,
241
- "output_hidden_states": false,
242
- "patch_size": 16,
243
- "pooling_kernel_size": 3,
244
- "position_embedding_size": 10240,
245
- "problem_type": null,
246
- "return_dict": true,
247
- "rms_norm_eps": 1e-06,
248
- "rope_parameters": {
249
- "rope_theta": 100.0,
250
- "rope_type": "axial"
251
- },
252
- "standardize": true,
253
- "use_clipped_linears": false
254
- },
255
- "vision_soft_tokens_per_image": 280
256
- },
257
- "backbone_id": "google/diffusiongemma-26B-A4B-it",
258
- "canvas_length": 64,
259
- "decoder_norm": "model.decoder.norm",
260
- "encoder_norm": "model.encoder.language_model.norm",
261
- "extra_layers": {
262
- "decoder": [
263
- "model.decoder.layers.14",
264
- "model.decoder.layers.22"
265
- ],
266
- "encoder": [
267
- "model.encoder.language_model.layers.14",
268
- "model.encoder.language_model.layers.22"
269
- ]
270
- },
271
- "head_config": {
272
- "chunk_size": 64,
273
- "d_backbone": 8448,
274
- "d_conv": 7,
275
- "d_model": 512,
276
- "d_state": 64,
277
- "expand": 2,
278
- "headdim": 64,
279
- "mlp_mult": 2,
280
- "n_blocks": 3,
281
- "n_types": 3
282
- },
283
- "max_prompt_tokens": 1024,
284
- "member_temperatures": [
285
- [
286
- 1.338762879371643,
287
- 1.776159405708313,
288
- 1.816263198852539
289
- ],
290
- [
291
- 1.4009417295455933,
292
- 1.899003267288208,
293
- 1.8320508003234863
294
- ],
295
- [
296
- 1.3898975849151611,
297
- 1.61630380153656,
298
- 1.888205885887146
299
- ]
300
- ],
301
- "model_type": "hev",
302
- "n_heads": 3,
303
- "prompt_format_version": 1,
304
- "seed_mode": "pad",
305
- "slot_token_id": 0,
306
- "temperatures": [
307
- 1.338762879371643,
308
- 1.776159405708313,
309
- 1.816263198852539
310
- ],
311
- "transformers_version": "5.17.0"
312
- }
 
1
+ {
2
+ "aliases": [
3
+ "A",
4
+ "B",
5
+ "C",
6
+ "D",
7
+ "E",
8
+ "F",
9
+ "G",
10
+ "H",
11
+ "I",
12
+ "J",
13
+ "K",
14
+ "L",
15
+ "M",
16
+ "N",
17
+ "O",
18
+ "P",
19
+ "Q",
20
+ "R",
21
+ "S",
22
+ "T",
23
+ "U",
24
+ "V",
25
+ "W",
26
+ "X",
27
+ "Y",
28
+ "Z",
29
+ "a",
30
+ "b",
31
+ "c",
32
+ "d",
33
+ "e",
34
+ "f",
35
+ "g",
36
+ "h",
37
+ "i",
38
+ "j",
39
+ "k",
40
+ "l",
41
+ "m",
42
+ "n",
43
+ "o",
44
+ "p",
45
+ "q",
46
+ "r",
47
+ "s",
48
+ "t",
49
+ "u",
50
+ "v",
51
+ "w",
52
+ "x",
53
+ "y",
54
+ "z",
55
+ "AA",
56
+ "AB",
57
+ "AC",
58
+ "AD",
59
+ "AE",
60
+ "AF",
61
+ "AG",
62
+ "AH",
63
+ "AI",
64
+ "AJ",
65
+ "AK",
66
+ "AL"
67
+ ],
68
+ "auto_map": {
69
+ "AutoConfig": "configuration_hev.HevConfig",
70
+ "AutoModel": "modeling_hev.HevForDecisions"
71
+ },
72
+ "backbone_config": {
73
+ "_name_or_path": "google/diffusiongemma-26B-A4B-it",
74
+ "architectures": [
75
+ "DiffusionGemmaForBlockDiffusion"
76
+ ],
77
+ "boi_token_id": 255999,
78
+ "canvas_length": 256,
79
+ "chunk_size_feed_forward": 0,
80
+ "dtype": "bfloat16",
81
+ "eoi_token_id": 258882,
82
+ "eos_token_id": [
83
+ 1,
84
+ 106
85
+ ],
86
+ "id2label": {
87
+ "0": "LABEL_0",
88
+ "1": "LABEL_1"
89
+ },
90
+ "image_token_id": 258880,
91
+ "initializer_range": 0.02,
92
+ "is_encoder_decoder": false,
93
+ "label2id": {
94
+ "LABEL_0": 0,
95
+ "LABEL_1": 1
96
+ },
97
+ "model_type": "diffusion_gemma",
98
+ "output_attentions": false,
99
+ "output_hidden_states": false,
100
+ "problem_type": null,
101
+ "return_dict": true,
102
+ "text_config": {
103
+ "_name_or_path": "",
104
+ "architectures": null,
105
+ "attention_bias": false,
106
+ "attention_dropout": 0.0,
107
+ "bos_token_id": 2,
108
+ "chunk_size_feed_forward": 0,
109
+ "dtype": "bfloat16",
110
+ "eos_token_id": 1,
111
+ "final_logit_softcapping": 30.0,
112
+ "head_dim": 256,
113
+ "hidden_activation": "gelu_pytorch_tanh",
114
+ "hidden_size": 2816,
115
+ "id2label": {
116
+ "0": "LABEL_0",
117
+ "1": "LABEL_1"
118
+ },
119
+ "initializer_range": 0.02,
120
+ "intermediate_size": 2112,
121
+ "is_encoder_decoder": false,
122
+ "label2id": {
123
+ "LABEL_0": 0,
124
+ "LABEL_1": 1
125
+ },
126
+ "layer_types": [
127
+ "sliding_attention",
128
+ "sliding_attention",
129
+ "sliding_attention",
130
+ "sliding_attention",
131
+ "sliding_attention",
132
+ "full_attention",
133
+ "sliding_attention",
134
+ "sliding_attention",
135
+ "sliding_attention",
136
+ "sliding_attention",
137
+ "sliding_attention",
138
+ "full_attention",
139
+ "sliding_attention",
140
+ "sliding_attention",
141
+ "sliding_attention",
142
+ "sliding_attention",
143
+ "sliding_attention",
144
+ "full_attention",
145
+ "sliding_attention",
146
+ "sliding_attention",
147
+ "sliding_attention",
148
+ "sliding_attention",
149
+ "sliding_attention",
150
+ "full_attention",
151
+ "sliding_attention",
152
+ "sliding_attention",
153
+ "sliding_attention",
154
+ "sliding_attention",
155
+ "sliding_attention",
156
+ "full_attention"
157
+ ],
158
+ "max_position_embeddings": 262144,
159
+ "model_type": "diffusion_gemma_text",
160
+ "moe_intermediate_size": 704,
161
+ "num_attention_heads": 16,
162
+ "num_experts": 128,
163
+ "num_hidden_layers": 30,
164
+ "num_key_value_heads": 8,
165
+ "output_attentions": false,
166
+ "output_hidden_states": false,
167
+ "pad_token_id": 0,
168
+ "per_layer_config": {
169
+ "05": {
170
+ "head_dim": 512,
171
+ "num_key_value_heads": 2
172
+ },
173
+ "11": {
174
+ "head_dim": 512,
175
+ "num_key_value_heads": 2
176
+ },
177
+ "17": {
178
+ "head_dim": 512,
179
+ "num_key_value_heads": 2
180
+ },
181
+ "23": {
182
+ "head_dim": 512,
183
+ "num_key_value_heads": 2
184
+ },
185
+ "29": {
186
+ "head_dim": 512,
187
+ "num_key_value_heads": 2
188
+ }
189
+ },
190
+ "problem_type": null,
191
+ "return_dict": true,
192
+ "rms_norm_eps": 1e-06,
193
+ "rope_parameters": {
194
+ "full_attention": {
195
+ "partial_rotary_factor": 0.25,
196
+ "rope_theta": 1000000.0,
197
+ "rope_type": "proportional"
198
+ },
199
+ "sliding_attention": {
200
+ "rope_theta": 10000.0,
201
+ "rope_type": "default"
202
+ }
203
+ },
204
+ "sliding_window": 1024,
205
+ "tie_word_embeddings": true,
206
+ "top_k_experts": 8,
207
+ "use_bidirectional_attention": "vision",
208
+ "vocab_size": 262144
209
+ },
210
+ "tie_word_embeddings": true,
211
+ "transformers_version": "5.17.0",
212
+ "vision_config": {
213
+ "_name_or_path": "",
214
+ "architectures": null,
215
+ "attention_bias": false,
216
+ "attention_dropout": 0.0,
217
+ "chunk_size_feed_forward": 0,
218
+ "default_output_length": 280,
219
+ "dtype": "bfloat16",
220
+ "global_head_dim": 72,
221
+ "head_dim": 72,
222
+ "hidden_activation": "gelu_pytorch_tanh",
223
+ "hidden_size": 1152,
224
+ "id2label": {
225
+ "0": "LABEL_0",
226
+ "1": "LABEL_1"
227
+ },
228
+ "initializer_range": 0.02,
229
+ "intermediate_size": 4304,
230
+ "is_encoder_decoder": false,
231
+ "label2id": {
232
+ "LABEL_0": 0,
233
+ "LABEL_1": 1
234
+ },
235
+ "max_position_embeddings": 131072,
236
+ "model_type": "gemma4_vision",
237
+ "num_attention_heads": 16,
238
+ "num_hidden_layers": 27,
239
+ "num_key_value_heads": 16,
240
+ "output_attentions": false,
241
+ "output_hidden_states": false,
242
+ "patch_size": 16,
243
+ "pooling_kernel_size": 3,
244
+ "position_embedding_size": 10240,
245
+ "problem_type": null,
246
+ "return_dict": true,
247
+ "rms_norm_eps": 1e-06,
248
+ "rope_parameters": {
249
+ "rope_theta": 100.0,
250
+ "rope_type": "axial"
251
+ },
252
+ "standardize": true,
253
+ "use_clipped_linears": false
254
+ },
255
+ "vision_soft_tokens_per_image": 280
256
+ },
257
+ "backbone_id": "google/diffusiongemma-26B-A4B-it",
258
+ "canvas_length": 64,
259
+ "decoder_norm": "model.decoder.norm",
260
+ "encoder_norm": "model.encoder.language_model.norm",
261
+ "extra_layers": {
262
+ "decoder": [
263
+ "model.decoder.layers.14",
264
+ "model.decoder.layers.22"
265
+ ],
266
+ "encoder": [
267
+ "model.encoder.language_model.layers.14",
268
+ "model.encoder.language_model.layers.22"
269
+ ]
270
+ },
271
+ "head_config": {
272
+ "chunk_size": 64,
273
+ "d_backbone": 8448,
274
+ "d_conv": 7,
275
+ "d_model": 512,
276
+ "d_state": 64,
277
+ "expand": 2,
278
+ "headdim": 64,
279
+ "mlp_mult": 2,
280
+ "n_blocks": 3,
281
+ "n_types": 3
282
+ },
283
+ "max_prompt_tokens": 1024,
284
+ "member_temperatures": [
285
+ [
286
+ 1.338762879371643,
287
+ 1.776159405708313,
288
+ 1.816263198852539
289
+ ],
290
+ [
291
+ 1.4009417295455933,
292
+ 1.899003267288208,
293
+ 1.8320508003234863
294
+ ],
295
+ [
296
+ 1.3898975849151611,
297
+ 1.61630380153656,
298
+ 1.888205885887146
299
+ ]
300
+ ],
301
+ "model_type": "hev",
302
+ "n_heads": 3,
303
+ "prompt_format_version": 1,
304
+ "seed_mode": "pad",
305
+ "slot_token_id": 0,
306
+ "temperatures": [
307
+ 1.338762879371643,
308
+ 1.776159405708313,
309
+ 1.816263198852539
310
+ ],
311
+ "transformers_version": "5.17.0"
312
+ }
configuration_hev.py CHANGED
@@ -1,47 +1,47 @@
1
- """HevConfig: everything needed to rebuild the frozen backbone, the head, and the prompt protocol."""
2
- from __future__ import annotations
3
-
4
- try:
5
- from transformers import PreTrainedConfig
6
- except ImportError: # transformers < 5
7
- from transformers import PretrainedConfig as PreTrainedConfig
8
-
9
- from .head import HevHeadConfig
10
- from .schema import ALIASES
11
-
12
-
13
- class HevConfig(PreTrainedConfig):
14
- model_type = "hev"
15
-
16
- def __init__(self, backbone_id: str = "google/diffusiongemma-26B-A4B-it", backbone_config: dict | None = None,
17
- head_config: dict | None = None, canvas_length: int = 64, seed_mode: str = "pad",
18
- slot_token_id: int = 0, encoder_norm: str = "", decoder_norm: str = "",
19
- max_prompt_tokens: int = 1024, temperatures: list[float] | None = None,
20
- n_heads: int = 1, member_temperatures: list[list[float]] | None = None,
21
- aliases: list[str] | None = None, prompt_format_version: int = 1,
22
- extra_layers: dict | None = None, **kwargs):
23
- self.backbone_id = backbone_id
24
- # Module names of the intermediate encoder/decoder layers whose unit-RMS outputs the features append to
25
- # the final-norm block (hev.features, --layers). Empty for a head trained on the final norms alone.
26
- extra_layers = extra_layers or {}
27
- self.extra_layers = {"encoder": list(extra_layers.get("encoder", [])),
28
- "decoder": list(extra_layers.get("decoder", []))}
29
- self.backbone_config = backbone_config or {}
30
- self.head_config = head_config or HevHeadConfig().to_dict()
31
- self.canvas_length = canvas_length
32
- self.seed_mode = seed_mode
33
- self.slot_token_id = slot_token_id
34
- self.encoder_norm = encoder_norm
35
- self.decoder_norm = decoder_norm
36
- self.max_prompt_tokens = max_prompt_tokens
37
- self.temperatures = temperatures or [1.0, 1.0, 1.0]
38
- self.n_heads = int(n_heads)
39
- # One temperature triple per member; a single-head repo written before this field existed has none, so
40
- # its `temperatures` triple serves member 0.
41
- self.member_temperatures = member_temperatures or [list(self.temperatures)]
42
- self.aliases = aliases or list(ALIASES)
43
- # The hev.prompting format the head was trained with. Configs written before this field existed used 1.
44
- self.prompt_format_version = prompt_format_version
45
- kwargs.setdefault("auto_map", {"AutoConfig": "configuration_hev.HevConfig",
46
- "AutoModel": "modeling_hev.HevForDecisions"})
47
- super().__init__(**kwargs)
 
1
+ """HevConfig: everything needed to rebuild the frozen backbone, the head, and the prompt protocol."""
2
+ from __future__ import annotations
3
+
4
+ try:
5
+ from transformers import PreTrainedConfig
6
+ except ImportError: # transformers < 5
7
+ from transformers import PretrainedConfig as PreTrainedConfig
8
+
9
+ from .head import HevHeadConfig
10
+ from .schema import ALIASES
11
+
12
+
13
+ class HevConfig(PreTrainedConfig):
14
+ model_type = "hev"
15
+
16
+ def __init__(self, backbone_id: str = "google/diffusiongemma-26B-A4B-it", backbone_config: dict | None = None,
17
+ head_config: dict | None = None, canvas_length: int = 64, seed_mode: str = "pad",
18
+ slot_token_id: int = 0, encoder_norm: str = "", decoder_norm: str = "",
19
+ max_prompt_tokens: int = 1024, temperatures: list[float] | None = None,
20
+ n_heads: int = 1, member_temperatures: list[list[float]] | None = None,
21
+ aliases: list[str] | None = None, prompt_format_version: int = 1,
22
+ extra_layers: dict | None = None, **kwargs):
23
+ self.backbone_id = backbone_id
24
+ # Module names of the intermediate encoder/decoder layers whose unit-RMS outputs the features append to
25
+ # the final-norm block (hev.features, --layers). Empty for a head trained on the final norms alone.
26
+ extra_layers = extra_layers or {}
27
+ self.extra_layers = {"encoder": list(extra_layers.get("encoder", [])),
28
+ "decoder": list(extra_layers.get("decoder", []))}
29
+ self.backbone_config = backbone_config or {}
30
+ self.head_config = head_config or HevHeadConfig().to_dict()
31
+ self.canvas_length = canvas_length
32
+ self.seed_mode = seed_mode
33
+ self.slot_token_id = slot_token_id
34
+ self.encoder_norm = encoder_norm
35
+ self.decoder_norm = decoder_norm
36
+ self.max_prompt_tokens = max_prompt_tokens
37
+ self.temperatures = temperatures or [1.0, 1.0, 1.0]
38
+ self.n_heads = int(n_heads)
39
+ # One temperature triple per member; a single-head repo written before this field existed has none, so
40
+ # its `temperatures` triple serves member 0.
41
+ self.member_temperatures = member_temperatures or [list(self.temperatures)]
42
+ self.aliases = aliases or list(ALIASES)
43
+ # The hev.prompting format the head was trained with. Configs written before this field existed used 1.
44
+ self.prompt_format_version = prompt_format_version
45
+ kwargs.setdefault("auto_map", {"AutoConfig": "configuration_hev.HevConfig",
46
+ "AutoModel": "modeling_hev.HevForDecisions"})
47
+ super().__init__(**kwargs)
data.py CHANGED
@@ -1,216 +1,216 @@
1
- """Loaders for LocalLLaMA/typed-decisions and Praveenrajus/jev-bench, plus deterministic manifests."""
2
- from __future__ import annotations
3
-
4
- import hashlib
5
- import json
6
- import math
7
- import random
8
- import re
9
- from collections import defaultdict
10
- from pathlib import Path
11
-
12
- from .schema import Example, Question, align_distribution
13
-
14
- TYPED_DECISIONS = "LocalLLaMA/typed-decisions"
15
- JEV_BENCH = "Praveenrajus/jev-bench"
16
- JEV_BENCH_CONFIGS = [
17
- "arc_challenge", "boolq", "chaosnli", "civil_comments", "fever_evidence", "go_emotions",
18
- "helpsteer2_helpfulness", "helpsteer2_verbosity", "massive", "measuring_hate_speech", "mmlu",
19
- "mnli", "paws", "sms_spam", "sst5", "strategyqa_closed", "strategyqa_grounded", "stsb", "yelp5",
20
- ]
21
- TRUE_LABELS = {"1", "true", "yes"}
22
- FALSE_LABELS = {"0", "false", "no"}
23
-
24
-
25
- def _questions_from_map(qmap: dict) -> list[Question]:
26
- return [Question.from_jev(qid, spec) for qid, spec in qmap.items()]
27
-
28
-
29
- def _label_option(label, q: Question) -> str | None:
30
- """The row's discrete gold label as one of q's option names, or None when it is missing or not an option.
31
-
32
- A boolean names the noul option "true" or "false", and a number its string form (a score level such as "2").
33
- """
34
- if isinstance(label, bool):
35
- label = "true" if label else "false"
36
- if label is None or str(label) not in q.options:
37
- return None
38
- return str(label)
39
-
40
-
41
- def typed_decisions_example(row: dict) -> Example:
42
- questions = _questions_from_map(json.loads(row["questions"]))
43
- gold_raw = json.loads(row["gold"])
44
- gold, labels = {}, {}
45
- for q in questions:
46
- entry = gold_raw[q.qid]
47
- gold[q.qid] = align_distribution(entry["probabilities"], q.options)
48
- # The card's discrete label breaks ties in the distribution; hev.evaluate scores accuracy against it.
49
- label = _label_option(entry.get("label"), q)
50
- if label is not None:
51
- labels[q.qid] = label
52
- meta = {"workflow": row["workflow"]}
53
- if labels:
54
- meta["labels"] = labels
55
- return Example(id=row["id"], source=f"typed-decisions/{row['workflow']}",
56
- state=row["state"], questions=questions, gold=gold, meta=meta)
57
-
58
-
59
- def _positional_distribution(soft, k: int) -> list[float] | None:
60
- if len(soft) != k:
61
- return None
62
- if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in soft):
63
- return None
64
- vals = [float(v) for v in soft]
65
- if any(v < 0 or not math.isfinite(v) for v in vals):
66
- return None
67
- total = sum(vals)
68
- if total <= 0:
69
- return None
70
- return [v / total for v in vals]
71
-
72
-
73
- def jev_bench_example(row: dict, config: str) -> Example:
74
- q = Question.from_jev("q", json.loads(row["question"]))
75
- soft = row.get("soft_label")
76
- if isinstance(soft, str):
77
- soft = json.loads(soft)
78
- dist = None
79
- if isinstance(soft, dict) and set(soft) & set(q.options):
80
- dist = align_distribution(soft, q.options)
81
- elif (isinstance(soft, (int, float)) and not isinstance(soft, bool) and math.isfinite(soft)
82
- and q.type == "noul"):
83
- p = min(1.0, max(0.0, float(soft)))
84
- dist = [p if o == "true" else 1.0 - p for o in q.options]
85
- elif isinstance(soft, (list, tuple)) and q.type == "score":
86
- dist = _positional_distribution(soft, q.k)
87
- if dist is None:
88
- label = str(row["label"])
89
- if q.type == "noul":
90
- norm = label.strip().lower()
91
- if norm in TRUE_LABELS:
92
- label = "true"
93
- elif norm in FALSE_LABELS:
94
- label = "false"
95
- else:
96
- raise ValueError(f"{row['id']}: unrecognised noul label {row['label']!r}")
97
- if label not in q.options:
98
- raise ValueError(f"{row['id']}: label {label!r} not in options {q.options}")
99
- dist = [1.0 if o == label else 0.0 for o in q.options]
100
- meta = row.get("meta") or "{}"
101
- meta = json.loads(meta) if isinstance(meta, str) else dict(meta)
102
- meta["config"] = config
103
- return Example(id=row["id"], source=f"jev-bench/{config}", state=row["state"],
104
- questions=[q], gold={"q": dist}, meta=meta)
105
-
106
-
107
- def split_validation(examples: list[Example], frac: float = 0.1, seed: int = 42):
108
- by_source = defaultdict(list)
109
- for ex in examples:
110
- by_source[ex.source].append(ex)
111
- train, val = [], []
112
- for source in sorted(by_source):
113
- group = sorted(by_source[source], key=lambda e: e.id)
114
- rng = random.Random(f"{seed}:{source}")
115
- rng.shuffle(group)
116
- n_val = max(1, round(len(group) * frac))
117
- val.extend(group[:n_val])
118
- train.extend(group[n_val:])
119
- return train, val
120
-
121
-
122
- def write_manifest(examples: list[Example], path) -> None:
123
- path = Path(path)
124
- path.parent.mkdir(parents=True, exist_ok=True)
125
- with path.open("w", encoding="utf-8") as f:
126
- for ex in examples:
127
- f.write(json.dumps(ex.to_dict(), ensure_ascii=False) + "\n")
128
-
129
-
130
- def read_manifest(path) -> list[Example]:
131
- with Path(path).open("r", encoding="utf-8") as f:
132
- return [Example.from_dict(json.loads(line)) for line in f if line.strip()]
133
-
134
-
135
- def manifest_checksum(path) -> str:
136
- return hashlib.sha256(Path(path).read_bytes()).hexdigest()[:16]
137
-
138
-
139
- def _is_missing_split(err: Exception, split: str) -> bool:
140
- """True for the error `datasets` raises when a config has no such split (the only load error that is skipped)."""
141
- msg = str(err)
142
- return ("Unknown split" in msg or "Bad split" in msg) and split in msg
143
-
144
-
145
- def _dataset_revision(ds) -> str | None:
146
- """The Hub commit a loaded split came from, read off its cache path (.../<config>/<version>/<sha>/...)."""
147
- for f in getattr(ds, "cache_files", None) or []:
148
- name = Path(str(f.get("filename", ""))).parent.name
149
- if re.fullmatch(r"[0-9a-f]{40}", name):
150
- return name
151
- return None
152
-
153
-
154
- def _sample(rows, k: int, seed: int, key: str):
155
- idx = list(range(len(rows)))
156
- random.Random(f"{seed}:{key}").shuffle(idx)
157
- return [rows[i] for i in sorted(idx[:k])]
158
-
159
-
160
- def build_manifests(out_dir, seed: int = 42, per_config: dict | None = None) -> dict:
161
- # Imported here, inside try/except, so neither the tests nor Hub users of the flattened data.py need `datasets`:
162
- # transformers' check_imports skips imports inside try blocks.
163
- try:
164
- from datasets import load_dataset
165
- except ImportError as err:
166
- raise ImportError('building the manifests needs the `datasets` package: pip install datasets '
167
- '(or pip install -e ".[data]")') from err
168
-
169
- per_config = per_config or {"train": 300, "validation": 100, "test": 200}
170
- out = Path(out_dir)
171
- revisions = defaultdict(set)
172
-
173
- def load(repo, config, split):
174
- ds = load_dataset(repo, config, split=split)
175
- sha = _dataset_revision(ds)
176
- if sha:
177
- revisions[repo].add(sha)
178
- return ds
179
-
180
- td_train = [typed_decisions_example(r) for r in load(TYPED_DECISIONS, "all", "train")]
181
- td_test = [typed_decisions_example(r) for r in load(TYPED_DECISIONS, "all", "test")]
182
- train, val = split_validation(td_train, 0.1, seed)
183
- jb_test = []
184
- for config in JEV_BENCH_CONFIGS:
185
- for split, target in (("train", train), ("validation", val), ("test", jb_test)):
186
- try:
187
- rows = load(JEV_BENCH, config, split)
188
- except ValueError as err:
189
- if not _is_missing_split(err, split):
190
- raise
191
- print(f"skip {config}/{split}: {err}", flush=True)
192
- continue
193
- rows = [rows[i] for i in range(len(rows))]
194
- for r in _sample(rows, per_config[split], seed, f"{config}/{split}"):
195
- target.append(jev_bench_example(r, config))
196
- write_manifest(train, out / "train.jsonl")
197
- write_manifest(val, out / "validation.jsonl")
198
- write_manifest(td_test, out / "test_td.jsonl")
199
- write_manifest(jb_test, out / "test_jb.jsonl")
200
- counts = {"train": len(train), "validation": len(val), "test_td": len(td_test), "test_jb": len(jb_test)}
201
- for name in ("train", "validation", "test_td", "test_jb"):
202
- counts[f"{name}_sha"] = manifest_checksum(out / f"{name}.jsonl")
203
- if revisions: # the dataset commit each repo was loaded from (a sorted list if the cache mixed several)
204
- counts["revisions"] = {repo: next(iter(shas)) if len(shas) == 1 else sorted(shas)
205
- for repo, shas in sorted(revisions.items())}
206
- (out / "counts.json").write_text(json.dumps(counts, indent=2))
207
- return counts
208
-
209
-
210
- if __name__ == "__main__":
211
- import argparse
212
- ap = argparse.ArgumentParser()
213
- ap.add_argument("--out", default="data")
214
- ap.add_argument("--seed", type=int, default=42)
215
- args = ap.parse_args()
216
- print(json.dumps(build_manifests(args.out, args.seed), indent=2))
 
1
+ """Loaders for LocalLLaMA/typed-decisions and Praveenrajus/jev-bench, plus deterministic manifests."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import math
7
+ import random
8
+ import re
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+
12
+ from .schema import Example, Question, align_distribution
13
+
14
+ TYPED_DECISIONS = "LocalLLaMA/typed-decisions"
15
+ JEV_BENCH = "Praveenrajus/jev-bench"
16
+ JEV_BENCH_CONFIGS = [
17
+ "arc_challenge", "boolq", "chaosnli", "civil_comments", "fever_evidence", "go_emotions",
18
+ "helpsteer2_helpfulness", "helpsteer2_verbosity", "massive", "measuring_hate_speech", "mmlu",
19
+ "mnli", "paws", "sms_spam", "sst5", "strategyqa_closed", "strategyqa_grounded", "stsb", "yelp5",
20
+ ]
21
+ TRUE_LABELS = {"1", "true", "yes"}
22
+ FALSE_LABELS = {"0", "false", "no"}
23
+
24
+
25
+ def _questions_from_map(qmap: dict) -> list[Question]:
26
+ return [Question.from_jev(qid, spec) for qid, spec in qmap.items()]
27
+
28
+
29
+ def _label_option(label, q: Question) -> str | None:
30
+ """The row's discrete gold label as one of q's option names, or None when it is missing or not an option.
31
+
32
+ A boolean names the noul option "true" or "false", and a number its string form (a score level such as "2").
33
+ """
34
+ if isinstance(label, bool):
35
+ label = "true" if label else "false"
36
+ if label is None or str(label) not in q.options:
37
+ return None
38
+ return str(label)
39
+
40
+
41
+ def typed_decisions_example(row: dict) -> Example:
42
+ questions = _questions_from_map(json.loads(row["questions"]))
43
+ gold_raw = json.loads(row["gold"])
44
+ gold, labels = {}, {}
45
+ for q in questions:
46
+ entry = gold_raw[q.qid]
47
+ gold[q.qid] = align_distribution(entry["probabilities"], q.options)
48
+ # The card's discrete label breaks ties in the distribution; hev.evaluate scores accuracy against it.
49
+ label = _label_option(entry.get("label"), q)
50
+ if label is not None:
51
+ labels[q.qid] = label
52
+ meta = {"workflow": row["workflow"]}
53
+ if labels:
54
+ meta["labels"] = labels
55
+ return Example(id=row["id"], source=f"typed-decisions/{row['workflow']}",
56
+ state=row["state"], questions=questions, gold=gold, meta=meta)
57
+
58
+
59
+ def _positional_distribution(soft, k: int) -> list[float] | None:
60
+ if len(soft) != k:
61
+ return None
62
+ if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in soft):
63
+ return None
64
+ vals = [float(v) for v in soft]
65
+ if any(v < 0 or not math.isfinite(v) for v in vals):
66
+ return None
67
+ total = sum(vals)
68
+ if total <= 0:
69
+ return None
70
+ return [v / total for v in vals]
71
+
72
+
73
+ def jev_bench_example(row: dict, config: str) -> Example:
74
+ q = Question.from_jev("q", json.loads(row["question"]))
75
+ soft = row.get("soft_label")
76
+ if isinstance(soft, str):
77
+ soft = json.loads(soft)
78
+ dist = None
79
+ if isinstance(soft, dict) and set(soft) & set(q.options):
80
+ dist = align_distribution(soft, q.options)
81
+ elif (isinstance(soft, (int, float)) and not isinstance(soft, bool) and math.isfinite(soft)
82
+ and q.type == "noul"):
83
+ p = min(1.0, max(0.0, float(soft)))
84
+ dist = [p if o == "true" else 1.0 - p for o in q.options]
85
+ elif isinstance(soft, (list, tuple)) and q.type == "score":
86
+ dist = _positional_distribution(soft, q.k)
87
+ if dist is None:
88
+ label = str(row["label"])
89
+ if q.type == "noul":
90
+ norm = label.strip().lower()
91
+ if norm in TRUE_LABELS:
92
+ label = "true"
93
+ elif norm in FALSE_LABELS:
94
+ label = "false"
95
+ else:
96
+ raise ValueError(f"{row['id']}: unrecognised noul label {row['label']!r}")
97
+ if label not in q.options:
98
+ raise ValueError(f"{row['id']}: label {label!r} not in options {q.options}")
99
+ dist = [1.0 if o == label else 0.0 for o in q.options]
100
+ meta = row.get("meta") or "{}"
101
+ meta = json.loads(meta) if isinstance(meta, str) else dict(meta)
102
+ meta["config"] = config
103
+ return Example(id=row["id"], source=f"jev-bench/{config}", state=row["state"],
104
+ questions=[q], gold={"q": dist}, meta=meta)
105
+
106
+
107
+ def split_validation(examples: list[Example], frac: float = 0.1, seed: int = 42):
108
+ by_source = defaultdict(list)
109
+ for ex in examples:
110
+ by_source[ex.source].append(ex)
111
+ train, val = [], []
112
+ for source in sorted(by_source):
113
+ group = sorted(by_source[source], key=lambda e: e.id)
114
+ rng = random.Random(f"{seed}:{source}")
115
+ rng.shuffle(group)
116
+ n_val = max(1, round(len(group) * frac))
117
+ val.extend(group[:n_val])
118
+ train.extend(group[n_val:])
119
+ return train, val
120
+
121
+
122
+ def write_manifest(examples: list[Example], path) -> None:
123
+ path = Path(path)
124
+ path.parent.mkdir(parents=True, exist_ok=True)
125
+ with path.open("w", encoding="utf-8") as f:
126
+ for ex in examples:
127
+ f.write(json.dumps(ex.to_dict(), ensure_ascii=False) + "\n")
128
+
129
+
130
+ def read_manifest(path) -> list[Example]:
131
+ with Path(path).open("r", encoding="utf-8") as f:
132
+ return [Example.from_dict(json.loads(line)) for line in f if line.strip()]
133
+
134
+
135
+ def manifest_checksum(path) -> str:
136
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()[:16]
137
+
138
+
139
+ def _is_missing_split(err: Exception, split: str) -> bool:
140
+ """True for the error `datasets` raises when a config has no such split (the only load error that is skipped)."""
141
+ msg = str(err)
142
+ return ("Unknown split" in msg or "Bad split" in msg) and split in msg
143
+
144
+
145
+ def _dataset_revision(ds) -> str | None:
146
+ """The Hub commit a loaded split came from, read off its cache path (.../<config>/<version>/<sha>/...)."""
147
+ for f in getattr(ds, "cache_files", None) or []:
148
+ name = Path(str(f.get("filename", ""))).parent.name
149
+ if re.fullmatch(r"[0-9a-f]{40}", name):
150
+ return name
151
+ return None
152
+
153
+
154
+ def _sample(rows, k: int, seed: int, key: str):
155
+ idx = list(range(len(rows)))
156
+ random.Random(f"{seed}:{key}").shuffle(idx)
157
+ return [rows[i] for i in sorted(idx[:k])]
158
+
159
+
160
+ def build_manifests(out_dir, seed: int = 42, per_config: dict | None = None) -> dict:
161
+ # Imported here, inside try/except, so neither the tests nor Hub users of the flattened data.py need `datasets`:
162
+ # transformers' check_imports skips imports inside try blocks.
163
+ try:
164
+ from datasets import load_dataset
165
+ except ImportError as err:
166
+ raise ImportError('building the manifests needs the `datasets` package: pip install datasets '
167
+ '(or pip install -e ".[data]")') from err
168
+
169
+ per_config = per_config or {"train": 300, "validation": 100, "test": 200}
170
+ out = Path(out_dir)
171
+ revisions = defaultdict(set)
172
+
173
+ def load(repo, config, split):
174
+ ds = load_dataset(repo, config, split=split)
175
+ sha = _dataset_revision(ds)
176
+ if sha:
177
+ revisions[repo].add(sha)
178
+ return ds
179
+
180
+ td_train = [typed_decisions_example(r) for r in load(TYPED_DECISIONS, "all", "train")]
181
+ td_test = [typed_decisions_example(r) for r in load(TYPED_DECISIONS, "all", "test")]
182
+ train, val = split_validation(td_train, 0.1, seed)
183
+ jb_test = []
184
+ for config in JEV_BENCH_CONFIGS:
185
+ for split, target in (("train", train), ("validation", val), ("test", jb_test)):
186
+ try:
187
+ rows = load(JEV_BENCH, config, split)
188
+ except ValueError as err:
189
+ if not _is_missing_split(err, split):
190
+ raise
191
+ print(f"skip {config}/{split}: {err}", flush=True)
192
+ continue
193
+ rows = [rows[i] for i in range(len(rows))]
194
+ for r in _sample(rows, per_config[split], seed, f"{config}/{split}"):
195
+ target.append(jev_bench_example(r, config))
196
+ write_manifest(train, out / "train.jsonl")
197
+ write_manifest(val, out / "validation.jsonl")
198
+ write_manifest(td_test, out / "test_td.jsonl")
199
+ write_manifest(jb_test, out / "test_jb.jsonl")
200
+ counts = {"train": len(train), "validation": len(val), "test_td": len(td_test), "test_jb": len(jb_test)}
201
+ for name in ("train", "validation", "test_td", "test_jb"):
202
+ counts[f"{name}_sha"] = manifest_checksum(out / f"{name}.jsonl")
203
+ if revisions: # the dataset commit each repo was loaded from (a sorted list if the cache mixed several)
204
+ counts["revisions"] = {repo: next(iter(shas)) if len(shas) == 1 else sorted(shas)
205
+ for repo, shas in sorted(revisions.items())}
206
+ (out / "counts.json").write_text(json.dumps(counts, indent=2))
207
+ return counts
208
+
209
+
210
+ if __name__ == "__main__":
211
+ import argparse
212
+ ap = argparse.ArgumentParser()
213
+ ap.add_argument("--out", default="data")
214
+ ap.add_argument("--seed", type=int, default=42)
215
+ args = ap.parse_args()
216
+ print(json.dumps(build_manifests(args.out, args.seed), indent=2))
eval/results.json CHANGED
@@ -1,223 +1,223 @@
1
- {
2
- "preds": "pod_artifacts/yl3x22ceoi4s80/unpacked/eval/test_td.preds.json",
3
- "manifest": "data/test_td.jsonl",
4
- "labelled": 2000,
5
- "rows": {
6
- "DiffusionGemma zero-shot canvas read": {
7
- "n": 2000,
8
- "acc": 0.7075,
9
- "soft_acc": 0.5731857140189666,
10
- "macro_f1": 0.550702638008923,
11
- "kl": 1.3327719231855613,
12
- "tv": 0.3621118097383339,
13
- "brier": 0.30395114563065456,
14
- "nll": 2.100130478809889,
15
- "ece": 0.21345150114595893,
16
- "score_mae": 0.41135865707582425,
17
- "within_1": 0.93375,
18
- "by_type": {
19
- "choice": {
20
- "n": 600,
21
- "acc": 0.6583333333333333,
22
- "soft_acc": 0.529550382418425,
23
- "macro_f1": 0.4580138346822841,
24
- "kl": 1.8344121477390998,
25
- "tv": 0.4134490126261477,
26
- "brier": 0.3653080227268783,
27
- "nll": 2.698206744585924,
28
- "ece": 0.27046276137232783,
29
- "score_mae": null,
30
- "within_1": null
31
- },
32
- "noul": {
33
- "n": 600,
34
- "acc": 0.7716666666666666,
35
- "soft_acc": 0.6983805466666665,
36
- "macro_f1": 0.6222345422638039,
37
- "kl": 1.0794531386915138,
38
- "tv": 0.27516267522582993,
39
- "brier": 0.25676828758281806,
40
- "nll": 1.5331753820440792,
41
- "ece": 0.19590681572755178,
42
- "score_mae": null,
43
- "within_1": null
44
- },
45
- "score": {
46
- "n": 800,
47
- "acc": 0.69625,
48
- "soft_acc": 0.5120160882335887,
49
- "macro_f1": 0.5665703123127417,
50
- "kl": 1.1465308431409578,
51
- "tv": 0.38882075845685227,
52
- "brier": 0.2933206313443617,
53
- "nll": 2.076789602052242,
54
- "ece": 0.1857137256860733,
55
- "score_mae": 0.41135865707582425,
56
- "within_1": 0.93375
57
- }
58
- },
59
- "by_source": {
60
- "typed-decisions/agent_trace_observability": {
61
- "n": 500,
62
- "acc": 0.604,
63
- "soft_acc": 0.4776453329467184,
64
- "macro_f1": 0.5448100563542999,
65
- "kl": 1.3346726647537777,
66
- "tv": 0.4248764365872663,
67
- "brier": 0.36639518814697086,
68
- "nll": 2.2131429376990006,
69
- "ece": 0.2710903854966163,
70
- "score_mae": 0.41654339421197734,
71
- "within_1": 0.965
72
- },
73
- "typed-decisions/customer_service": {
74
- "n": 500,
75
- "acc": 0.772,
76
- "soft_acc": 0.6172040574027722,
77
- "macro_f1": 0.6487457943630335,
78
- "kl": 1.5586844768592238,
79
- "tv": 0.34467774901520876,
80
- "brier": 0.28435340437718454,
81
- "nll": 2.2958391148687096,
82
- "ece": 0.1874517269730567,
83
- "score_mae": 0.3006309953052926,
84
- "within_1": 0.99
85
- },
86
- "typed-decisions/invoice_processing": {
87
- "n": 500,
88
- "acc": 0.758,
89
- "soft_acc": 0.6795901281829838,
90
- "macro_f1": 0.571010468782984,
91
- "kl": 0.7263074853218779,
92
- "tv": 0.24453685503895994,
93
- "brier": 0.20045868313962484,
94
- "nll": 1.301254960986774,
95
- "ece": 0.1528367287516594,
96
- "score_mae": 0.3736864058152724,
97
- "within_1": 0.91
98
- },
99
- "typed-decisions/security_incidents": {
100
- "n": 500,
101
- "acc": 0.696,
102
- "soft_acc": 0.51830333754338,
103
- "macro_f1": 0.43824423253537487,
104
- "kl": 1.7114230658073863,
105
- "tv": 0.4343561983119038,
106
- "brier": 0.36459730685883546,
107
- "nll": 2.590284901685105,
108
- "ece": 0.24981171822547912,
109
- "score_mae": 0.5545738329707549,
110
- "within_1": 0.87
111
- }
112
- }
113
- },
114
- "Hev": {
115
- "n": 2000,
116
- "acc": 0.731,
117
- "soft_acc": 0.5841690466990694,
118
- "macro_f1": 0.6263593100482722,
119
- "kl": 0.18476683805325994,
120
- "tv": 0.21320750403794955,
121
- "brier": 0.09350640922665171,
122
- "nll": 0.9521253933876698,
123
- "ece": 0.16195184084028008,
124
- "score_mae": 0.30327667244408074,
125
- "within_1": 0.97625,
126
- "by_type": {
127
- "choice": {
128
- "n": 600,
129
- "acc": 0.6883333333333334,
130
- "soft_acc": 0.5400114920828492,
131
- "macro_f1": 0.5819579445698401,
132
- "kl": 0.2682545787140505,
133
- "tv": 0.2583168463366746,
134
- "brier": 0.12431620966551991,
135
- "nll": 1.1320491751871322,
136
- "ece": 0.15876342798272766,
137
- "score_mae": null,
138
- "within_1": null
139
- },
140
- "noul": {
141
- "n": 600,
142
- "acc": 0.8133333333333334,
143
- "soft_acc": 0.7151805511737863,
144
- "macro_f1": 0.7030793844723765,
145
- "kl": 0.10688763325700651,
146
- "tv": 0.1591590390598867,
147
- "brier": 0.07606820441416512,
148
- "nll": 0.5606098758757383,
149
- "ece": 0.08742988765239716,
150
- "score_mae": null,
151
- "within_1": null
152
- },
153
- "score": {
154
- "n": 800,
155
- "acc": 0.70125,
156
- "soft_acc": 0.519028584305197,
157
- "macro_f1": 0.602120278339018,
158
- "kl": 0.1805604361548552,
159
- "tv": 0.21991184604745284,
160
- "brier": 0.08347771250686478,
161
- "nll": 1.1108191951720112,
162
- "ece": 0.22084334811195733,
163
- "score_mae": 0.30327667244408074,
164
- "within_1": 0.97625
165
- }
166
- },
167
- "by_source": {
168
- "typed-decisions/agent_trace_observability": {
169
- "n": 500,
170
- "acc": 0.654,
171
- "soft_acc": 0.49528532839939,
172
- "macro_f1": 0.6164904940650149,
173
- "kl": 0.19085320215634394,
174
- "tv": 0.21603136127034667,
175
- "brier": 0.09737799980224329,
176
- "nll": 1.0693234745297415,
177
- "ece": 0.15192090582847595,
178
- "score_mae": 0.2841385978687322,
179
- "within_1": 0.99
180
- },
181
- "typed-decisions/customer_service": {
182
- "n": 500,
183
- "acc": 0.76,
184
- "soft_acc": 0.6186040637716651,
185
- "macro_f1": 0.696363079487989,
186
- "kl": 0.18797709494129541,
187
- "tv": 0.2113354651791742,
188
- "brier": 0.09376893105456086,
189
- "nll": 0.9251317321598946,
190
- "ece": 0.14841172212362289,
191
- "score_mae": 0.24546261819079518,
192
- "within_1": 1.0
193
- },
194
- "typed-decisions/invoice_processing": {
195
- "n": 500,
196
- "acc": 0.786,
197
- "soft_acc": 0.6944667954742908,
198
- "macro_f1": 0.5980108397703063,
199
- "kl": 0.22106904117845244,
200
- "tv": 0.23180358876640095,
201
- "brier": 0.11166511418841754,
202
- "nll": 0.7960165171770665,
203
- "ece": 0.1501800780892372,
204
- "score_mae": 0.39022797410623755,
205
- "within_1": 0.92
206
- },
207
- "typed-decisions/security_incidents": {
208
- "n": 500,
209
- "acc": 0.724,
210
- "soft_acc": 0.5283199991509319,
211
- "macro_f1": 0.5945728268697784,
212
- "kl": 0.13916801393694356,
213
- "tv": 0.19365960093587636,
214
- "brier": 0.07121359186138425,
215
- "nll": 1.0180298496839644,
216
- "ece": 0.2086083108484745,
217
- "score_mae": 0.29327749961055816,
218
- "within_1": 0.995
219
- }
220
- }
221
- }
222
- }
223
  }
 
1
+ {
2
+ "preds": "pod_artifacts/yl3x22ceoi4s80/unpacked/eval/test_td.preds.json",
3
+ "manifest": "data/test_td.jsonl",
4
+ "labelled": 2000,
5
+ "rows": {
6
+ "DiffusionGemma zero-shot canvas read": {
7
+ "n": 2000,
8
+ "acc": 0.7075,
9
+ "soft_acc": 0.5731857140189666,
10
+ "macro_f1": 0.550702638008923,
11
+ "kl": 1.3327719231855613,
12
+ "tv": 0.3621118097383339,
13
+ "brier": 0.30395114563065456,
14
+ "nll": 2.100130478809889,
15
+ "ece": 0.21345150114595893,
16
+ "score_mae": 0.41135865707582425,
17
+ "within_1": 0.93375,
18
+ "by_type": {
19
+ "choice": {
20
+ "n": 600,
21
+ "acc": 0.6583333333333333,
22
+ "soft_acc": 0.529550382418425,
23
+ "macro_f1": 0.4580138346822841,
24
+ "kl": 1.8344121477390998,
25
+ "tv": 0.4134490126261477,
26
+ "brier": 0.3653080227268783,
27
+ "nll": 2.698206744585924,
28
+ "ece": 0.27046276137232783,
29
+ "score_mae": null,
30
+ "within_1": null
31
+ },
32
+ "noul": {
33
+ "n": 600,
34
+ "acc": 0.7716666666666666,
35
+ "soft_acc": 0.6983805466666665,
36
+ "macro_f1": 0.6222345422638039,
37
+ "kl": 1.0794531386915138,
38
+ "tv": 0.27516267522582993,
39
+ "brier": 0.25676828758281806,
40
+ "nll": 1.5331753820440792,
41
+ "ece": 0.19590681572755178,
42
+ "score_mae": null,
43
+ "within_1": null
44
+ },
45
+ "score": {
46
+ "n": 800,
47
+ "acc": 0.69625,
48
+ "soft_acc": 0.5120160882335887,
49
+ "macro_f1": 0.5665703123127417,
50
+ "kl": 1.1465308431409578,
51
+ "tv": 0.38882075845685227,
52
+ "brier": 0.2933206313443617,
53
+ "nll": 2.076789602052242,
54
+ "ece": 0.1857137256860733,
55
+ "score_mae": 0.41135865707582425,
56
+ "within_1": 0.93375
57
+ }
58
+ },
59
+ "by_source": {
60
+ "typed-decisions/agent_trace_observability": {
61
+ "n": 500,
62
+ "acc": 0.604,
63
+ "soft_acc": 0.4776453329467184,
64
+ "macro_f1": 0.5448100563542999,
65
+ "kl": 1.3346726647537777,
66
+ "tv": 0.4248764365872663,
67
+ "brier": 0.36639518814697086,
68
+ "nll": 2.2131429376990006,
69
+ "ece": 0.2710903854966163,
70
+ "score_mae": 0.41654339421197734,
71
+ "within_1": 0.965
72
+ },
73
+ "typed-decisions/customer_service": {
74
+ "n": 500,
75
+ "acc": 0.772,
76
+ "soft_acc": 0.6172040574027722,
77
+ "macro_f1": 0.6487457943630335,
78
+ "kl": 1.5586844768592238,
79
+ "tv": 0.34467774901520876,
80
+ "brier": 0.28435340437718454,
81
+ "nll": 2.2958391148687096,
82
+ "ece": 0.1874517269730567,
83
+ "score_mae": 0.3006309953052926,
84
+ "within_1": 0.99
85
+ },
86
+ "typed-decisions/invoice_processing": {
87
+ "n": 500,
88
+ "acc": 0.758,
89
+ "soft_acc": 0.6795901281829838,
90
+ "macro_f1": 0.571010468782984,
91
+ "kl": 0.7263074853218779,
92
+ "tv": 0.24453685503895994,
93
+ "brier": 0.20045868313962484,
94
+ "nll": 1.301254960986774,
95
+ "ece": 0.1528367287516594,
96
+ "score_mae": 0.3736864058152724,
97
+ "within_1": 0.91
98
+ },
99
+ "typed-decisions/security_incidents": {
100
+ "n": 500,
101
+ "acc": 0.696,
102
+ "soft_acc": 0.51830333754338,
103
+ "macro_f1": 0.43824423253537487,
104
+ "kl": 1.7114230658073863,
105
+ "tv": 0.4343561983119038,
106
+ "brier": 0.36459730685883546,
107
+ "nll": 2.590284901685105,
108
+ "ece": 0.24981171822547912,
109
+ "score_mae": 0.5545738329707549,
110
+ "within_1": 0.87
111
+ }
112
+ }
113
+ },
114
+ "Hev": {
115
+ "n": 2000,
116
+ "acc": 0.731,
117
+ "soft_acc": 0.5841690466990694,
118
+ "macro_f1": 0.6263593100482722,
119
+ "kl": 0.18476683805325994,
120
+ "tv": 0.21320750403794955,
121
+ "brier": 0.09350640922665171,
122
+ "nll": 0.9521253933876698,
123
+ "ece": 0.16195184084028008,
124
+ "score_mae": 0.30327667244408074,
125
+ "within_1": 0.97625,
126
+ "by_type": {
127
+ "choice": {
128
+ "n": 600,
129
+ "acc": 0.6883333333333334,
130
+ "soft_acc": 0.5400114920828492,
131
+ "macro_f1": 0.5819579445698401,
132
+ "kl": 0.2682545787140505,
133
+ "tv": 0.2583168463366746,
134
+ "brier": 0.12431620966551991,
135
+ "nll": 1.1320491751871322,
136
+ "ece": 0.15876342798272766,
137
+ "score_mae": null,
138
+ "within_1": null
139
+ },
140
+ "noul": {
141
+ "n": 600,
142
+ "acc": 0.8133333333333334,
143
+ "soft_acc": 0.7151805511737863,
144
+ "macro_f1": 0.7030793844723765,
145
+ "kl": 0.10688763325700651,
146
+ "tv": 0.1591590390598867,
147
+ "brier": 0.07606820441416512,
148
+ "nll": 0.5606098758757383,
149
+ "ece": 0.08742988765239716,
150
+ "score_mae": null,
151
+ "within_1": null
152
+ },
153
+ "score": {
154
+ "n": 800,
155
+ "acc": 0.70125,
156
+ "soft_acc": 0.519028584305197,
157
+ "macro_f1": 0.602120278339018,
158
+ "kl": 0.1805604361548552,
159
+ "tv": 0.21991184604745284,
160
+ "brier": 0.08347771250686478,
161
+ "nll": 1.1108191951720112,
162
+ "ece": 0.22084334811195733,
163
+ "score_mae": 0.30327667244408074,
164
+ "within_1": 0.97625
165
+ }
166
+ },
167
+ "by_source": {
168
+ "typed-decisions/agent_trace_observability": {
169
+ "n": 500,
170
+ "acc": 0.654,
171
+ "soft_acc": 0.49528532839939,
172
+ "macro_f1": 0.6164904940650149,
173
+ "kl": 0.19085320215634394,
174
+ "tv": 0.21603136127034667,
175
+ "brier": 0.09737799980224329,
176
+ "nll": 1.0693234745297415,
177
+ "ece": 0.15192090582847595,
178
+ "score_mae": 0.2841385978687322,
179
+ "within_1": 0.99
180
+ },
181
+ "typed-decisions/customer_service": {
182
+ "n": 500,
183
+ "acc": 0.76,
184
+ "soft_acc": 0.6186040637716651,
185
+ "macro_f1": 0.696363079487989,
186
+ "kl": 0.18797709494129541,
187
+ "tv": 0.2113354651791742,
188
+ "brier": 0.09376893105456086,
189
+ "nll": 0.9251317321598946,
190
+ "ece": 0.14841172212362289,
191
+ "score_mae": 0.24546261819079518,
192
+ "within_1": 1.0
193
+ },
194
+ "typed-decisions/invoice_processing": {
195
+ "n": 500,
196
+ "acc": 0.786,
197
+ "soft_acc": 0.6944667954742908,
198
+ "macro_f1": 0.5980108397703063,
199
+ "kl": 0.22106904117845244,
200
+ "tv": 0.23180358876640095,
201
+ "brier": 0.11166511418841754,
202
+ "nll": 0.7960165171770665,
203
+ "ece": 0.1501800780892372,
204
+ "score_mae": 0.39022797410623755,
205
+ "within_1": 0.92
206
+ },
207
+ "typed-decisions/security_incidents": {
208
+ "n": 500,
209
+ "acc": 0.724,
210
+ "soft_acc": 0.5283199991509319,
211
+ "macro_f1": 0.5945728268697784,
212
+ "kl": 0.13916801393694356,
213
+ "tv": 0.19365960093587636,
214
+ "brier": 0.07121359186138425,
215
+ "nll": 1.0180298496839644,
216
+ "ece": 0.2086083108484745,
217
+ "score_mae": 0.29327749961055816,
218
+ "within_1": 0.995
219
+ }
220
+ }
221
+ }
222
+ }
223
  }
features.py CHANGED
@@ -1,345 +1,345 @@
1
- """Backbone feature extraction: one forward pass per example, hidden states captured by hooks."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import json
6
- import math
7
- import random
8
- import re
9
- import time
10
- from pathlib import Path
11
-
12
- import torch
13
-
14
- from .cache import CacheWriter, Decision, FeatureCache, Record
15
- from .data import read_manifest
16
- from .prompting import alias_token_ids, canvas_ids, shuffled_order, tokenize_prompt
17
- from .schema import Example, argmax
18
-
19
- MODEL_ID = "google/diffusiongemma-26B-A4B-it"
20
- PROBE_STATE = "A short probe state."
21
- RANDOM_DRAWS = 4
22
- SEED_MODES = ("pad", "alias", "random")
23
- ZERO_SHOT_LOG = "configs/session1_zero_shot.jsonl"
24
-
25
-
26
- class NormCapture:
27
- """Registers forward hooks on final-norm modules (and any modules named in `extra`) and keeps their latest
28
- outputs. With no names given every norm outside the layer stacks is hooked (discovery, see probe)."""
29
-
30
- def __init__(self, model, encoder_name: str | None = None, decoder_name: str | None = None,
31
- extra: tuple[str, ...] | list[str] = ()):
32
- self.outputs: dict[str, torch.Tensor] = {}
33
- self.handles = []
34
- extra = set(extra)
35
- for name, mod in model.named_modules():
36
- if name in extra:
37
- self.handles.append(mod.register_forward_hook(self._hook(name)))
38
- continue
39
- if ".layers." in f".{name}." or "Norm" not in type(mod).__name__:
40
- continue
41
- if encoder_name is not None and name not in (encoder_name, decoder_name):
42
- continue
43
- self.handles.append(mod.register_forward_hook(self._hook(name)))
44
-
45
- def _hook(self, name):
46
- def fn(_m, _i, out):
47
- self.outputs[name] = out[0] if isinstance(out, tuple) else out
48
- return fn
49
-
50
- def remove(self):
51
- for h in self.handles:
52
- h.remove()
53
-
54
-
55
- def unit_rms(x: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
56
- """Scale every row to unit RMS (a parameter-free RMSNorm). Intermediate layers are residual-stream states
57
- of arbitrary magnitude; this keeps them on the final norm's scale and inside float16 for the cache."""
58
- return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + eps)
59
-
60
-
61
- _LAYER_RE = re.compile(r"(.*\blayers)\.(\d+)")
62
-
63
-
64
- def layer_stacks(model) -> dict[str, int]:
65
- """Layer-stack prefixes and their depths, from module names of the form `<prefix>.layers.<n>`."""
66
- stacks: dict[str, int] = {}
67
- for name, _ in model.named_modules():
68
- m = _LAYER_RE.fullmatch(name)
69
- if m:
70
- stacks[m.group(1)] = max(stacks.get(m.group(1), 0), int(m.group(2)) + 1)
71
- return stacks
72
-
73
-
74
- def select_layers(n_layers: int, fracs) -> list[int]:
75
- """Zero-based layer indices at the given depth fractions: 0.5 of 30 layers is layer 14, 0.75 is 22."""
76
- out = set()
77
- for f in fracs:
78
- if not 0.0 < float(f) <= 1.0:
79
- raise ValueError(f"layer fraction must be in (0, 1], got {f}")
80
- out.add(max(0, min(n_layers - 1, math.ceil(float(f) * n_layers) - 1)))
81
- return sorted(out)
82
-
83
-
84
- def resolve_extra_layers(model, encoder_norm: str, decoder_norm: str, fracs) -> dict[str, list[str]]:
85
- """Module names of the extra layers to capture: for each final norm, the layer stack beside it (same parent,
86
- else the shallowest stack below that parent) at `fracs` of its depth."""
87
- stacks = layer_stacks(model)
88
-
89
- def parent(name: str) -> str:
90
- return name.rsplit(".", 1)[0] if "." in name else ""
91
-
92
- def stack_for(norm: str) -> str:
93
- p = parent(norm)
94
- beside = [s for s in stacks if parent(s) == p]
95
- below = [s for s in stacks if p and s.startswith(p + ".")]
96
- cands = beside or below
97
- if not cands:
98
- raise RuntimeError(f"no layer stack found beside {norm!r}; stacks: {sorted(stacks)}")
99
- return min(cands, key=len)
100
-
101
- enc, dec = stack_for(encoder_norm), stack_for(decoder_norm)
102
- if enc == dec:
103
- raise RuntimeError(f"encoder and decoder norms resolve to the same layer stack {enc!r}")
104
- return {"encoder": [f"{enc}.{i}" for i in select_layers(stacks[enc], fracs)],
105
- "decoder": [f"{dec}.{i}" for i in select_layers(stacks[dec], fracs)]}
106
-
107
-
108
- def extra_layer_names(info: dict) -> list[str]:
109
- extra = info.get("extra_layers") or {}
110
- return list(extra.get("encoder", [])) + list(extra.get("decoder", []))
111
-
112
-
113
- def _probe_example() -> Example:
114
- from .schema import Question
115
- q = Question.from_jev("ok", {"type": "noul", "instructions": "Is the probe fine?"})
116
- return Example(id="probe", source="probe", state=PROBE_STATE, questions=[q], gold={"ok": None})
117
-
118
-
119
- @torch.no_grad()
120
- def _forward(model, prompt_ids: list[int], canvas: list[int]):
121
- dev = model.device
122
- input_ids = torch.tensor([prompt_ids], dtype=torch.long, device=dev)
123
- return model(input_ids=input_ids, attention_mask=torch.ones_like(input_ids),
124
- decoder_input_ids=torch.tensor([canvas], dtype=torch.long, device=dev))
125
-
126
-
127
- def probe(model, tok, hidden_size: int) -> dict:
128
- ex = _probe_example()
129
- tp = tokenize_prompt(ex, tok)
130
- for canvas_length in (64, 256):
131
- canvas, _ = canvas_ids(ex, tok, slot_token_id=0, canvas_length=canvas_length)
132
- cap = NormCapture(model)
133
- try:
134
- _forward(model, tp.ids, canvas)
135
- except Exception as err: # noqa: BLE001 - the whole point is to learn which length works
136
- cap.remove()
137
- print(f"canvas_length {canvas_length} failed: {type(err).__name__}: {err}")
138
- continue
139
- cap.remove()
140
- enc = [n for n, o in cap.outputs.items() if o.shape[-2] == len(tp.ids) and o.shape[-1] == hidden_size]
141
- dec = [n for n, o in cap.outputs.items() if o.shape[-2] == canvas_length and o.shape[-1] == hidden_size]
142
- if not enc or not dec:
143
- shapes = {k: tuple(v.shape) for k, v in cap.outputs.items()}
144
- raise RuntimeError(f"could not find final norms; captured {shapes}")
145
- if enc[-1] == dec[-1]:
146
- raise RuntimeError(f"encoder and decoder norms resolved to the same module: {enc[-1]}")
147
- if len(enc) > 1 or len(dec) > 1:
148
- print(f"warning: several final-norm candidates, using the last; encoder {enc}, decoder {dec}")
149
- return {"canvas_length": canvas_length, "encoder_norm": enc[-1], "decoder_norm": dec[-1],
150
- "hidden_size": hidden_size}
151
- raise RuntimeError("neither canvas length 64 nor 256 ran")
152
-
153
-
154
- def _slot_token(seed_mode: str, alias_ids: list[int], rng: random.Random, vocab: int) -> list[int]:
155
- if seed_mode == "pad":
156
- return [0]
157
- if seed_mode == "alias":
158
- return [alias_ids[0]]
159
- if seed_mode == "random":
160
- return [rng.randrange(10, vocab) for _ in range(RANDOM_DRAWS)]
161
- raise ValueError(f"unknown seed_mode {seed_mode}")
162
-
163
-
164
- def _rows(cap: "NormCapture", name: str, n_rows: int, what: str) -> torch.Tensor:
165
- """A captured layer's rows for this forward (batch element 0), checked against the expected row count."""
166
- out = cap.outputs[name][0].float()
167
- if out.shape[0] != n_rows:
168
- raise RuntimeError(f"{name} output has shape {tuple(out.shape)}, expected {n_rows} {what} rows")
169
- return out
170
-
171
-
172
- @torch.no_grad()
173
- def encode_example(model, tok, ex: Example, cap: "NormCapture", info: dict, *, slot_tokens: list[int],
174
- alias_ids: list[int], max_tokens: int = 1024, option_order=None) -> tuple[torch.Tensor, Record]:
175
- """One example through the backbone: features averaged over slot tokens, plus a Record with priors.
176
-
177
- The Record's path is left empty; the caller names the file.
178
- """
179
- canvas_length = info["canvas_length"]
180
- tp = tokenize_prompt(ex, tok, max_tokens=max_tokens, option_order=option_order)
181
- feats_sum, prob_sum, slots = None, None, None
182
- for slot_tok in slot_tokens:
183
- canvas, slots = canvas_ids(ex, tok, slot_token_id=slot_tok, canvas_length=canvas_length)
184
- cap.outputs.clear()
185
- out = _forward(model, tp.ids, canvas)
186
- if out.logits.shape[1] != canvas_length:
187
- raise RuntimeError(f"logits cover {out.logits.shape[1]} positions, expected canvas_length {canvas_length}")
188
- extra = info.get("extra_layers") or {}
189
- wanted = [info["encoder_norm"], info["decoder_norm"], *extra_layer_names(info)]
190
- missing = [n for n in wanted if n not in cap.outputs]
191
- if missing:
192
- raise RuntimeError(f"norm outputs missing after forward: {missing}; captured {sorted(cap.outputs)}")
193
- hp = cap.outputs[info["encoder_norm"]][0].float()
194
- hc = cap.outputs[info["decoder_norm"]][0].float()
195
- if hp.shape[0] != len(tp.ids):
196
- raise RuntimeError(f"encoder norm output has shape {tuple(hp.shape)}, expected {len(tp.ids)} prompt rows")
197
- if hc.shape[0] != canvas_length:
198
- raise RuntimeError(f"decoder norm output has shape {tuple(hc.shape)}, expected canvas_length {canvas_length}")
199
- enc_extra = [unit_rms(_rows(cap, name, len(tp.ids), "prompt")) for name in extra.get("encoder", [])]
200
- dec_extra = [unit_rms(_rows(cap, name, canvas_length, "canvas")) for name in extra.get("decoder", [])]
201
- feats = torch.cat([torch.cat([hp, *enc_extra], dim=1), torch.cat([hc, *dec_extra], dim=1)], dim=0)
202
- probs = torch.softmax(out.logits[0].float(), dim=-1)[slots] # (n_q, vocab)
203
- feats_sum = feats if feats_sum is None else feats_sum + feats
204
- prob_sum = probs if prob_sum is None else prob_sum + probs
205
- feats = feats_sum / len(slot_tokens)
206
- logp = (prob_sum / len(slot_tokens)).clamp_min(1e-12).log()
207
- decisions = []
208
- for qi, q in enumerate(ex.questions):
209
- ids = torch.tensor([alias_ids[a] for a in tp.option_alias[qi]], device=logp.device)
210
- prior = logp[qi, ids]
211
- prior = (prior - torch.logsumexp(prior, dim=0)).tolist()
212
- decisions.append(Decision(qid=q.qid, type=q.type, slot=len(tp.ids) + slots[qi],
213
- spans=[list(s) for s in tp.option_spans[qi]],
214
- alias=list(tp.option_alias[qi]), prior=prior, gold=ex.gold.get(q.qid)))
215
- rec = Record(id=ex.id, source=ex.source, path="", n_prompt=len(tp.ids),
216
- n_canvas=canvas_length, decisions=decisions)
217
- return feats, rec
218
-
219
-
220
- def extract(model, tok, examples: list[Example], out_dir, *, seed_mode: str, canvas_length: int,
221
- alias_ids: list[int], hidden_size: int, max_tokens: int = 1024, shuffle_rng=None,
222
- limit: int | None = None, vocab_size: int = 262144, log_every: int = 50,
223
- info: dict | None = None, layer_fracs=()) -> dict:
224
- """Write a feature cache. With `layer_fracs` (depth fractions, e.g. (0.5, 0.75)) every row also carries the
225
- unit-RMS outputs of the encoder (prompt rows) or decoder (canvas rows) layers at those depths, appended to the
226
- final-norm block, so the feature dim is hidden_size * (1 + len(layers))."""
227
- info = dict(probe(model, tok, hidden_size) if info is None else info)
228
- if info["canvas_length"] != canvas_length:
229
- raise ValueError(f"requested canvas {canvas_length} but probe says {info['canvas_length']}")
230
- if layer_fracs:
231
- info["extra_layers"] = resolve_extra_layers(model, info["encoder_norm"], info["decoder_norm"], layer_fracs)
232
- writer = CacheWriter(out_dir) # opened before the hooks go on, so a folder that cannot be written leaks nothing
233
- cap = NormCapture(model, info["encoder_norm"], info["decoder_norm"], extra=extra_layer_names(info))
234
- rng = random.Random(1234)
235
- n_dec, t0 = 0, time.time()
236
- examples = examples[:limit] if limit else examples
237
- try:
238
- for i, ex in enumerate(examples):
239
- order = shuffled_order(ex, shuffle_rng) if shuffle_rng is not None else None
240
- draws = _slot_token(seed_mode, alias_ids, rng, vocab_size)
241
- feats, rec = encode_example(model, tok, ex, cap, info, slot_tokens=draws, alias_ids=alias_ids,
242
- max_tokens=max_tokens, option_order=order)
243
- rec.path = f"{ex.id.replace('/', '_')}.safetensors"
244
- writer.write(rec, feats)
245
- n_dec += len(rec.decisions)
246
- if (i + 1) % log_every == 0:
247
- print(f"{i + 1}/{len(examples)} examples, {time.time() - t0:.0f}s", flush=True)
248
- finally: # close the manifest and drop the hooks even when an example raises
249
- writer.close()
250
- cap.remove()
251
- n_extra = len((info.get("extra_layers") or {}).get("encoder", []))
252
- summary = {"examples": len(examples), "decisions": n_dec, "seed_mode": seed_mode,
253
- "canvas_length": canvas_length, "seconds": round(time.time() - t0, 1),
254
- "feature_dim": hidden_size * (1 + n_extra), **info}
255
- Path(out_dir, "summary.json").write_text(json.dumps(summary, indent=2))
256
- return summary
257
-
258
-
259
- def zero_shot_accuracy(cache_dir) -> float:
260
- cache = FeatureCache(cache_dir)
261
- hits = total = 0
262
- for rec in cache.records:
263
- for d in rec.decisions:
264
- if d.gold is None:
265
- continue
266
- total += 1
267
- hits += int(argmax(d.prior) == argmax(d.gold))
268
- return hits / max(total, 1)
269
-
270
-
271
- def load_backbone(model_id: str = MODEL_ID, load_in_4bit: bool = False):
272
- from transformers import AutoTokenizer, DiffusionGemmaForBlockDiffusion
273
- kwargs = {"device_map": "auto", "dtype": torch.bfloat16}
274
- if load_in_4bit:
275
- from transformers import BitsAndBytesConfig
276
- kwargs["quantization_config"] = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_compute_dtype=torch.bfloat16)
277
- model = DiffusionGemmaForBlockDiffusion.from_pretrained(model_id, **kwargs).eval()
278
- return model, AutoTokenizer.from_pretrained(model_id)
279
-
280
-
281
- def main():
282
- ap = argparse.ArgumentParser()
283
- sub = ap.add_subparsers(dest="cmd", required=True)
284
- p = sub.add_parser("probe")
285
- p.add_argument("--model", default=MODEL_ID)
286
- e = sub.add_parser("extract")
287
- e.add_argument("--model", default=MODEL_ID)
288
- e.add_argument("--manifest", required=True)
289
- e.add_argument("--out", required=True)
290
- e.add_argument("--seed-mode", default="pad",
291
- help="pad, alias or random, or a comma-separated list of them; a list writes <out>_<mode> per mode")
292
- e.add_argument("--canvas", type=int, default=64)
293
- e.add_argument("--limit", type=int, default=None)
294
- e.add_argument("--shuffle-seed", type=int, default=None, help="shuffle choice options (training augmentation)")
295
- e.add_argument("--load-in-4bit", action="store_true")
296
- e.add_argument("--zero-shot-log", action="store_true", help=f"append each cache's zero-shot accuracy to {ZERO_SHOT_LOG}")
297
- for sp in (p, e):
298
- sp.add_argument("--layers", default=None,
299
- help="depth fractions of extra encoder/decoder layers to append to the features, e.g. 0.5,0.75 "
300
- "(feature dim becomes hidden_size * (1 + count)); probe records their module names")
301
- z = sub.add_parser("zero-shot")
302
- z.add_argument("--cache", required=True)
303
- args = ap.parse_args()
304
- fracs = tuple(float(x) for x in args.layers.split(",")) if getattr(args, "layers", None) else ()
305
- if args.cmd == "zero-shot":
306
- print(json.dumps({"cache": args.cache, "zero_shot_accuracy": zero_shot_accuracy(args.cache)}))
307
- return
308
- if args.cmd == "extract":
309
- modes = args.seed_mode.split(",")
310
- if any(m not in SEED_MODES for m in modes) or len(set(modes)) != len(modes):
311
- e.error(f"--seed-mode takes distinct values from {', '.join(SEED_MODES)}; got {args.seed_mode!r}")
312
- model, tok = load_backbone(args.model, getattr(args, "load_in_4bit", False))
313
- hidden = model.config.text_config.hidden_size
314
- if args.cmd == "probe":
315
- info = probe(model, tok, hidden)
316
- if fracs:
317
- info["extra_layers"] = resolve_extra_layers(model, info["encoder_norm"], info["decoder_norm"], fracs)
318
- info["feature_dim"] = hidden * (1 + len(info["extra_layers"]["encoder"]))
319
- Path("configs").mkdir(exist_ok=True)
320
- Path("configs/backbone.json").write_text(json.dumps({**info, "model_id": args.model}, indent=2))
321
- print(json.dumps(info, indent=2))
322
- return
323
- alias = alias_token_ids(tok, 64)
324
- vocab = getattr(model.config.text_config, "vocab_size", 262144)
325
- examples = read_manifest(args.manifest)
326
- info = probe(model, tok, hidden) if len(modes) > 1 else None # one probe shared by every mode
327
- for mode in modes:
328
- out = f"{args.out}_{mode}" if len(modes) > 1 else args.out
329
- # a fresh rng per mode, so each cache matches what a separate run of that mode would write
330
- rng = random.Random(args.shuffle_seed) if args.shuffle_seed is not None else None
331
- summary = extract(model, tok, examples, out, seed_mode=mode, canvas_length=args.canvas, alias_ids=alias,
332
- hidden_size=hidden, shuffle_rng=rng, limit=args.limit, vocab_size=vocab, info=info,
333
- layer_fracs=fracs)
334
- print(json.dumps(summary, indent=2))
335
- acc = zero_shot_accuracy(out)
336
- print(f"zero_shot {mode} {acc:.4f}", flush=True)
337
- if args.zero_shot_log:
338
- log = Path(ZERO_SHOT_LOG)
339
- log.parent.mkdir(parents=True, exist_ok=True)
340
- with log.open("a", encoding="utf-8") as f:
341
- f.write(json.dumps({"cache": out, "seed_mode": mode, "zero_shot_accuracy": acc}) + "\n")
342
-
343
-
344
- if __name__ == "__main__":
345
- main()
 
1
+ """Backbone feature extraction: one forward pass per example, hidden states captured by hooks."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import random
8
+ import re
9
+ import time
10
+ from pathlib import Path
11
+
12
+ import torch
13
+
14
+ from .cache import CacheWriter, Decision, FeatureCache, Record
15
+ from .data import read_manifest
16
+ from .prompting import alias_token_ids, canvas_ids, shuffled_order, tokenize_prompt
17
+ from .schema import Example, argmax
18
+
19
+ MODEL_ID = "google/diffusiongemma-26B-A4B-it"
20
+ PROBE_STATE = "A short probe state."
21
+ RANDOM_DRAWS = 4
22
+ SEED_MODES = ("pad", "alias", "random")
23
+ ZERO_SHOT_LOG = "configs/session1_zero_shot.jsonl"
24
+
25
+
26
+ class NormCapture:
27
+ """Registers forward hooks on final-norm modules (and any modules named in `extra`) and keeps their latest
28
+ outputs. With no names given every norm outside the layer stacks is hooked (discovery, see probe)."""
29
+
30
+ def __init__(self, model, encoder_name: str | None = None, decoder_name: str | None = None,
31
+ extra: tuple[str, ...] | list[str] = ()):
32
+ self.outputs: dict[str, torch.Tensor] = {}
33
+ self.handles = []
34
+ extra = set(extra)
35
+ for name, mod in model.named_modules():
36
+ if name in extra:
37
+ self.handles.append(mod.register_forward_hook(self._hook(name)))
38
+ continue
39
+ if ".layers." in f".{name}." or "Norm" not in type(mod).__name__:
40
+ continue
41
+ if encoder_name is not None and name not in (encoder_name, decoder_name):
42
+ continue
43
+ self.handles.append(mod.register_forward_hook(self._hook(name)))
44
+
45
+ def _hook(self, name):
46
+ def fn(_m, _i, out):
47
+ self.outputs[name] = out[0] if isinstance(out, tuple) else out
48
+ return fn
49
+
50
+ def remove(self):
51
+ for h in self.handles:
52
+ h.remove()
53
+
54
+
55
+ def unit_rms(x: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
56
+ """Scale every row to unit RMS (a parameter-free RMSNorm). Intermediate layers are residual-stream states
57
+ of arbitrary magnitude; this keeps them on the final norm's scale and inside float16 for the cache."""
58
+ return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + eps)
59
+
60
+
61
+ _LAYER_RE = re.compile(r"(.*\blayers)\.(\d+)")
62
+
63
+
64
+ def layer_stacks(model) -> dict[str, int]:
65
+ """Layer-stack prefixes and their depths, from module names of the form `<prefix>.layers.<n>`."""
66
+ stacks: dict[str, int] = {}
67
+ for name, _ in model.named_modules():
68
+ m = _LAYER_RE.fullmatch(name)
69
+ if m:
70
+ stacks[m.group(1)] = max(stacks.get(m.group(1), 0), int(m.group(2)) + 1)
71
+ return stacks
72
+
73
+
74
+ def select_layers(n_layers: int, fracs) -> list[int]:
75
+ """Zero-based layer indices at the given depth fractions: 0.5 of 30 layers is layer 14, 0.75 is 22."""
76
+ out = set()
77
+ for f in fracs:
78
+ if not 0.0 < float(f) <= 1.0:
79
+ raise ValueError(f"layer fraction must be in (0, 1], got {f}")
80
+ out.add(max(0, min(n_layers - 1, math.ceil(float(f) * n_layers) - 1)))
81
+ return sorted(out)
82
+
83
+
84
+ def resolve_extra_layers(model, encoder_norm: str, decoder_norm: str, fracs) -> dict[str, list[str]]:
85
+ """Module names of the extra layers to capture: for each final norm, the layer stack beside it (same parent,
86
+ else the shallowest stack below that parent) at `fracs` of its depth."""
87
+ stacks = layer_stacks(model)
88
+
89
+ def parent(name: str) -> str:
90
+ return name.rsplit(".", 1)[0] if "." in name else ""
91
+
92
+ def stack_for(norm: str) -> str:
93
+ p = parent(norm)
94
+ beside = [s for s in stacks if parent(s) == p]
95
+ below = [s for s in stacks if p and s.startswith(p + ".")]
96
+ cands = beside or below
97
+ if not cands:
98
+ raise RuntimeError(f"no layer stack found beside {norm!r}; stacks: {sorted(stacks)}")
99
+ return min(cands, key=len)
100
+
101
+ enc, dec = stack_for(encoder_norm), stack_for(decoder_norm)
102
+ if enc == dec:
103
+ raise RuntimeError(f"encoder and decoder norms resolve to the same layer stack {enc!r}")
104
+ return {"encoder": [f"{enc}.{i}" for i in select_layers(stacks[enc], fracs)],
105
+ "decoder": [f"{dec}.{i}" for i in select_layers(stacks[dec], fracs)]}
106
+
107
+
108
+ def extra_layer_names(info: dict) -> list[str]:
109
+ extra = info.get("extra_layers") or {}
110
+ return list(extra.get("encoder", [])) + list(extra.get("decoder", []))
111
+
112
+
113
+ def _probe_example() -> Example:
114
+ from .schema import Question
115
+ q = Question.from_jev("ok", {"type": "noul", "instructions": "Is the probe fine?"})
116
+ return Example(id="probe", source="probe", state=PROBE_STATE, questions=[q], gold={"ok": None})
117
+
118
+
119
+ @torch.no_grad()
120
+ def _forward(model, prompt_ids: list[int], canvas: list[int]):
121
+ dev = model.device
122
+ input_ids = torch.tensor([prompt_ids], dtype=torch.long, device=dev)
123
+ return model(input_ids=input_ids, attention_mask=torch.ones_like(input_ids),
124
+ decoder_input_ids=torch.tensor([canvas], dtype=torch.long, device=dev))
125
+
126
+
127
+ def probe(model, tok, hidden_size: int) -> dict:
128
+ ex = _probe_example()
129
+ tp = tokenize_prompt(ex, tok)
130
+ for canvas_length in (64, 256):
131
+ canvas, _ = canvas_ids(ex, tok, slot_token_id=0, canvas_length=canvas_length)
132
+ cap = NormCapture(model)
133
+ try:
134
+ _forward(model, tp.ids, canvas)
135
+ except Exception as err: # noqa: BLE001 - the whole point is to learn which length works
136
+ cap.remove()
137
+ print(f"canvas_length {canvas_length} failed: {type(err).__name__}: {err}")
138
+ continue
139
+ cap.remove()
140
+ enc = [n for n, o in cap.outputs.items() if o.shape[-2] == len(tp.ids) and o.shape[-1] == hidden_size]
141
+ dec = [n for n, o in cap.outputs.items() if o.shape[-2] == canvas_length and o.shape[-1] == hidden_size]
142
+ if not enc or not dec:
143
+ shapes = {k: tuple(v.shape) for k, v in cap.outputs.items()}
144
+ raise RuntimeError(f"could not find final norms; captured {shapes}")
145
+ if enc[-1] == dec[-1]:
146
+ raise RuntimeError(f"encoder and decoder norms resolved to the same module: {enc[-1]}")
147
+ if len(enc) > 1 or len(dec) > 1:
148
+ print(f"warning: several final-norm candidates, using the last; encoder {enc}, decoder {dec}")
149
+ return {"canvas_length": canvas_length, "encoder_norm": enc[-1], "decoder_norm": dec[-1],
150
+ "hidden_size": hidden_size}
151
+ raise RuntimeError("neither canvas length 64 nor 256 ran")
152
+
153
+
154
+ def _slot_token(seed_mode: str, alias_ids: list[int], rng: random.Random, vocab: int) -> list[int]:
155
+ if seed_mode == "pad":
156
+ return [0]
157
+ if seed_mode == "alias":
158
+ return [alias_ids[0]]
159
+ if seed_mode == "random":
160
+ return [rng.randrange(10, vocab) for _ in range(RANDOM_DRAWS)]
161
+ raise ValueError(f"unknown seed_mode {seed_mode}")
162
+
163
+
164
+ def _rows(cap: "NormCapture", name: str, n_rows: int, what: str) -> torch.Tensor:
165
+ """A captured layer's rows for this forward (batch element 0), checked against the expected row count."""
166
+ out = cap.outputs[name][0].float()
167
+ if out.shape[0] != n_rows:
168
+ raise RuntimeError(f"{name} output has shape {tuple(out.shape)}, expected {n_rows} {what} rows")
169
+ return out
170
+
171
+
172
+ @torch.no_grad()
173
+ def encode_example(model, tok, ex: Example, cap: "NormCapture", info: dict, *, slot_tokens: list[int],
174
+ alias_ids: list[int], max_tokens: int = 1024, option_order=None) -> tuple[torch.Tensor, Record]:
175
+ """One example through the backbone: features averaged over slot tokens, plus a Record with priors.
176
+
177
+ The Record's path is left empty; the caller names the file.
178
+ """
179
+ canvas_length = info["canvas_length"]
180
+ tp = tokenize_prompt(ex, tok, max_tokens=max_tokens, option_order=option_order)
181
+ feats_sum, prob_sum, slots = None, None, None
182
+ for slot_tok in slot_tokens:
183
+ canvas, slots = canvas_ids(ex, tok, slot_token_id=slot_tok, canvas_length=canvas_length)
184
+ cap.outputs.clear()
185
+ out = _forward(model, tp.ids, canvas)
186
+ if out.logits.shape[1] != canvas_length:
187
+ raise RuntimeError(f"logits cover {out.logits.shape[1]} positions, expected canvas_length {canvas_length}")
188
+ extra = info.get("extra_layers") or {}
189
+ wanted = [info["encoder_norm"], info["decoder_norm"], *extra_layer_names(info)]
190
+ missing = [n for n in wanted if n not in cap.outputs]
191
+ if missing:
192
+ raise RuntimeError(f"norm outputs missing after forward: {missing}; captured {sorted(cap.outputs)}")
193
+ hp = cap.outputs[info["encoder_norm"]][0].float()
194
+ hc = cap.outputs[info["decoder_norm"]][0].float()
195
+ if hp.shape[0] != len(tp.ids):
196
+ raise RuntimeError(f"encoder norm output has shape {tuple(hp.shape)}, expected {len(tp.ids)} prompt rows")
197
+ if hc.shape[0] != canvas_length:
198
+ raise RuntimeError(f"decoder norm output has shape {tuple(hc.shape)}, expected canvas_length {canvas_length}")
199
+ enc_extra = [unit_rms(_rows(cap, name, len(tp.ids), "prompt")) for name in extra.get("encoder", [])]
200
+ dec_extra = [unit_rms(_rows(cap, name, canvas_length, "canvas")) for name in extra.get("decoder", [])]
201
+ feats = torch.cat([torch.cat([hp, *enc_extra], dim=1), torch.cat([hc, *dec_extra], dim=1)], dim=0)
202
+ probs = torch.softmax(out.logits[0].float(), dim=-1)[slots] # (n_q, vocab)
203
+ feats_sum = feats if feats_sum is None else feats_sum + feats
204
+ prob_sum = probs if prob_sum is None else prob_sum + probs
205
+ feats = feats_sum / len(slot_tokens)
206
+ logp = (prob_sum / len(slot_tokens)).clamp_min(1e-12).log()
207
+ decisions = []
208
+ for qi, q in enumerate(ex.questions):
209
+ ids = torch.tensor([alias_ids[a] for a in tp.option_alias[qi]], device=logp.device)
210
+ prior = logp[qi, ids]
211
+ prior = (prior - torch.logsumexp(prior, dim=0)).tolist()
212
+ decisions.append(Decision(qid=q.qid, type=q.type, slot=len(tp.ids) + slots[qi],
213
+ spans=[list(s) for s in tp.option_spans[qi]],
214
+ alias=list(tp.option_alias[qi]), prior=prior, gold=ex.gold.get(q.qid)))
215
+ rec = Record(id=ex.id, source=ex.source, path="", n_prompt=len(tp.ids),
216
+ n_canvas=canvas_length, decisions=decisions)
217
+ return feats, rec
218
+
219
+
220
+ def extract(model, tok, examples: list[Example], out_dir, *, seed_mode: str, canvas_length: int,
221
+ alias_ids: list[int], hidden_size: int, max_tokens: int = 1024, shuffle_rng=None,
222
+ limit: int | None = None, vocab_size: int = 262144, log_every: int = 50,
223
+ info: dict | None = None, layer_fracs=()) -> dict:
224
+ """Write a feature cache. With `layer_fracs` (depth fractions, e.g. (0.5, 0.75)) every row also carries the
225
+ unit-RMS outputs of the encoder (prompt rows) or decoder (canvas rows) layers at those depths, appended to the
226
+ final-norm block, so the feature dim is hidden_size * (1 + len(layers))."""
227
+ info = dict(probe(model, tok, hidden_size) if info is None else info)
228
+ if info["canvas_length"] != canvas_length:
229
+ raise ValueError(f"requested canvas {canvas_length} but probe says {info['canvas_length']}")
230
+ if layer_fracs:
231
+ info["extra_layers"] = resolve_extra_layers(model, info["encoder_norm"], info["decoder_norm"], layer_fracs)
232
+ writer = CacheWriter(out_dir) # opened before the hooks go on, so a folder that cannot be written leaks nothing
233
+ cap = NormCapture(model, info["encoder_norm"], info["decoder_norm"], extra=extra_layer_names(info))
234
+ rng = random.Random(1234)
235
+ n_dec, t0 = 0, time.time()
236
+ examples = examples[:limit] if limit else examples
237
+ try:
238
+ for i, ex in enumerate(examples):
239
+ order = shuffled_order(ex, shuffle_rng) if shuffle_rng is not None else None
240
+ draws = _slot_token(seed_mode, alias_ids, rng, vocab_size)
241
+ feats, rec = encode_example(model, tok, ex, cap, info, slot_tokens=draws, alias_ids=alias_ids,
242
+ max_tokens=max_tokens, option_order=order)
243
+ rec.path = f"{ex.id.replace('/', '_')}.safetensors"
244
+ writer.write(rec, feats)
245
+ n_dec += len(rec.decisions)
246
+ if (i + 1) % log_every == 0:
247
+ print(f"{i + 1}/{len(examples)} examples, {time.time() - t0:.0f}s", flush=True)
248
+ finally: # close the manifest and drop the hooks even when an example raises
249
+ writer.close()
250
+ cap.remove()
251
+ n_extra = len((info.get("extra_layers") or {}).get("encoder", []))
252
+ summary = {"examples": len(examples), "decisions": n_dec, "seed_mode": seed_mode,
253
+ "canvas_length": canvas_length, "seconds": round(time.time() - t0, 1),
254
+ "feature_dim": hidden_size * (1 + n_extra), **info}
255
+ Path(out_dir, "summary.json").write_text(json.dumps(summary, indent=2))
256
+ return summary
257
+
258
+
259
+ def zero_shot_accuracy(cache_dir) -> float:
260
+ cache = FeatureCache(cache_dir)
261
+ hits = total = 0
262
+ for rec in cache.records:
263
+ for d in rec.decisions:
264
+ if d.gold is None:
265
+ continue
266
+ total += 1
267
+ hits += int(argmax(d.prior) == argmax(d.gold))
268
+ return hits / max(total, 1)
269
+
270
+
271
+ def load_backbone(model_id: str = MODEL_ID, load_in_4bit: bool = False):
272
+ from transformers import AutoTokenizer, DiffusionGemmaForBlockDiffusion
273
+ kwargs = {"device_map": "auto", "dtype": torch.bfloat16}
274
+ if load_in_4bit:
275
+ from transformers import BitsAndBytesConfig
276
+ kwargs["quantization_config"] = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_compute_dtype=torch.bfloat16)
277
+ model = DiffusionGemmaForBlockDiffusion.from_pretrained(model_id, **kwargs).eval()
278
+ return model, AutoTokenizer.from_pretrained(model_id)
279
+
280
+
281
+ def main():
282
+ ap = argparse.ArgumentParser()
283
+ sub = ap.add_subparsers(dest="cmd", required=True)
284
+ p = sub.add_parser("probe")
285
+ p.add_argument("--model", default=MODEL_ID)
286
+ e = sub.add_parser("extract")
287
+ e.add_argument("--model", default=MODEL_ID)
288
+ e.add_argument("--manifest", required=True)
289
+ e.add_argument("--out", required=True)
290
+ e.add_argument("--seed-mode", default="pad",
291
+ help="pad, alias or random, or a comma-separated list of them; a list writes <out>_<mode> per mode")
292
+ e.add_argument("--canvas", type=int, default=64)
293
+ e.add_argument("--limit", type=int, default=None)
294
+ e.add_argument("--shuffle-seed", type=int, default=None, help="shuffle choice options (training augmentation)")
295
+ e.add_argument("--load-in-4bit", action="store_true")
296
+ e.add_argument("--zero-shot-log", action="store_true", help=f"append each cache's zero-shot accuracy to {ZERO_SHOT_LOG}")
297
+ for sp in (p, e):
298
+ sp.add_argument("--layers", default=None,
299
+ help="depth fractions of extra encoder/decoder layers to append to the features, e.g. 0.5,0.75 "
300
+ "(feature dim becomes hidden_size * (1 + count)); probe records their module names")
301
+ z = sub.add_parser("zero-shot")
302
+ z.add_argument("--cache", required=True)
303
+ args = ap.parse_args()
304
+ fracs = tuple(float(x) for x in args.layers.split(",")) if getattr(args, "layers", None) else ()
305
+ if args.cmd == "zero-shot":
306
+ print(json.dumps({"cache": args.cache, "zero_shot_accuracy": zero_shot_accuracy(args.cache)}))
307
+ return
308
+ if args.cmd == "extract":
309
+ modes = args.seed_mode.split(",")
310
+ if any(m not in SEED_MODES for m in modes) or len(set(modes)) != len(modes):
311
+ e.error(f"--seed-mode takes distinct values from {', '.join(SEED_MODES)}; got {args.seed_mode!r}")
312
+ model, tok = load_backbone(args.model, getattr(args, "load_in_4bit", False))
313
+ hidden = model.config.text_config.hidden_size
314
+ if args.cmd == "probe":
315
+ info = probe(model, tok, hidden)
316
+ if fracs:
317
+ info["extra_layers"] = resolve_extra_layers(model, info["encoder_norm"], info["decoder_norm"], fracs)
318
+ info["feature_dim"] = hidden * (1 + len(info["extra_layers"]["encoder"]))
319
+ Path("configs").mkdir(exist_ok=True)
320
+ Path("configs/backbone.json").write_text(json.dumps({**info, "model_id": args.model}, indent=2))
321
+ print(json.dumps(info, indent=2))
322
+ return
323
+ alias = alias_token_ids(tok, 64)
324
+ vocab = getattr(model.config.text_config, "vocab_size", 262144)
325
+ examples = read_manifest(args.manifest)
326
+ info = probe(model, tok, hidden) if len(modes) > 1 else None # one probe shared by every mode
327
+ for mode in modes:
328
+ out = f"{args.out}_{mode}" if len(modes) > 1 else args.out
329
+ # a fresh rng per mode, so each cache matches what a separate run of that mode would write
330
+ rng = random.Random(args.shuffle_seed) if args.shuffle_seed is not None else None
331
+ summary = extract(model, tok, examples, out, seed_mode=mode, canvas_length=args.canvas, alias_ids=alias,
332
+ hidden_size=hidden, shuffle_rng=rng, limit=args.limit, vocab_size=vocab, info=info,
333
+ layer_fracs=fracs)
334
+ print(json.dumps(summary, indent=2))
335
+ acc = zero_shot_accuracy(out)
336
+ print(f"zero_shot {mode} {acc:.4f}", flush=True)
337
+ if args.zero_shot_log:
338
+ log = Path(ZERO_SHOT_LOG)
339
+ log.parent.mkdir(parents=True, exist_ok=True)
340
+ with log.open("a", encoding="utf-8") as f:
341
+ f.write(json.dumps({"cache": out, "seed_mode": mode, "zero_shot_accuracy": acc}) + "\n")
342
+
343
+
344
+ if __name__ == "__main__":
345
+ main()
head.py CHANGED
@@ -1,119 +1,119 @@
1
- """Hev decision head: projection, Hydra stack, pointer readout over option spans, backbone prior, temperatures."""
2
- from __future__ import annotations
3
-
4
- import math
5
- from dataclasses import asdict, dataclass
6
-
7
- import torch
8
- from torch import nn
9
-
10
- from .cache import Batch
11
- from .hydra_torch import HydraBlock, RMSNormGated
12
-
13
-
14
- @dataclass
15
- class HevHeadConfig:
16
- d_backbone: int = 2816
17
- d_model: int = 512
18
- n_blocks: int = 3
19
- d_state: int = 64
20
- expand: int = 2
21
- headdim: int = 64
22
- d_conv: int = 7
23
- mlp_mult: int = 2
24
- chunk_size: int = 64
25
- n_types: int = 3
26
-
27
- def to_dict(self) -> dict:
28
- return asdict(self)
29
-
30
- @classmethod
31
- def from_dict(cls, d: dict) -> "HevHeadConfig":
32
- return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
33
-
34
-
35
- class HevHead(nn.Module):
36
- def __init__(self, cfg: HevHeadConfig):
37
- super().__init__()
38
- self.cfg = cfg
39
- self.in_proj = nn.Linear(cfg.d_backbone, cfg.d_model)
40
- self.seg_emb = nn.Embedding(2, cfg.d_model)
41
- self.in_norm = RMSNormGated(cfg.d_model)
42
- self.blocks = nn.ModuleList([
43
- HydraBlock(cfg.d_model, mlp_mult=cfg.mlp_mult, d_state=cfg.d_state, d_conv=cfg.d_conv,
44
- expand=cfg.expand, headdim=cfg.headdim, chunk_size=cfg.chunk_size)
45
- for _ in range(cfg.n_blocks)
46
- ])
47
- self.final_norm = RMSNormGated(cfg.d_model)
48
- self.w_q = nn.Linear(cfg.d_model, cfg.d_model, bias=False)
49
- self.w_o = nn.Linear(cfg.d_model, cfg.d_model, bias=False)
50
- nn.init.normal_(self.w_q.weight, std=0.02)
51
- nn.init.zeros_(self.w_o.weight) # pointer term is exactly 0 at init, so a fresh head starts at the backbone prior
52
- self.beta = nn.Parameter(torch.ones(cfg.n_types))
53
- self.log_temp = nn.Parameter(torch.zeros(cfg.n_types))
54
-
55
- def num_parameters(self) -> int:
56
- return sum(p.numel() for p in self.parameters() if p.requires_grad)
57
-
58
- def encode(self, feats: torch.Tensor, seg: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
59
- h = self.in_proj(feats.float()) + self.seg_emb(seg)
60
- h = self.in_norm(h) * mask[..., None].to(h.dtype)
61
- for blk in self.blocks:
62
- h = blk(h, mask)
63
- return self.final_norm(h)
64
-
65
- def forward_flat(self, batch: Batch, use_temperature: bool = False) -> torch.Tensor:
66
- """Logits for every option of every decision in the batch as one flat tensor, in the batch's option order.
67
-
68
- This is the whole readout in a fixed number of tensor ops: a gather of the span rows, one index_add for
69
- the span means, one gather for the slot queries, and elementwise arithmetic on the flat option axis.
70
- The per-decision loops it replaced issued ~360 ops per decision and left a GPU idle (session 3).
71
- """
72
- h = self.encode(batch.feats, batch.seg, batch.mask)
73
- b, l, d = h.shape
74
- if bool((batch.span_len <= 0).any()): # one host sync per batch; collate already rejects empty spans
75
- bad = int((batch.span_len <= 0).nonzero()[0])
76
- raise ValueError(f"empty span for decision {batch.decisions[int(batch.opt_dec[bad])]['qid']!r}")
77
- hf = h.reshape(b * l, d).float()
78
- sums = hf.new_zeros(batch.span_len.shape[0], d).index_add(0, batch.span_opt, hf.index_select(0, batch.span_rows))
79
- reps = sums / batch.span_len[:, None]
80
- q = self.w_q(h[batch.dec_b, batch.dec_slot].float()) # (n_dec, d_model)
81
- scale = 1.0 / math.sqrt(self.cfg.d_model)
82
- scores = (self.w_o(reps) * q[batch.opt_dec]).sum(-1).float() * scale
83
- logits = scores + self.beta[batch.dec_type][batch.opt_dec] * batch.opt_prior
84
- if use_temperature:
85
- logits = logits / torch.exp(self.log_temp)[batch.dec_type][batch.opt_dec]
86
- return logits
87
-
88
- def forward(self, batch: Batch, use_temperature: bool = False) -> list[torch.Tensor]:
89
- """One logit vector per decision: views into forward_flat's output."""
90
- return list(torch.split(self.forward_flat(batch, use_temperature), batch.counts))
91
-
92
-
93
- class HevEnsemble(nn.Module):
94
- """One or more HevHeads with the same config, averaged in probability space.
95
-
96
- forward() returns, per decision, the log of the mean of the members' softmaxes, so every consumer that
97
- applies softmax to the head's output (evaluate.predict, HevForDecisions.decide) gets exactly the averaged
98
- distribution. Members keep their own fitted temperatures; the ensemble is not re-calibrated."""
99
-
100
- def __init__(self, members: list[HevHead]):
101
- super().__init__()
102
- if not members:
103
- raise ValueError("an ensemble needs at least one member")
104
- cfg = members[0].cfg
105
- if any(m.cfg != cfg for m in members):
106
- raise ValueError("every ensemble member must share one HevHeadConfig")
107
- self.members = nn.ModuleList(members)
108
- self.cfg = cfg
109
-
110
- def num_parameters(self) -> int:
111
- return sum(m.num_parameters() for m in self.members)
112
-
113
- def forward(self, batch: Batch, use_temperature: bool = False) -> list[torch.Tensor]:
114
- outs = [m(batch, use_temperature=use_temperature) for m in self.members]
115
- result = []
116
- for i in range(len(outs[0])):
117
- probs = torch.stack([torch.softmax(o[i].float(), 0) for o in outs]).mean(0)
118
- result.append(torch.log(probs.clamp_min(1e-12)))
119
- return result
 
1
+ """Hev decision head: projection, Hydra stack, pointer readout over option spans, backbone prior, temperatures."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from dataclasses import asdict, dataclass
6
+
7
+ import torch
8
+ from torch import nn
9
+
10
+ from .cache import Batch
11
+ from .hydra_torch import HydraBlock, RMSNormGated
12
+
13
+
14
+ @dataclass
15
+ class HevHeadConfig:
16
+ d_backbone: int = 2816
17
+ d_model: int = 512
18
+ n_blocks: int = 3
19
+ d_state: int = 64
20
+ expand: int = 2
21
+ headdim: int = 64
22
+ d_conv: int = 7
23
+ mlp_mult: int = 2
24
+ chunk_size: int = 64
25
+ n_types: int = 3
26
+
27
+ def to_dict(self) -> dict:
28
+ return asdict(self)
29
+
30
+ @classmethod
31
+ def from_dict(cls, d: dict) -> "HevHeadConfig":
32
+ return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
33
+
34
+
35
+ class HevHead(nn.Module):
36
+ def __init__(self, cfg: HevHeadConfig):
37
+ super().__init__()
38
+ self.cfg = cfg
39
+ self.in_proj = nn.Linear(cfg.d_backbone, cfg.d_model)
40
+ self.seg_emb = nn.Embedding(2, cfg.d_model)
41
+ self.in_norm = RMSNormGated(cfg.d_model)
42
+ self.blocks = nn.ModuleList([
43
+ HydraBlock(cfg.d_model, mlp_mult=cfg.mlp_mult, d_state=cfg.d_state, d_conv=cfg.d_conv,
44
+ expand=cfg.expand, headdim=cfg.headdim, chunk_size=cfg.chunk_size)
45
+ for _ in range(cfg.n_blocks)
46
+ ])
47
+ self.final_norm = RMSNormGated(cfg.d_model)
48
+ self.w_q = nn.Linear(cfg.d_model, cfg.d_model, bias=False)
49
+ self.w_o = nn.Linear(cfg.d_model, cfg.d_model, bias=False)
50
+ nn.init.normal_(self.w_q.weight, std=0.02)
51
+ nn.init.zeros_(self.w_o.weight) # pointer term is exactly 0 at init, so a fresh head starts at the backbone prior
52
+ self.beta = nn.Parameter(torch.ones(cfg.n_types))
53
+ self.log_temp = nn.Parameter(torch.zeros(cfg.n_types))
54
+
55
+ def num_parameters(self) -> int:
56
+ return sum(p.numel() for p in self.parameters() if p.requires_grad)
57
+
58
+ def encode(self, feats: torch.Tensor, seg: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
59
+ h = self.in_proj(feats.float()) + self.seg_emb(seg)
60
+ h = self.in_norm(h) * mask[..., None].to(h.dtype)
61
+ for blk in self.blocks:
62
+ h = blk(h, mask)
63
+ return self.final_norm(h)
64
+
65
+ def forward_flat(self, batch: Batch, use_temperature: bool = False) -> torch.Tensor:
66
+ """Logits for every option of every decision in the batch as one flat tensor, in the batch's option order.
67
+
68
+ This is the whole readout in a fixed number of tensor ops: a gather of the span rows, one index_add for
69
+ the span means, one gather for the slot queries, and elementwise arithmetic on the flat option axis.
70
+ The per-decision loops it replaced issued ~360 ops per decision and left a GPU idle (session 3).
71
+ """
72
+ h = self.encode(batch.feats, batch.seg, batch.mask)
73
+ b, l, d = h.shape
74
+ if bool((batch.span_len <= 0).any()): # one host sync per batch; collate already rejects empty spans
75
+ bad = int((batch.span_len <= 0).nonzero()[0])
76
+ raise ValueError(f"empty span for decision {batch.decisions[int(batch.opt_dec[bad])]['qid']!r}")
77
+ hf = h.reshape(b * l, d).float()
78
+ sums = hf.new_zeros(batch.span_len.shape[0], d).index_add(0, batch.span_opt, hf.index_select(0, batch.span_rows))
79
+ reps = sums / batch.span_len[:, None]
80
+ q = self.w_q(h[batch.dec_b, batch.dec_slot].float()) # (n_dec, d_model)
81
+ scale = 1.0 / math.sqrt(self.cfg.d_model)
82
+ scores = (self.w_o(reps) * q[batch.opt_dec]).sum(-1).float() * scale
83
+ logits = scores + self.beta[batch.dec_type][batch.opt_dec] * batch.opt_prior
84
+ if use_temperature:
85
+ logits = logits / torch.exp(self.log_temp)[batch.dec_type][batch.opt_dec]
86
+ return logits
87
+
88
+ def forward(self, batch: Batch, use_temperature: bool = False) -> list[torch.Tensor]:
89
+ """One logit vector per decision: views into forward_flat's output."""
90
+ return list(torch.split(self.forward_flat(batch, use_temperature), batch.counts))
91
+
92
+
93
+ class HevEnsemble(nn.Module):
94
+ """One or more HevHeads with the same config, averaged in probability space.
95
+
96
+ forward() returns, per decision, the log of the mean of the members' softmaxes, so every consumer that
97
+ applies softmax to the head's output (evaluate.predict, HevForDecisions.decide) gets exactly the averaged
98
+ distribution. Members keep their own fitted temperatures; the ensemble is not re-calibrated."""
99
+
100
+ def __init__(self, members: list[HevHead]):
101
+ super().__init__()
102
+ if not members:
103
+ raise ValueError("an ensemble needs at least one member")
104
+ cfg = members[0].cfg
105
+ if any(m.cfg != cfg for m in members):
106
+ raise ValueError("every ensemble member must share one HevHeadConfig")
107
+ self.members = nn.ModuleList(members)
108
+ self.cfg = cfg
109
+
110
+ def num_parameters(self) -> int:
111
+ return sum(m.num_parameters() for m in self.members)
112
+
113
+ def forward(self, batch: Batch, use_temperature: bool = False) -> list[torch.Tensor]:
114
+ outs = [m(batch, use_temperature=use_temperature) for m in self.members]
115
+ result = []
116
+ for i in range(len(outs[0])):
117
+ probs = torch.stack([torch.softmax(o[i].float(), 0) for o in outs]).mean(0)
118
+ result.append(torch.log(probs.clamp_min(1e-12)))
119
+ return result
hydra_torch.py CHANGED
@@ -1,156 +1,156 @@
1
- """Pure-PyTorch Hydra (Hwang, Lahoti, Dao, Gu 2024): a quasiseparable bidirectional SSM mixer.
2
-
3
- Mirrors goombalab/hydra's HydraMixer parameter layout exactly (same names and shapes), so weights are
4
- interchangeable with the CUDA implementation. The SSD scan is the chunked algorithm from the Mamba-2
5
- paper (Listing 1), padded to a chunk multiple. No mamba_ssm, no einops, no CUDA kernels.
6
- """
7
- from __future__ import annotations
8
-
9
- import math
10
-
11
- import torch
12
- import torch.nn.functional as F
13
- from torch import nn
14
-
15
-
16
- def segsum(x: torch.Tensor) -> torch.Tensor:
17
- """out[..., i, j] = sum_{k=j+1..i} x[..., k] for i >= j, else -inf."""
18
- T = x.shape[-1]
19
- x = x.unsqueeze(-1).expand(*x.shape, T)
20
- lower = torch.tril(torch.ones(T, T, dtype=torch.bool, device=x.device), diagonal=-1)
21
- x = x.masked_fill(~lower, 0)
22
- x_segsum = torch.cumsum(x, dim=-2)
23
- keep = torch.tril(torch.ones(T, T, dtype=torch.bool, device=x.device), diagonal=0)
24
- return x_segsum.masked_fill(~keep, float("-inf"))
25
-
26
-
27
- def ssd_scan(X: torch.Tensor, A: torch.Tensor, B: torch.Tensor, C: torch.Tensor, chunk: int = 64) -> torch.Tensor:
28
- b, l, h, p = X.shape
29
- pad = (-l) % chunk
30
- if pad:
31
- X = F.pad(X, (0, 0, 0, 0, 0, pad))
32
- A = F.pad(A, (0, 0, 0, pad))
33
- B = F.pad(B, (0, 0, 0, 0, 0, pad))
34
- C = F.pad(C, (0, 0, 0, 0, 0, pad))
35
- L = X.shape[1]
36
- c = L // chunk
37
- X = X.reshape(b, c, chunk, h, p)
38
- B = B.reshape(b, c, chunk, h, -1)
39
- C = C.reshape(b, c, chunk, h, -1)
40
- A = A.reshape(b, c, chunk, h).permute(0, 3, 1, 2) # (b, h, c, l)
41
- A_cumsum = torch.cumsum(A, dim=-1)
42
- Lmat = torch.exp(segsum(A)) # (b, h, c, l, s)
43
- Y_diag = torch.einsum("bclhn,bcshn,bhcls,bcshp->bclhp", C, B, Lmat, X)
44
- decay_states = torch.exp(A_cumsum[:, :, :, -1:] - A_cumsum)
45
- states = torch.einsum("bclhn,bhcl,bclhp->bchpn", B, decay_states, X)
46
- states = torch.cat([torch.zeros_like(states[:, :1]), states], dim=1)
47
- decay_chunk = torch.exp(segsum(F.pad(A_cumsum[:, :, :, -1], (1, 0))))
48
- new_states = torch.einsum("bhzc,bchpn->bzhpn", decay_chunk, states)
49
- states = new_states[:, :-1]
50
- Y_off = torch.einsum("bclhn,bchpn,bhcl->bclhp", C, states, torch.exp(A_cumsum))
51
- Y = (Y_diag + Y_off).reshape(b, L, h, p)
52
- return Y[:, :l]
53
-
54
-
55
- def ssd_naive(X: torch.Tensor, A: torch.Tensor, B: torch.Tensor, C: torch.Tensor) -> torch.Tensor:
56
- b, l, h, p = X.shape
57
- n = B.shape[-1]
58
- state = X.new_zeros(b, h, p, n)
59
- ys = []
60
- for t in range(l):
61
- state = state * torch.exp(A[:, t])[:, :, None, None] + X[:, t][..., None] * B[:, t][:, :, None, :]
62
- ys.append(torch.einsum("bhpn,bhn->bhp", state, C[:, t]))
63
- return torch.stack(ys, dim=1)
64
-
65
-
66
- class RMSNormGated(nn.Module):
67
- """RMSNorm followed by an optional SiLU gate (norm_before_gate=True in mamba_ssm)."""
68
-
69
- def __init__(self, d: int, eps: float = 1e-5):
70
- super().__init__()
71
- self.eps = eps
72
- self.weight = nn.Parameter(torch.ones(d))
73
-
74
- def forward(self, x: torch.Tensor, z: torch.Tensor | None = None) -> torch.Tensor:
75
- xf = x.float()
76
- y = xf * torch.rsqrt(xf.pow(2).mean(-1, keepdim=True) + self.eps) * self.weight.float()
77
- if z is not None:
78
- y = y * F.silu(z.float())
79
- return y.to(x.dtype)
80
-
81
-
82
- class HydraMixer(nn.Module):
83
- def __init__(self, d_model: int, d_state: int = 64, d_conv: int = 7, expand: int = 2, headdim: int = 64,
84
- ngroups: int = 1, chunk_size: int = 64, dt_min: float = 0.001, dt_max: float = 0.1,
85
- dt_init_floor: float = 1e-4):
86
- super().__init__()
87
- self.d_model, self.d_state, self.headdim, self.ngroups, self.chunk_size = d_model, d_state, headdim, ngroups, chunk_size
88
- self.d_inner = expand * d_model
89
- if self.d_inner % headdim != 0:
90
- raise ValueError(f"d_inner (expand * d_model = {self.d_inner}) must be divisible by headdim ({headdim})")
91
- self.nheads = self.d_inner // headdim
92
- if self.nheads % ngroups != 0:
93
- raise ValueError(f"nheads (d_inner / headdim = {self.nheads}) must be divisible by ngroups ({ngroups})")
94
- if d_conv % 2 != 1:
95
- raise ValueError(f"d_conv must be odd so the non-causal conv keeps the sequence length, got {d_conv}")
96
- d_in_proj = 2 * self.d_inner + 2 * (2 * ngroups * d_state) + 2 * self.nheads
97
- self.in_proj = nn.Linear(d_model, d_in_proj, bias=False)
98
- conv_dim = self.d_inner + 2 * (2 * ngroups * d_state)
99
- self.conv1d = nn.Conv1d(conv_dim, conv_dim, kernel_size=d_conv, groups=conv_dim, padding=d_conv // 2, bias=True)
100
- dt = torch.exp(torch.rand(self.nheads) * (math.log(dt_max) - math.log(dt_min)) + math.log(dt_min))
101
- dt = torch.clamp(dt, min=dt_init_floor)
102
- self.dt_bias = nn.Parameter(dt + torch.log(-torch.expm1(-dt)))
103
- self.dt_bias._no_weight_decay = True
104
- self.A_log = nn.Parameter(torch.zeros(self.nheads)) # A = -exp(0) = -1, as in the official init
105
- self.A_log._no_weight_decay = True
106
- self.D = nn.Parameter(torch.ones(self.nheads))
107
- self.D._no_weight_decay = True
108
- self.fc_D = nn.Linear(self.d_inner, self.nheads, bias=False)
109
- self.norm = RMSNormGated(self.d_inner, eps=1e-5)
110
- self.out_proj = nn.Linear(self.d_inner, d_model, bias=False)
111
-
112
- def forward(self, u: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
113
- b, l, _ = u.shape
114
- H, P, G, N = self.nheads, self.headdim, self.ngroups, self.d_state
115
- if mask is not None:
116
- u = u * mask[..., None].to(u.dtype)
117
- z, xBC, dt = torch.split(self.in_proj(u), [self.d_inner, self.d_inner + 4 * G * N, 2 * H], dim=-1)
118
- xBC = F.silu(self.conv1d(xBC.transpose(1, 2)).transpose(1, 2))
119
- dt2 = F.softplus(torch.cat([dt[..., :H], dt[..., H:].flip(1)], dim=0) + self.dt_bias) # (2b, l, H)
120
- if mask is not None:
121
- m = mask[..., None].to(u.dtype)
122
- xBC = xBC * m
123
- dt2 = dt2 * torch.cat([m, m.flip(1)], dim=0)
124
- x, BC = torch.split(xBC, [self.d_inner, 4 * G * N], dim=-1)
125
- x_og = x
126
- x2 = torch.cat([x, x.flip(1)], dim=0)
127
- BC2 = torch.cat([BC[..., :2 * G * N], BC[..., 2 * G * N:].flip(1)], dim=0)
128
- Bm, Cm = torch.split(BC2, [G * N, G * N], dim=-1)
129
- A = -torch.exp(self.A_log.float())
130
- Xh = x2.reshape(2 * b, l, H, P).float() * dt2.float()[..., None]
131
- Ah = A[None, None, :] * dt2.float()
132
- Bh = Bm.reshape(2 * b, l, G, N).float().repeat_interleave(H // G, dim=2)
133
- Ch = Cm.reshape(2 * b, l, G, N).float().repeat_interleave(H // G, dim=2)
134
- y = ssd_scan(Xh, Ah, Bh, Ch, self.chunk_size).reshape(2 * b, l, self.d_inner).to(u.dtype)
135
- y = torch.cat([torch.zeros_like(y[:, :1]), y[:, :-1]], dim=1) # shift by one, first position zero
136
- y_fw, y_bw = y[:b], y[b:].flip(1)
137
- d_term = F.linear(x_og, self.fc_D.weight, self.D).repeat_interleave(P, dim=-1)
138
- y = y_fw + y_bw + x_og * d_term
139
- y = self.norm(y, z)
140
- return self.out_proj(y)
141
-
142
-
143
- class HydraBlock(nn.Module):
144
- def __init__(self, d_model: int, mlp_mult: int = 2, **mixer_kwargs):
145
- super().__init__()
146
- self.norm1 = RMSNormGated(d_model)
147
- self.mixer = HydraMixer(d_model, **mixer_kwargs)
148
- self.norm2 = RMSNormGated(d_model)
149
- hidden = mlp_mult * d_model
150
- self.fc1 = nn.Linear(d_model, 2 * hidden)
151
- self.fc2 = nn.Linear(hidden, d_model)
152
-
153
- def forward(self, x: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
154
- x = x + self.mixer(self.norm1(x), mask)
155
- h, g = self.fc1(self.norm2(x)).chunk(2, dim=-1)
156
- return x + self.fc2(F.silu(g) * h)
 
1
+ """Pure-PyTorch Hydra (Hwang, Lahoti, Dao, Gu 2024): a quasiseparable bidirectional SSM mixer.
2
+
3
+ Mirrors goombalab/hydra's HydraMixer parameter layout exactly (same names and shapes), so weights are
4
+ interchangeable with the CUDA implementation. The SSD scan is the chunked algorithm from the Mamba-2
5
+ paper (Listing 1), padded to a chunk multiple. No mamba_ssm, no einops, no CUDA kernels.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ import torch
12
+ import torch.nn.functional as F
13
+ from torch import nn
14
+
15
+
16
+ def segsum(x: torch.Tensor) -> torch.Tensor:
17
+ """out[..., i, j] = sum_{k=j+1..i} x[..., k] for i >= j, else -inf."""
18
+ T = x.shape[-1]
19
+ x = x.unsqueeze(-1).expand(*x.shape, T)
20
+ lower = torch.tril(torch.ones(T, T, dtype=torch.bool, device=x.device), diagonal=-1)
21
+ x = x.masked_fill(~lower, 0)
22
+ x_segsum = torch.cumsum(x, dim=-2)
23
+ keep = torch.tril(torch.ones(T, T, dtype=torch.bool, device=x.device), diagonal=0)
24
+ return x_segsum.masked_fill(~keep, float("-inf"))
25
+
26
+
27
+ def ssd_scan(X: torch.Tensor, A: torch.Tensor, B: torch.Tensor, C: torch.Tensor, chunk: int = 64) -> torch.Tensor:
28
+ b, l, h, p = X.shape
29
+ pad = (-l) % chunk
30
+ if pad:
31
+ X = F.pad(X, (0, 0, 0, 0, 0, pad))
32
+ A = F.pad(A, (0, 0, 0, pad))
33
+ B = F.pad(B, (0, 0, 0, 0, 0, pad))
34
+ C = F.pad(C, (0, 0, 0, 0, 0, pad))
35
+ L = X.shape[1]
36
+ c = L // chunk
37
+ X = X.reshape(b, c, chunk, h, p)
38
+ B = B.reshape(b, c, chunk, h, -1)
39
+ C = C.reshape(b, c, chunk, h, -1)
40
+ A = A.reshape(b, c, chunk, h).permute(0, 3, 1, 2) # (b, h, c, l)
41
+ A_cumsum = torch.cumsum(A, dim=-1)
42
+ Lmat = torch.exp(segsum(A)) # (b, h, c, l, s)
43
+ Y_diag = torch.einsum("bclhn,bcshn,bhcls,bcshp->bclhp", C, B, Lmat, X)
44
+ decay_states = torch.exp(A_cumsum[:, :, :, -1:] - A_cumsum)
45
+ states = torch.einsum("bclhn,bhcl,bclhp->bchpn", B, decay_states, X)
46
+ states = torch.cat([torch.zeros_like(states[:, :1]), states], dim=1)
47
+ decay_chunk = torch.exp(segsum(F.pad(A_cumsum[:, :, :, -1], (1, 0))))
48
+ new_states = torch.einsum("bhzc,bchpn->bzhpn", decay_chunk, states)
49
+ states = new_states[:, :-1]
50
+ Y_off = torch.einsum("bclhn,bchpn,bhcl->bclhp", C, states, torch.exp(A_cumsum))
51
+ Y = (Y_diag + Y_off).reshape(b, L, h, p)
52
+ return Y[:, :l]
53
+
54
+
55
+ def ssd_naive(X: torch.Tensor, A: torch.Tensor, B: torch.Tensor, C: torch.Tensor) -> torch.Tensor:
56
+ b, l, h, p = X.shape
57
+ n = B.shape[-1]
58
+ state = X.new_zeros(b, h, p, n)
59
+ ys = []
60
+ for t in range(l):
61
+ state = state * torch.exp(A[:, t])[:, :, None, None] + X[:, t][..., None] * B[:, t][:, :, None, :]
62
+ ys.append(torch.einsum("bhpn,bhn->bhp", state, C[:, t]))
63
+ return torch.stack(ys, dim=1)
64
+
65
+
66
+ class RMSNormGated(nn.Module):
67
+ """RMSNorm followed by an optional SiLU gate (norm_before_gate=True in mamba_ssm)."""
68
+
69
+ def __init__(self, d: int, eps: float = 1e-5):
70
+ super().__init__()
71
+ self.eps = eps
72
+ self.weight = nn.Parameter(torch.ones(d))
73
+
74
+ def forward(self, x: torch.Tensor, z: torch.Tensor | None = None) -> torch.Tensor:
75
+ xf = x.float()
76
+ y = xf * torch.rsqrt(xf.pow(2).mean(-1, keepdim=True) + self.eps) * self.weight.float()
77
+ if z is not None:
78
+ y = y * F.silu(z.float())
79
+ return y.to(x.dtype)
80
+
81
+
82
+ class HydraMixer(nn.Module):
83
+ def __init__(self, d_model: int, d_state: int = 64, d_conv: int = 7, expand: int = 2, headdim: int = 64,
84
+ ngroups: int = 1, chunk_size: int = 64, dt_min: float = 0.001, dt_max: float = 0.1,
85
+ dt_init_floor: float = 1e-4):
86
+ super().__init__()
87
+ self.d_model, self.d_state, self.headdim, self.ngroups, self.chunk_size = d_model, d_state, headdim, ngroups, chunk_size
88
+ self.d_inner = expand * d_model
89
+ if self.d_inner % headdim != 0:
90
+ raise ValueError(f"d_inner (expand * d_model = {self.d_inner}) must be divisible by headdim ({headdim})")
91
+ self.nheads = self.d_inner // headdim
92
+ if self.nheads % ngroups != 0:
93
+ raise ValueError(f"nheads (d_inner / headdim = {self.nheads}) must be divisible by ngroups ({ngroups})")
94
+ if d_conv % 2 != 1:
95
+ raise ValueError(f"d_conv must be odd so the non-causal conv keeps the sequence length, got {d_conv}")
96
+ d_in_proj = 2 * self.d_inner + 2 * (2 * ngroups * d_state) + 2 * self.nheads
97
+ self.in_proj = nn.Linear(d_model, d_in_proj, bias=False)
98
+ conv_dim = self.d_inner + 2 * (2 * ngroups * d_state)
99
+ self.conv1d = nn.Conv1d(conv_dim, conv_dim, kernel_size=d_conv, groups=conv_dim, padding=d_conv // 2, bias=True)
100
+ dt = torch.exp(torch.rand(self.nheads) * (math.log(dt_max) - math.log(dt_min)) + math.log(dt_min))
101
+ dt = torch.clamp(dt, min=dt_init_floor)
102
+ self.dt_bias = nn.Parameter(dt + torch.log(-torch.expm1(-dt)))
103
+ self.dt_bias._no_weight_decay = True
104
+ self.A_log = nn.Parameter(torch.zeros(self.nheads)) # A = -exp(0) = -1, as in the official init
105
+ self.A_log._no_weight_decay = True
106
+ self.D = nn.Parameter(torch.ones(self.nheads))
107
+ self.D._no_weight_decay = True
108
+ self.fc_D = nn.Linear(self.d_inner, self.nheads, bias=False)
109
+ self.norm = RMSNormGated(self.d_inner, eps=1e-5)
110
+ self.out_proj = nn.Linear(self.d_inner, d_model, bias=False)
111
+
112
+ def forward(self, u: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
113
+ b, l, _ = u.shape
114
+ H, P, G, N = self.nheads, self.headdim, self.ngroups, self.d_state
115
+ if mask is not None:
116
+ u = u * mask[..., None].to(u.dtype)
117
+ z, xBC, dt = torch.split(self.in_proj(u), [self.d_inner, self.d_inner + 4 * G * N, 2 * H], dim=-1)
118
+ xBC = F.silu(self.conv1d(xBC.transpose(1, 2)).transpose(1, 2))
119
+ dt2 = F.softplus(torch.cat([dt[..., :H], dt[..., H:].flip(1)], dim=0) + self.dt_bias) # (2b, l, H)
120
+ if mask is not None:
121
+ m = mask[..., None].to(u.dtype)
122
+ xBC = xBC * m
123
+ dt2 = dt2 * torch.cat([m, m.flip(1)], dim=0)
124
+ x, BC = torch.split(xBC, [self.d_inner, 4 * G * N], dim=-1)
125
+ x_og = x
126
+ x2 = torch.cat([x, x.flip(1)], dim=0)
127
+ BC2 = torch.cat([BC[..., :2 * G * N], BC[..., 2 * G * N:].flip(1)], dim=0)
128
+ Bm, Cm = torch.split(BC2, [G * N, G * N], dim=-1)
129
+ A = -torch.exp(self.A_log.float())
130
+ Xh = x2.reshape(2 * b, l, H, P).float() * dt2.float()[..., None]
131
+ Ah = A[None, None, :] * dt2.float()
132
+ Bh = Bm.reshape(2 * b, l, G, N).float().repeat_interleave(H // G, dim=2)
133
+ Ch = Cm.reshape(2 * b, l, G, N).float().repeat_interleave(H // G, dim=2)
134
+ y = ssd_scan(Xh, Ah, Bh, Ch, self.chunk_size).reshape(2 * b, l, self.d_inner).to(u.dtype)
135
+ y = torch.cat([torch.zeros_like(y[:, :1]), y[:, :-1]], dim=1) # shift by one, first position zero
136
+ y_fw, y_bw = y[:b], y[b:].flip(1)
137
+ d_term = F.linear(x_og, self.fc_D.weight, self.D).repeat_interleave(P, dim=-1)
138
+ y = y_fw + y_bw + x_og * d_term
139
+ y = self.norm(y, z)
140
+ return self.out_proj(y)
141
+
142
+
143
+ class HydraBlock(nn.Module):
144
+ def __init__(self, d_model: int, mlp_mult: int = 2, **mixer_kwargs):
145
+ super().__init__()
146
+ self.norm1 = RMSNormGated(d_model)
147
+ self.mixer = HydraMixer(d_model, **mixer_kwargs)
148
+ self.norm2 = RMSNormGated(d_model)
149
+ hidden = mlp_mult * d_model
150
+ self.fc1 = nn.Linear(d_model, 2 * hidden)
151
+ self.fc2 = nn.Linear(hidden, d_model)
152
+
153
+ def forward(self, x: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
154
+ x = x + self.mixer(self.norm1(x), mask)
155
+ h, g = self.fc1(self.norm2(x)).chunk(2, dim=-1)
156
+ return x + self.fc2(F.silu(g) * h)
modeling_hev.py CHANGED
@@ -1,163 +1,163 @@
1
- """HevForDecisions: frozen DiffusionGemma backbone + Hev head, exposing Jev-shaped decide()."""
2
- from __future__ import annotations
3
-
4
- import math
5
- import random
6
- from pathlib import Path
7
-
8
- import torch
9
- from safetensors.torch import load_file
10
- from transformers import PreTrainedModel
11
-
12
- from .cache import collate
13
- from .configuration_hev import HevConfig
14
- from .features import NormCapture, _slot_token, encode_example, extra_layer_names
15
- from .head import HevEnsemble, HevHead, HevHeadConfig
16
- from .prompting import PROMPT_FORMAT_VERSION, alias_token_ids
17
- from .schema import Example, Question
18
-
19
-
20
- # Hub download options that from_pretrained forwards to snapshot_download and HevConfig.from_pretrained.
21
- HUB_KWARGS = ("revision", "token", "cache_dir", "local_files_only", "force_download")
22
-
23
-
24
- def _is_local_dir(name: str) -> bool:
25
- try:
26
- return Path(name).is_dir()
27
- except (OSError, ValueError):
28
- return False
29
-
30
-
31
- def _vocab_size(backbone) -> int:
32
- """Backbone vocabulary size, read as hev.features.main reads it for random slot draws (default 262144)."""
33
- return getattr(getattr(backbone.config, "text_config", None), "vocab_size", 262144)
34
-
35
-
36
- def _param_device(module) -> torch.device:
37
- """Device of the module's first parameter, or CPU when it has none."""
38
- params = module.parameters() if hasattr(module, "parameters") else iter(())
39
- first = next(params, None)
40
- return first.device if first is not None else torch.device("cpu")
41
-
42
-
43
- class HevForDecisions(PreTrainedModel):
44
- config_class = HevConfig
45
-
46
- def __init__(self, config: HevConfig, backbone=None, tokenizer=None):
47
- super().__init__(config)
48
- members = []
49
- temps = list(getattr(config, "member_temperatures", None) or [config.temperatures])
50
- for i in range(int(getattr(config, "n_heads", 1))):
51
- head = HevHead(HevHeadConfig.from_dict(config.head_config))
52
- with torch.no_grad():
53
- head.log_temp.copy_(torch.log(torch.tensor(temps[i] if i < len(temps) else config.temperatures,
54
- dtype=torch.float32)))
55
- members.append(head)
56
- self.head = HevEnsemble(members)
57
- self.backbone = backbone
58
- self.tokenizer = tokenizer
59
- self._cap = None
60
- self._alias_ids = None
61
-
62
- @classmethod
63
- def from_pretrained(cls, pretrained_model_name_or_path, *model_args, backbone_cls=None, tokenizer_cls=None,
64
- dtype=torch.bfloat16, device_map="auto", load_in_4bit: bool = False, config=None, **kwargs):
65
- """Load the Hev config, head, frozen backbone and tokenizer from a local folder or a Hub repo.
66
-
67
- `revision`, `token`, `cache_dir`, `local_files_only` and `force_download` are forwarded to the Hub
68
- download and to HevConfig.from_pretrained. A HevConfig passed as `config` (AutoModel does this) is used as is.
69
- """
70
- hub = {k: kwargs[k] for k in HUB_KWARGS if kwargs.get(k) is not None}
71
- name = str(pretrained_model_name_or_path)
72
- path = Path(name)
73
- from_hub = not path.exists()
74
- if from_hub:
75
- from huggingface_hub import snapshot_download
76
- path = Path(snapshot_download(name, **hub))
77
- if not isinstance(config, HevConfig):
78
- config = HevConfig.from_pretrained(str(path), **hub)
79
- if from_hub:
80
- config._name_or_path = name # the repo id, not the local snapshot folder
81
- if backbone_cls is None or tokenizer_cls is None:
82
- from transformers import AutoConfig, AutoTokenizer, DiffusionGemmaForBlockDiffusion
83
- backbone_cls = backbone_cls or DiffusionGemmaForBlockDiffusion
84
- tokenizer_cls = tokenizer_cls or AutoTokenizer
85
- bb_cfg = AutoConfig.for_model(**config.backbone_config)
86
- else:
87
- bb_cfg = None
88
- bb_kwargs = {"config": bb_cfg, "dtype": dtype, "device_map": device_map}
89
- if load_in_4bit:
90
- from transformers import BitsAndBytesConfig
91
- bb_kwargs["quantization_config"] = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_compute_dtype=dtype)
92
- backbone = backbone_cls.from_pretrained(str(path), **bb_kwargs).eval()
93
- tokenizer = tokenizer_cls.from_pretrained(str(path))
94
- model = cls(config, backbone=backbone, tokenizer=tokenizer)
95
- if len(model.head.members) == 1 and (path / "hev_head.safetensors").exists():
96
- model.head.members[0].load_state_dict(load_file(str(path / "hev_head.safetensors")))
97
- else:
98
- for i, member in enumerate(model.head.members):
99
- member.load_state_dict(load_file(str(path / f"hev_head_{i}.safetensors")))
100
- model.head.to(_param_device(backbone))
101
- return model.eval()
102
-
103
- @staticmethod
104
- def confidence(probs) -> float:
105
- k = len(probs)
106
- if k < 2:
107
- return 1.0
108
- h = -sum(p * math.log(p) for p in probs if p > 0)
109
- return max(0.0, min(1.0, 1.0 - h / math.log(k)))
110
-
111
- def _extra_layers(self) -> dict:
112
- extra = getattr(self.config, "extra_layers", None) or {}
113
- return {"encoder": list(extra.get("encoder", [])), "decoder": list(extra.get("decoder", []))}
114
-
115
- def _ensure_capture(self):
116
- if self._cap is None:
117
- self._cap = NormCapture(self.backbone, self.config.encoder_norm, self.config.decoder_norm,
118
- extra=extra_layer_names({"extra_layers": self._extra_layers()}))
119
- self._alias_ids = alias_token_ids(self.tokenizer, len(self.config.aliases))
120
-
121
- @torch.no_grad()
122
- def decide(self, state: str, questions: dict) -> dict:
123
- """Answer Jev-shaped questions about `state` with the backbone features and the trained head.
124
-
125
- The answer slots are seeded exactly as hev.features.extract seeded them in training, by
126
- `config.seed_mode`: the pad token (0) for "pad", the first alias token for "alias", and an average
127
- over RANDOM_DRAWS forward passes with random slot tokens (fixed rng seed 1234) for "random".
128
- `config.slot_token_id` is not read here; seed_mode governs.
129
- """
130
- version = getattr(self.config, "prompt_format_version", 1)
131
- if version != PROMPT_FORMAT_VERSION:
132
- raise RuntimeError(
133
- f"this head was trained with prompt format {version}, but the installed hev code builds prompt format "
134
- f"{PROMPT_FORMAT_VERSION}; load the code revision the head was published with, or retrain the head")
135
- self._ensure_capture()
136
- qs = [Question.from_jev(qid, spec) for qid, spec in questions.items()]
137
- ex = Example(id="request", source="api", state=state, questions=qs, gold={})
138
- info = {"canvas_length": self.config.canvas_length, "encoder_norm": self.config.encoder_norm,
139
- "decoder_norm": self.config.decoder_norm, "extra_layers": self._extra_layers()}
140
- slot_tokens = _slot_token(self.config.seed_mode, self._alias_ids, random.Random(1234),
141
- _vocab_size(self.backbone))
142
- feats, rec = encode_example(self.backbone, self.tokenizer, ex, self._cap, info,
143
- slot_tokens=slot_tokens, alias_ids=self._alias_ids,
144
- max_tokens=self.config.max_prompt_tokens)
145
- batch = collate([(rec, feats)]).to(next(self.head.parameters()).device)
146
- logits = self.head(batch, use_temperature=True)
147
- answers = {}
148
- for q, lg in zip(qs, logits):
149
- probs = torch.softmax(lg.float(), 0).tolist()
150
- if q.type == "noul":
151
- answers[q.qid] = {"type": "noul", "noul": probs[0]}
152
- elif q.type == "choice":
153
- best = max(range(q.k), key=lambda i: probs[i])
154
- answers[q.qid] = {"type": "choice", "choice": q.options[best],
155
- "probabilities": dict(zip(q.options, probs)), "confidence": self.confidence(probs)}
156
- else:
157
- answers[q.qid] = {"type": "score", "score": sum(i * p for i, p in enumerate(probs)),
158
- "probabilities": {str(i): p for i, p in enumerate(probs)},
159
- "confidence": self.confidence(probs), "legend": list(q.descriptions)}
160
- name = getattr(self.config, "_name_or_path", "") or ""
161
- # A local folder name would leak a filesystem path into every response, so only a Hub repo id is reported.
162
- return {"model": name if name and not _is_local_dir(name) else "hev",
163
- "answers": answers, "usage": {"input_tokens": rec.n_prompt, "output_tokens": 0}}
 
1
+ """HevForDecisions: frozen DiffusionGemma backbone + Hev head, exposing Jev-shaped decide()."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import random
6
+ from pathlib import Path
7
+
8
+ import torch
9
+ from safetensors.torch import load_file
10
+ from transformers import PreTrainedModel
11
+
12
+ from .cache import collate
13
+ from .configuration_hev import HevConfig
14
+ from .features import NormCapture, _slot_token, encode_example, extra_layer_names
15
+ from .head import HevEnsemble, HevHead, HevHeadConfig
16
+ from .prompting import PROMPT_FORMAT_VERSION, alias_token_ids
17
+ from .schema import Example, Question
18
+
19
+
20
+ # Hub download options that from_pretrained forwards to snapshot_download and HevConfig.from_pretrained.
21
+ HUB_KWARGS = ("revision", "token", "cache_dir", "local_files_only", "force_download")
22
+
23
+
24
+ def _is_local_dir(name: str) -> bool:
25
+ try:
26
+ return Path(name).is_dir()
27
+ except (OSError, ValueError):
28
+ return False
29
+
30
+
31
+ def _vocab_size(backbone) -> int:
32
+ """Backbone vocabulary size, read as hev.features.main reads it for random slot draws (default 262144)."""
33
+ return getattr(getattr(backbone.config, "text_config", None), "vocab_size", 262144)
34
+
35
+
36
+ def _param_device(module) -> torch.device:
37
+ """Device of the module's first parameter, or CPU when it has none."""
38
+ params = module.parameters() if hasattr(module, "parameters") else iter(())
39
+ first = next(params, None)
40
+ return first.device if first is not None else torch.device("cpu")
41
+
42
+
43
+ class HevForDecisions(PreTrainedModel):
44
+ config_class = HevConfig
45
+
46
+ def __init__(self, config: HevConfig, backbone=None, tokenizer=None):
47
+ super().__init__(config)
48
+ members = []
49
+ temps = list(getattr(config, "member_temperatures", None) or [config.temperatures])
50
+ for i in range(int(getattr(config, "n_heads", 1))):
51
+ head = HevHead(HevHeadConfig.from_dict(config.head_config))
52
+ with torch.no_grad():
53
+ head.log_temp.copy_(torch.log(torch.tensor(temps[i] if i < len(temps) else config.temperatures,
54
+ dtype=torch.float32)))
55
+ members.append(head)
56
+ self.head = HevEnsemble(members)
57
+ self.backbone = backbone
58
+ self.tokenizer = tokenizer
59
+ self._cap = None
60
+ self._alias_ids = None
61
+
62
+ @classmethod
63
+ def from_pretrained(cls, pretrained_model_name_or_path, *model_args, backbone_cls=None, tokenizer_cls=None,
64
+ dtype=torch.bfloat16, device_map="auto", load_in_4bit: bool = False, config=None, **kwargs):
65
+ """Load the Hev config, head, frozen backbone and tokenizer from a local folder or a Hub repo.
66
+
67
+ `revision`, `token`, `cache_dir`, `local_files_only` and `force_download` are forwarded to the Hub
68
+ download and to HevConfig.from_pretrained. A HevConfig passed as `config` (AutoModel does this) is used as is.
69
+ """
70
+ hub = {k: kwargs[k] for k in HUB_KWARGS if kwargs.get(k) is not None}
71
+ name = str(pretrained_model_name_or_path)
72
+ path = Path(name)
73
+ from_hub = not path.exists()
74
+ if from_hub:
75
+ from huggingface_hub import snapshot_download
76
+ path = Path(snapshot_download(name, **hub))
77
+ if not isinstance(config, HevConfig):
78
+ config = HevConfig.from_pretrained(str(path), **hub)
79
+ if from_hub:
80
+ config._name_or_path = name # the repo id, not the local snapshot folder
81
+ if backbone_cls is None or tokenizer_cls is None:
82
+ from transformers import AutoConfig, AutoTokenizer, DiffusionGemmaForBlockDiffusion
83
+ backbone_cls = backbone_cls or DiffusionGemmaForBlockDiffusion
84
+ tokenizer_cls = tokenizer_cls or AutoTokenizer
85
+ bb_cfg = AutoConfig.for_model(**config.backbone_config)
86
+ else:
87
+ bb_cfg = None
88
+ bb_kwargs = {"config": bb_cfg, "dtype": dtype, "device_map": device_map}
89
+ if load_in_4bit:
90
+ from transformers import BitsAndBytesConfig
91
+ bb_kwargs["quantization_config"] = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_compute_dtype=dtype)
92
+ backbone = backbone_cls.from_pretrained(str(path), **bb_kwargs).eval()
93
+ tokenizer = tokenizer_cls.from_pretrained(str(path))
94
+ model = cls(config, backbone=backbone, tokenizer=tokenizer)
95
+ if len(model.head.members) == 1 and (path / "hev_head.safetensors").exists():
96
+ model.head.members[0].load_state_dict(load_file(str(path / "hev_head.safetensors")))
97
+ else:
98
+ for i, member in enumerate(model.head.members):
99
+ member.load_state_dict(load_file(str(path / f"hev_head_{i}.safetensors")))
100
+ model.head.to(_param_device(backbone))
101
+ return model.eval()
102
+
103
+ @staticmethod
104
+ def confidence(probs) -> float:
105
+ k = len(probs)
106
+ if k < 2:
107
+ return 1.0
108
+ h = -sum(p * math.log(p) for p in probs if p > 0)
109
+ return max(0.0, min(1.0, 1.0 - h / math.log(k)))
110
+
111
+ def _extra_layers(self) -> dict:
112
+ extra = getattr(self.config, "extra_layers", None) or {}
113
+ return {"encoder": list(extra.get("encoder", [])), "decoder": list(extra.get("decoder", []))}
114
+
115
+ def _ensure_capture(self):
116
+ if self._cap is None:
117
+ self._cap = NormCapture(self.backbone, self.config.encoder_norm, self.config.decoder_norm,
118
+ extra=extra_layer_names({"extra_layers": self._extra_layers()}))
119
+ self._alias_ids = alias_token_ids(self.tokenizer, len(self.config.aliases))
120
+
121
+ @torch.no_grad()
122
+ def decide(self, state: str, questions: dict) -> dict:
123
+ """Answer Jev-shaped questions about `state` with the backbone features and the trained head.
124
+
125
+ The answer slots are seeded exactly as hev.features.extract seeded them in training, by
126
+ `config.seed_mode`: the pad token (0) for "pad", the first alias token for "alias", and an average
127
+ over RANDOM_DRAWS forward passes with random slot tokens (fixed rng seed 1234) for "random".
128
+ `config.slot_token_id` is not read here; seed_mode governs.
129
+ """
130
+ version = getattr(self.config, "prompt_format_version", 1)
131
+ if version != PROMPT_FORMAT_VERSION:
132
+ raise RuntimeError(
133
+ f"this head was trained with prompt format {version}, but the installed hev code builds prompt format "
134
+ f"{PROMPT_FORMAT_VERSION}; load the code revision the head was published with, or retrain the head")
135
+ self._ensure_capture()
136
+ qs = [Question.from_jev(qid, spec) for qid, spec in questions.items()]
137
+ ex = Example(id="request", source="api", state=state, questions=qs, gold={})
138
+ info = {"canvas_length": self.config.canvas_length, "encoder_norm": self.config.encoder_norm,
139
+ "decoder_norm": self.config.decoder_norm, "extra_layers": self._extra_layers()}
140
+ slot_tokens = _slot_token(self.config.seed_mode, self._alias_ids, random.Random(1234),
141
+ _vocab_size(self.backbone))
142
+ feats, rec = encode_example(self.backbone, self.tokenizer, ex, self._cap, info,
143
+ slot_tokens=slot_tokens, alias_ids=self._alias_ids,
144
+ max_tokens=self.config.max_prompt_tokens)
145
+ batch = collate([(rec, feats)]).to(next(self.head.parameters()).device)
146
+ logits = self.head(batch, use_temperature=True)
147
+ answers = {}
148
+ for q, lg in zip(qs, logits):
149
+ probs = torch.softmax(lg.float(), 0).tolist()
150
+ if q.type == "noul":
151
+ answers[q.qid] = {"type": "noul", "noul": probs[0]}
152
+ elif q.type == "choice":
153
+ best = max(range(q.k), key=lambda i: probs[i])
154
+ answers[q.qid] = {"type": "choice", "choice": q.options[best],
155
+ "probabilities": dict(zip(q.options, probs)), "confidence": self.confidence(probs)}
156
+ else:
157
+ answers[q.qid] = {"type": "score", "score": sum(i * p for i, p in enumerate(probs)),
158
+ "probabilities": {str(i): p for i, p in enumerate(probs)},
159
+ "confidence": self.confidence(probs), "legend": list(q.descriptions)}
160
+ name = getattr(self.config, "_name_or_path", "") or ""
161
+ # A local folder name would leak a filesystem path into every response, so only a Hub repo id is reported.
162
+ return {"model": name if name and not _is_local_dir(name) else "hev",
163
+ "answers": answers, "usage": {"input_tokens": rec.n_prompt, "output_tokens": 0}}
prompting.py CHANGED
@@ -1,118 +1,118 @@
1
- """Prompt and canvas construction. Token spans are exact because every piece is tokenised separately."""
2
- from __future__ import annotations
3
-
4
- import random
5
- from dataclasses import dataclass
6
-
7
- from .schema import ALIASES, Example
8
-
9
- # Bump when anything below changes the token ids, option spans or canvas a trained head was fitted on; every
10
- # feature cache must then be re-extracted and the head retrained. tests/test_format_pin.py pins version 1.
11
- PROMPT_FORMAT_VERSION = 1
12
- SENTINEL = "<<HEV_USER_TEXT>>"
13
- HEADER = "State:\n"
14
- QUESTIONS_HEADER = "\nQuestions:\n"
15
- FOOTER = "\nAnswer as JSON with one option letter per question.\n"
16
- MIN_STATE_TOKENS = 16
17
-
18
-
19
- @dataclass
20
- class Piece:
21
- text: str
22
- kind: str # header | state | question | option | footer
23
- qidx: int = -1
24
- oidx: int = -1
25
- alias_idx: int = -1
26
-
27
-
28
- @dataclass
29
- class TokenizedPrompt:
30
- ids: list[int]
31
- option_spans: list[list[tuple[int, int]]]
32
- option_alias: list[list[int]]
33
- truncated: bool
34
-
35
-
36
- def render_pieces(example: Example, option_order: dict[int, list[int]] | None = None) -> list[Piece]:
37
- pieces = [Piece(HEADER, "header"), Piece(example.state.strip() + "\n", "state"),
38
- Piece(QUESTIONS_HEADER, "header")]
39
- for qi, q in enumerate(example.questions):
40
- pieces.append(Piece(f"{q.qid} ({q.type}): {q.instructions}\n", "question", qi))
41
- order = (option_order or {}).get(qi) or list(range(q.k))
42
- for pos, oi in enumerate(order):
43
- pieces.append(Piece(f" {ALIASES[pos]}: {q.descriptions[oi]}\n", "option", qi, oi, pos))
44
- pieces.append(Piece(FOOTER, "footer"))
45
- return pieces
46
-
47
-
48
- def chat_affixes(tok) -> tuple[list[int], list[int]]:
49
- text = tok.apply_chat_template([{"role": "user", "content": SENTINEL}],
50
- tokenize=False, add_generation_prompt=True)
51
- pre, post = text.split(SENTINEL)
52
- return tok.encode(pre, add_special_tokens=False), tok.encode(post, add_special_tokens=False)
53
-
54
-
55
- def tokenize_prompt(example: Example, tok, max_tokens: int = 1024,
56
- option_order: dict[int, list[int]] | None = None) -> TokenizedPrompt:
57
- pre, post = chat_affixes(tok)
58
- pieces = render_pieces(example, option_order)
59
- enc = [tok.encode(p.text, add_special_tokens=False) for p in pieces]
60
- budget = max_tokens - len(pre) - len(post)
61
- fixed = sum(len(e) for e, p in zip(enc, pieces) if p.kind != "state")
62
- truncated = False
63
- for i, p in enumerate(pieces):
64
- if p.kind == "state" and fixed + len(enc[i]) > budget:
65
- keep = max(budget - fixed, MIN_STATE_TOKENS)
66
- half = keep // 2
67
- enc[i] = enc[i][:half] + enc[i][-(keep - half):]
68
- truncated = True
69
- ids = list(pre)
70
- spans = [[(0, 0)] * q.k for q in example.questions]
71
- alias = [[0] * q.k for q in example.questions]
72
- for p, e in zip(pieces, enc):
73
- start = len(ids)
74
- ids.extend(e)
75
- if p.kind == "option":
76
- spans[p.qidx][p.oidx] = (start, len(ids))
77
- alias[p.qidx][p.oidx] = p.alias_idx
78
- ids.extend(post)
79
- if len(ids) > max_tokens:
80
- raise ValueError(f"{example.id}: prompt has {len(ids)} tokens even after truncation (max {max_tokens})")
81
- return TokenizedPrompt(ids=ids, option_spans=spans, option_alias=alias, truncated=truncated)
82
-
83
-
84
- def canvas_ids(example: Example, tok, slot_token_id: int, canvas_length: int,
85
- eos_id: int = 1) -> tuple[list[int], list[int]]:
86
- ids: list[int] = []
87
- slots: list[int] = []
88
- for qi, q in enumerate(example.questions):
89
- prefix = '{"' if qi == 0 else ', "'
90
- ids.extend(tok.encode(prefix + q.qid + '": "', add_special_tokens=False))
91
- slots.append(len(ids))
92
- ids.append(slot_token_id)
93
- ids.extend(tok.encode('"', add_special_tokens=False))
94
- ids.extend(tok.encode("}", add_special_tokens=False))
95
- if len(ids) > canvas_length:
96
- raise ValueError(f"{example.id}: canvas needs {len(ids)} tokens, canvas_length is {canvas_length}")
97
- ids.extend([eos_id] * (canvas_length - len(ids)))
98
- return ids, slots
99
-
100
-
101
- def alias_token_ids(tok, n: int) -> list[int]:
102
- out = []
103
- for a in ALIASES[:n]:
104
- ids = tok.encode(a, add_special_tokens=False)
105
- if len(ids) != 1:
106
- raise ValueError(f"alias {a!r} is not a single token: {ids}")
107
- out.append(ids[0])
108
- return out
109
-
110
-
111
- def shuffled_order(example: Example, rng: random.Random) -> dict[int, list[int]]:
112
- order = {}
113
- for qi, q in enumerate(example.questions):
114
- if q.type == "choice":
115
- perm = list(range(q.k))
116
- rng.shuffle(perm)
117
- order[qi] = perm
118
- return order
 
1
+ """Prompt and canvas construction. Token spans are exact because every piece is tokenised separately."""
2
+ from __future__ import annotations
3
+
4
+ import random
5
+ from dataclasses import dataclass
6
+
7
+ from .schema import ALIASES, Example
8
+
9
+ # Bump when anything below changes the token ids, option spans or canvas a trained head was fitted on; every
10
+ # feature cache must then be re-extracted and the head retrained. tests/test_format_pin.py pins version 1.
11
+ PROMPT_FORMAT_VERSION = 1
12
+ SENTINEL = "<<HEV_USER_TEXT>>"
13
+ HEADER = "State:\n"
14
+ QUESTIONS_HEADER = "\nQuestions:\n"
15
+ FOOTER = "\nAnswer as JSON with one option letter per question.\n"
16
+ MIN_STATE_TOKENS = 16
17
+
18
+
19
+ @dataclass
20
+ class Piece:
21
+ text: str
22
+ kind: str # header | state | question | option | footer
23
+ qidx: int = -1
24
+ oidx: int = -1
25
+ alias_idx: int = -1
26
+
27
+
28
+ @dataclass
29
+ class TokenizedPrompt:
30
+ ids: list[int]
31
+ option_spans: list[list[tuple[int, int]]]
32
+ option_alias: list[list[int]]
33
+ truncated: bool
34
+
35
+
36
+ def render_pieces(example: Example, option_order: dict[int, list[int]] | None = None) -> list[Piece]:
37
+ pieces = [Piece(HEADER, "header"), Piece(example.state.strip() + "\n", "state"),
38
+ Piece(QUESTIONS_HEADER, "header")]
39
+ for qi, q in enumerate(example.questions):
40
+ pieces.append(Piece(f"{q.qid} ({q.type}): {q.instructions}\n", "question", qi))
41
+ order = (option_order or {}).get(qi) or list(range(q.k))
42
+ for pos, oi in enumerate(order):
43
+ pieces.append(Piece(f" {ALIASES[pos]}: {q.descriptions[oi]}\n", "option", qi, oi, pos))
44
+ pieces.append(Piece(FOOTER, "footer"))
45
+ return pieces
46
+
47
+
48
+ def chat_affixes(tok) -> tuple[list[int], list[int]]:
49
+ text = tok.apply_chat_template([{"role": "user", "content": SENTINEL}],
50
+ tokenize=False, add_generation_prompt=True)
51
+ pre, post = text.split(SENTINEL)
52
+ return tok.encode(pre, add_special_tokens=False), tok.encode(post, add_special_tokens=False)
53
+
54
+
55
+ def tokenize_prompt(example: Example, tok, max_tokens: int = 1024,
56
+ option_order: dict[int, list[int]] | None = None) -> TokenizedPrompt:
57
+ pre, post = chat_affixes(tok)
58
+ pieces = render_pieces(example, option_order)
59
+ enc = [tok.encode(p.text, add_special_tokens=False) for p in pieces]
60
+ budget = max_tokens - len(pre) - len(post)
61
+ fixed = sum(len(e) for e, p in zip(enc, pieces) if p.kind != "state")
62
+ truncated = False
63
+ for i, p in enumerate(pieces):
64
+ if p.kind == "state" and fixed + len(enc[i]) > budget:
65
+ keep = max(budget - fixed, MIN_STATE_TOKENS)
66
+ half = keep // 2
67
+ enc[i] = enc[i][:half] + enc[i][-(keep - half):]
68
+ truncated = True
69
+ ids = list(pre)
70
+ spans = [[(0, 0)] * q.k for q in example.questions]
71
+ alias = [[0] * q.k for q in example.questions]
72
+ for p, e in zip(pieces, enc):
73
+ start = len(ids)
74
+ ids.extend(e)
75
+ if p.kind == "option":
76
+ spans[p.qidx][p.oidx] = (start, len(ids))
77
+ alias[p.qidx][p.oidx] = p.alias_idx
78
+ ids.extend(post)
79
+ if len(ids) > max_tokens:
80
+ raise ValueError(f"{example.id}: prompt has {len(ids)} tokens even after truncation (max {max_tokens})")
81
+ return TokenizedPrompt(ids=ids, option_spans=spans, option_alias=alias, truncated=truncated)
82
+
83
+
84
+ def canvas_ids(example: Example, tok, slot_token_id: int, canvas_length: int,
85
+ eos_id: int = 1) -> tuple[list[int], list[int]]:
86
+ ids: list[int] = []
87
+ slots: list[int] = []
88
+ for qi, q in enumerate(example.questions):
89
+ prefix = '{"' if qi == 0 else ', "'
90
+ ids.extend(tok.encode(prefix + q.qid + '": "', add_special_tokens=False))
91
+ slots.append(len(ids))
92
+ ids.append(slot_token_id)
93
+ ids.extend(tok.encode('"', add_special_tokens=False))
94
+ ids.extend(tok.encode("}", add_special_tokens=False))
95
+ if len(ids) > canvas_length:
96
+ raise ValueError(f"{example.id}: canvas needs {len(ids)} tokens, canvas_length is {canvas_length}")
97
+ ids.extend([eos_id] * (canvas_length - len(ids)))
98
+ return ids, slots
99
+
100
+
101
+ def alias_token_ids(tok, n: int) -> list[int]:
102
+ out = []
103
+ for a in ALIASES[:n]:
104
+ ids = tok.encode(a, add_special_tokens=False)
105
+ if len(ids) != 1:
106
+ raise ValueError(f"alias {a!r} is not a single token: {ids}")
107
+ out.append(ids[0])
108
+ return out
109
+
110
+
111
+ def shuffled_order(example: Example, rng: random.Random) -> dict[int, list[int]]:
112
+ order = {}
113
+ for qi, q in enumerate(example.questions):
114
+ if q.type == "choice":
115
+ perm = list(range(q.k))
116
+ rng.shuffle(perm)
117
+ order[qi] = perm
118
+ return order
schema.py CHANGED
@@ -1,104 +1,104 @@
1
- """Request schema shared by data loading, prompting, the head and the client."""
2
- from __future__ import annotations
3
-
4
- import math
5
- from dataclasses import asdict, dataclass, field
6
- from typing import Optional
7
-
8
- QUESTION_TYPES = ("noul", "choice", "score")
9
- TYPE_INDEX = {t: i for i, t in enumerate(QUESTION_TYPES)}
10
- MAX_CHOICE_OPTIONS = 64
11
- MAX_SCORE_LEVELS = 10
12
- NOUL_OPTIONS = ["true", "false"]
13
- NOUL_DEFAULT_DESCRIPTIONS = ["yes, the statement is true", "no, the statement is false"]
14
- ALIASES = (
15
- [chr(c) for c in range(ord("A"), ord("Z") + 1)]
16
- + [chr(c) for c in range(ord("a"), ord("z") + 1)]
17
- + ["A" + chr(c) for c in range(ord("A"), ord("L") + 1)]
18
- )
19
-
20
-
21
- @dataclass
22
- class Question:
23
- qid: str
24
- type: str
25
- instructions: str
26
- options: list[str]
27
- descriptions: list[str]
28
-
29
- @classmethod
30
- def from_jev(cls, qid: str, spec: dict) -> "Question":
31
- qtype = spec.get("type")
32
- if qtype not in QUESTION_TYPES:
33
- raise ValueError(f"{qid}: unknown question type {qtype!r}")
34
- instructions = str(spec.get("instructions", "")).strip()
35
- criteria = spec.get("criteria")
36
- if qtype == "noul":
37
- options = list(NOUL_OPTIONS)
38
- descriptions = list(NOUL_DEFAULT_DESCRIPTIONS)
39
- if criteria is not None:
40
- if not isinstance(criteria, dict) or set(criteria) - set(NOUL_OPTIONS):
41
- raise ValueError(f"{qid}: noul criteria must be a mapping with 'true' and 'false' keys")
42
- descriptions = [str(criteria.get("true") or descriptions[0]),
43
- str(criteria.get("false") or descriptions[1])]
44
- elif qtype == "choice":
45
- if not isinstance(criteria, dict):
46
- raise ValueError(f"{qid}: choice needs a criteria mapping")
47
- options = [str(k) for k in criteria.keys()]
48
- descriptions = [str(v) if v else str(k) for k, v in criteria.items()]
49
- if not (2 <= len(options) <= MAX_CHOICE_OPTIONS):
50
- raise ValueError(f"{qid}: choice needs 2..{MAX_CHOICE_OPTIONS} options, got {len(options)}")
51
- else:
52
- if not isinstance(criteria, list):
53
- raise ValueError(f"{qid}: score needs a criteria list")
54
- if not (2 <= len(criteria) <= MAX_SCORE_LEVELS):
55
- raise ValueError(f"{qid}: score needs 2..{MAX_SCORE_LEVELS} levels, got {len(criteria)}")
56
- options = [str(i) for i in range(len(criteria))]
57
- descriptions = [str(c) for c in criteria]
58
- if len(set(options)) != len(options):
59
- raise ValueError(f"{qid}: duplicate options")
60
- return cls(qid=qid, type=qtype, instructions=instructions,
61
- options=options, descriptions=descriptions)
62
-
63
- @property
64
- def k(self) -> int:
65
- return len(self.options)
66
-
67
-
68
- def argmax(values) -> int:
69
- """Index of the first maximum, so ties go to the lowest index. Shared by evaluation and the zero-shot read."""
70
- return max(range(len(values)), key=lambda i: values[i])
71
-
72
-
73
- def align_distribution(probs: dict, options: list[str]) -> list[float]:
74
- vals = [float(probs.get(o, 0.0)) for o in options]
75
- if any(v < 0 or not math.isfinite(v) for v in vals):
76
- raise ValueError("distribution values must be finite and non-negative")
77
- total = sum(vals)
78
- if total <= 0:
79
- raise ValueError("distribution has no mass on the listed options")
80
- return [v / total for v in vals]
81
-
82
-
83
- @dataclass
84
- class Example:
85
- id: str
86
- source: str
87
- state: str
88
- questions: list[Question]
89
- gold: dict[str, Optional[list[float]]]
90
- meta: dict = field(default_factory=dict)
91
-
92
- def to_dict(self) -> dict:
93
- return {
94
- "id": self.id, "source": self.source, "state": self.state,
95
- "questions": [asdict(q) for q in self.questions],
96
- "gold": self.gold, "meta": self.meta,
97
- }
98
-
99
- @classmethod
100
- def from_dict(cls, d: dict) -> "Example":
101
- return cls(id=d["id"], source=d["source"], state=d["state"],
102
- questions=[Question(**q) for q in d["questions"]],
103
- gold={k: (list(v) if v is not None else None) for k, v in d["gold"].items()},
104
- meta=dict(d.get("meta", {})))
 
1
+ """Request schema shared by data loading, prompting, the head and the client."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Optional
7
+
8
+ QUESTION_TYPES = ("noul", "choice", "score")
9
+ TYPE_INDEX = {t: i for i, t in enumerate(QUESTION_TYPES)}
10
+ MAX_CHOICE_OPTIONS = 64
11
+ MAX_SCORE_LEVELS = 10
12
+ NOUL_OPTIONS = ["true", "false"]
13
+ NOUL_DEFAULT_DESCRIPTIONS = ["yes, the statement is true", "no, the statement is false"]
14
+ ALIASES = (
15
+ [chr(c) for c in range(ord("A"), ord("Z") + 1)]
16
+ + [chr(c) for c in range(ord("a"), ord("z") + 1)]
17
+ + ["A" + chr(c) for c in range(ord("A"), ord("L") + 1)]
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class Question:
23
+ qid: str
24
+ type: str
25
+ instructions: str
26
+ options: list[str]
27
+ descriptions: list[str]
28
+
29
+ @classmethod
30
+ def from_jev(cls, qid: str, spec: dict) -> "Question":
31
+ qtype = spec.get("type")
32
+ if qtype not in QUESTION_TYPES:
33
+ raise ValueError(f"{qid}: unknown question type {qtype!r}")
34
+ instructions = str(spec.get("instructions", "")).strip()
35
+ criteria = spec.get("criteria")
36
+ if qtype == "noul":
37
+ options = list(NOUL_OPTIONS)
38
+ descriptions = list(NOUL_DEFAULT_DESCRIPTIONS)
39
+ if criteria is not None:
40
+ if not isinstance(criteria, dict) or set(criteria) - set(NOUL_OPTIONS):
41
+ raise ValueError(f"{qid}: noul criteria must be a mapping with 'true' and 'false' keys")
42
+ descriptions = [str(criteria.get("true") or descriptions[0]),
43
+ str(criteria.get("false") or descriptions[1])]
44
+ elif qtype == "choice":
45
+ if not isinstance(criteria, dict):
46
+ raise ValueError(f"{qid}: choice needs a criteria mapping")
47
+ options = [str(k) for k in criteria.keys()]
48
+ descriptions = [str(v) if v else str(k) for k, v in criteria.items()]
49
+ if not (2 <= len(options) <= MAX_CHOICE_OPTIONS):
50
+ raise ValueError(f"{qid}: choice needs 2..{MAX_CHOICE_OPTIONS} options, got {len(options)}")
51
+ else:
52
+ if not isinstance(criteria, list):
53
+ raise ValueError(f"{qid}: score needs a criteria list")
54
+ if not (2 <= len(criteria) <= MAX_SCORE_LEVELS):
55
+ raise ValueError(f"{qid}: score needs 2..{MAX_SCORE_LEVELS} levels, got {len(criteria)}")
56
+ options = [str(i) for i in range(len(criteria))]
57
+ descriptions = [str(c) for c in criteria]
58
+ if len(set(options)) != len(options):
59
+ raise ValueError(f"{qid}: duplicate options")
60
+ return cls(qid=qid, type=qtype, instructions=instructions,
61
+ options=options, descriptions=descriptions)
62
+
63
+ @property
64
+ def k(self) -> int:
65
+ return len(self.options)
66
+
67
+
68
+ def argmax(values) -> int:
69
+ """Index of the first maximum, so ties go to the lowest index. Shared by evaluation and the zero-shot read."""
70
+ return max(range(len(values)), key=lambda i: values[i])
71
+
72
+
73
+ def align_distribution(probs: dict, options: list[str]) -> list[float]:
74
+ vals = [float(probs.get(o, 0.0)) for o in options]
75
+ if any(v < 0 or not math.isfinite(v) for v in vals):
76
+ raise ValueError("distribution values must be finite and non-negative")
77
+ total = sum(vals)
78
+ if total <= 0:
79
+ raise ValueError("distribution has no mass on the listed options")
80
+ return [v / total for v in vals]
81
+
82
+
83
+ @dataclass
84
+ class Example:
85
+ id: str
86
+ source: str
87
+ state: str
88
+ questions: list[Question]
89
+ gold: dict[str, Optional[list[float]]]
90
+ meta: dict = field(default_factory=dict)
91
+
92
+ def to_dict(self) -> dict:
93
+ return {
94
+ "id": self.id, "source": self.source, "state": self.state,
95
+ "questions": [asdict(q) for q in self.questions],
96
+ "gold": self.gold, "meta": self.meta,
97
+ }
98
+
99
+ @classmethod
100
+ def from_dict(cls, d: dict) -> "Example":
101
+ return cls(id=d["id"], source=d["source"], state=d["state"],
102
+ questions=[Question(**q) for q in d["questions"]],
103
+ gold={k: (list(v) if v is not None else None) for k, v in d["gold"].items()},
104
+ meta=dict(d.get("meta", {})))