HallD commited on
Commit
2afdef9
·
verified ·
1 Parent(s): caaf3a7

yev 0.2.0

Browse files
README.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ tags:
3
+ - yev
4
+ - decision-model
5
+ ---
6
+
7
+ # yev
8
+
9
+ The client for [Yev-Decision-1](https://huggingface.co/HallD/Yev-Decision-1), a local **System One**
10
+ decision model. Give it a state, which can be text, images or both, plus named questions. It returns
11
+ a typed probability distribution for each one: a **Choice** between options, a **Score** on an
12
+ ordered scale, or a **Noul** (yes or no). It generates no text and nothing leaves the machine.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ uv add "yev[inference] @ git+https://huggingface.co/HallD/yev"
18
+ ```
19
+
20
+ `pip install "yev[inference] @ git+https://huggingface.co/HallD/yev"` works the same way. Python 3.13
21
+ or later.
22
+
23
+ | Extra | Brings | For |
24
+ | --- | --- | --- |
25
+ | *(none)* | pydantic, huggingface-hub, numpy | Question and answer types only |
26
+ | `inference` | torch, transformers, peft, accelerate, pillow | Answering questions with a model |
27
+ | `quantized` | `inference` plus bitsandbytes | `quantize4bit=True` on a CUDA GPU |
28
+
29
+ ## Use
30
+
31
+ ```python
32
+ from yev import Choice, Image, Noul, Score, YevClient
33
+
34
+ with YevClient() as client: # YEV_DEFAULT_MODEL, else HallD/Yev-Decision-1
35
+ response = client.system_one(
36
+ state="I was charged twice. Please fix this ASAP.",
37
+ questions={
38
+ "billing": Noul(instructions="Is this ticket about billing?"),
39
+ "tone": Choice(instructions="What is the customer's tone?",
40
+ criteria={"calm": None, "frustrated": None, "angry": None}),
41
+ "urgency": Score(instructions="How urgent is this ticket?",
42
+ criteria=["can wait", "this week", "today"]),
43
+ },
44
+ )
45
+ print(response.nouls["billing"].noul, response.choices["tone"].probabilities)
46
+ ```
47
+
48
+ The first call downloads the model and its backbone,
49
+ [`Qwen/Qwen3.5-4B-Base`](https://huggingface.co/Qwen/Qwen3.5-4B-Base) at a pinned revision, into the
50
+ Hugging Face cache. After that it runs offline (`HF_HUB_OFFLINE=1`). The backbone runs in bfloat16;
51
+ a GPU with 12 GB holds it.
52
+
53
+ The call shape follows the TypeSafe SDK (0.7.5): `YevClient` and `AsyncYevClient` take its
54
+ `system_one(state, questions, model=, assets=, response_model=)`, its question objects (TypeSafe's
55
+ own are accepted), and return its answer and response types. It runs in-process, so there are no
56
+ API keys, retries or timeouts; Box questions are refused, and a Score takes at most 10 levels.
57
+
58
+ `yev --export HallD/Yev-Decision-1 --state "I was charged twice." --noul billing "Is this ticket about billing?"`
59
+ asks from the command line.
60
+
61
+ ## JevBench
62
+
63
+ `examples/jevbench_adapter.py` is an adapter for the
64
+ [JevBench](https://github.com/fstandhartinger/jevbench) harness: it sends the harness's own question
65
+ to `system_one` and returns the probabilities.
examples/jevbench_adapter.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A JevBench adapter for yev: the harness's own question dict goes to ``system_one`` unchanged.
2
+
3
+ from examples.jevbench_adapter import YevAdapter
4
+ from jevbench.adapters.conformance import check_adapter
5
+ print(check_adapter(YevAdapter("HallD/Yev-Decision-1")).summary())
6
+ """
7
+
8
+ import time
9
+
10
+ from jevbench.adapters.base import DecisionResult, build_question
11
+
12
+ from yev import Yev
13
+
14
+
15
+ class YevAdapter:
16
+ name = "yev_local"
17
+ cost_basis = "local_no_provider_tariff"
18
+ price_input_per_m = None
19
+ price_output_per_m = None
20
+
21
+ def __init__(self, model="HallD/Yev-Decision-1"):
22
+ self.model, self.client = model, Yev(model, lazy=False)
23
+
24
+ def reserve_estimate(self, task):
25
+ return None
26
+
27
+ def run(self, task):
28
+ result = DecisionResult(adapter=self.name, ok=False, probs_source="native", model=self.model)
29
+ question = build_question(task)
30
+ result.request_body = {"state": task.state, "questions": {"decision": question}}
31
+ started = time.perf_counter()
32
+ try:
33
+ response = self.client.system_one(task.state, {"decision": question}, windows="mean")
34
+ except Exception as error: # a failed answer is a failed attempt
35
+ result.latency_s = time.perf_counter() - started
36
+ result.error = f"{type(error).__name__}: {str(error)[:300]}"
37
+ return result
38
+ result.latency_s = time.perf_counter() - started
39
+ kind = task.question["type"]
40
+ if kind == "noul":
41
+ p = float(response.nouls["decision"].noul)
42
+ result.probs = {"yes": p, "no": 1.0 - p}
43
+ else:
44
+ answer = response.choices["decision"] if kind == "choice" else response.scores["decision"]
45
+ result.probs = {str(k): float(v) for k, v in answer.probabilities.items()}
46
+ result.ok = True
47
+ result.raw = {"runtime": {"probability_origin": "native-softmax", "windows": "mean"}}
48
+ return result
pyproject.toml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "yev"
3
+ version = "0.2.0"
4
+ description = "A local System One decision model: typed Choice, Score and Noul probabilities, no generated text."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "huggingface-hub>=0.36.2",
9
+ "numpy>=1.26",
10
+ "pydantic>=2.9,<3",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ inference = [
15
+ "torch>=2.6,<3",
16
+ "transformers>=5.17,<6",
17
+ "peft>=0.21,<0.22",
18
+ "accelerate>=1.12,<2",
19
+ "pillow>=11",
20
+ ]
21
+ quantized = [
22
+ "yev[inference]",
23
+ "bitsandbytes>=0.50",
24
+ ]
25
+
26
+ [project.scripts]
27
+ yev = "yev.cli:main"
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.12.17,<0.13.0"]
31
+ build-backend = "uv_build"
32
+
33
+ [tool.uv.build-backend]
34
+ module-name = "yev"
35
+ module-root = "src"
src/yev/__init__.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """yev: a local System One decision model with the TypeSafe SDK's call shape.
2
+
3
+ The package holds two layers:
4
+
5
+ - The client surface, the TypeSafe SDK's (0.7.5) ported (``YevClient`` and ``AsyncYevClient``, the
6
+ questions, ``Image`` and the answer types), which answers ``system_one(state, questions)`` from a
7
+ locally exported model.
8
+ - ``yev.training``: the model, its loading and export, which the client reads an export with.
9
+
10
+ Importing this module does not load PyTorch; the training stack is imported on first use.
11
+ """
12
+
13
+ from . import constants as constants
14
+ from .answers import (
15
+ Answer,
16
+ BoxAnswer,
17
+ BoxCoordinates,
18
+ ChoiceAnswer,
19
+ ListModelsResponse,
20
+ ModelMetadata,
21
+ NoulAnswer,
22
+ ScoreAnswer,
23
+ SystemOneResponse,
24
+ Usage,
25
+ )
26
+ from .client import AsyncModels, AsyncYevClient, Models, Yev, YevClient
27
+ from .errors import YevError, YevModelNotFoundError, YevResponseValidationError
28
+ from .primitives import (
29
+ CHOICE_LIMITS,
30
+ SCORE_LIMITS,
31
+ Asset,
32
+ Box,
33
+ BoxModel,
34
+ Choice,
35
+ ChoiceModel,
36
+ Content,
37
+ ContentValue,
38
+ Image,
39
+ JSONContent,
40
+ JSONValue,
41
+ Noul,
42
+ NoulCriteria,
43
+ NoulModel,
44
+ Question,
45
+ QuestionModel,
46
+ Questions,
47
+ Score,
48
+ ScoreModel,
49
+ normalize_question,
50
+ normalize_questions,
51
+ )
52
+
53
+ __version__ = "0.2.0"
54
+
55
+ __all__ = [
56
+ "Answer",
57
+ "Asset",
58
+ "AsyncModels",
59
+ "AsyncYevClient",
60
+ "Box",
61
+ "BoxAnswer",
62
+ "BoxCoordinates",
63
+ "BoxModel",
64
+ "CHOICE_LIMITS",
65
+ "Choice",
66
+ "ChoiceAnswer",
67
+ "ChoiceModel",
68
+ "Content",
69
+ "ContentValue",
70
+ "Image",
71
+ "JSONContent",
72
+ "JSONValue",
73
+ "ListModelsResponse",
74
+ "ModelMetadata",
75
+ "Models",
76
+ "Noul",
77
+ "NoulAnswer",
78
+ "NoulCriteria",
79
+ "NoulModel",
80
+ "Question",
81
+ "QuestionModel",
82
+ "Questions",
83
+ "SCORE_LIMITS",
84
+ "Score",
85
+ "ScoreAnswer",
86
+ "ScoreModel",
87
+ "SystemOneResponse",
88
+ "Usage",
89
+ "Yev",
90
+ "YevClient",
91
+ "YevError",
92
+ "YevModelNotFoundError",
93
+ "YevResponseValidationError",
94
+ "__version__",
95
+ "constants",
96
+ "normalize_question",
97
+ "normalize_questions",
98
+ ]
src/yev/answers.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed answers and response objects, ported from the TypeSafe SDK's (0.7.5) response types.
2
+
3
+ The same classes, fields and accessors (``response.nouls``, ``.choices``, ``.scores``, ``.boxes``),
4
+ immutable and tolerant of unknown fields. yev never returns a ``BoxAnswer`` (it has no box head);
5
+ the type is here for ``response_model``s written against the TypeSafe SDK. yev adds ``SystemOneResponse.elapsed_seconds``, the time the
6
+ model took, and its ``Usage.output_tokens`` is always 0: nothing is generated.
7
+ """
8
+
9
+ from functools import cached_property
10
+ from typing import Annotated, Any, Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+
15
+ class _Schema(BaseModel):
16
+ model_config = ConfigDict(extra="ignore", frozen=True)
17
+
18
+
19
+ class NoulAnswer(_Schema):
20
+ """A yes/no answer."""
21
+
22
+ type: Literal["noul"] = "noul"
23
+ noul: float
24
+ """Probability of a yes answer or a true statement, from 0 to 1."""
25
+
26
+
27
+ class ChoiceAnswer(_Schema):
28
+ """A selected label and its probabilities."""
29
+
30
+ type: Literal["choice"] = "choice"
31
+ choice: str
32
+ """The name of the choice with the highest probability among the question's criteria."""
33
+ confidence: float
34
+ """Confidence in the selected choice, from 0 to 1."""
35
+ probabilities: dict[str, float]
36
+ """Probability of each choice in criteria, keyed by choice name; values sum to about 1."""
37
+
38
+
39
+ class ScoreAnswer(_Schema):
40
+ """An expected score with its rubric and probabilities."""
41
+
42
+ type: Literal["score"] = "score"
43
+ score: float
44
+ """Expected score: the probability-weighted average of the rubric levels."""
45
+ confidence: float
46
+ """Confidence in the score, from 0 to 1."""
47
+ legend: dict[int, str | dict[str, Any] | list[Any]]
48
+ """Rubric descriptions keyed by integer score."""
49
+ probabilities: dict[int, float]
50
+ """Probabilities keyed by integer score."""
51
+
52
+
53
+ class BoxCoordinates(_Schema):
54
+ """A box's edges as fractions of the image's height (``top``, ``bottom``) and width (``left``, ``right``)."""
55
+
56
+ top: float
57
+ bottom: float
58
+ left: float
59
+ right: float
60
+
61
+
62
+ class BoxAnswer(_Schema):
63
+ """A located box with its confidence."""
64
+
65
+ type: Literal["box"] = "box"
66
+ box: BoxCoordinates
67
+ """The box's edges as fractions of the image's height and width, from 0 to 1."""
68
+ confidence: float | None
69
+ """Confidence for the box, from 0 to 1, or ``None`` when the model does not provide it."""
70
+
71
+
72
+ Answer = Annotated[NoulAnswer | ChoiceAnswer | ScoreAnswer | BoxAnswer, Field(discriminator="type")]
73
+ """An answer to a single question, identified by its ``type``."""
74
+
75
+
76
+ class Usage(_Schema):
77
+ """Token counts for a request."""
78
+
79
+ input_tokens: int | None = None
80
+ """Number of input tokens the model read."""
81
+ output_tokens: int | None = 0
82
+ """Always 0: yev generates no text."""
83
+
84
+
85
+ class SystemOneResponse(_Schema):
86
+ """Answers grouped by question type with model and usage metadata.
87
+
88
+ A subclass that declares fields named after questions gets those answers as attributes when it is
89
+ passed as ``response_model``, as in the TypeSafe SDK.
90
+ """
91
+
92
+ model: str
93
+ """The model used to answer the request."""
94
+ usage: Usage
95
+ """Token usage for the request."""
96
+ answers: dict[str, Answer] = Field(default_factory=dict)
97
+ """All answer objects keyed by question name."""
98
+ elapsed_seconds: float | None = None
99
+ """Seconds the model took to answer (for a batch, the whole batch)."""
100
+
101
+ @cached_property
102
+ def nouls(self) -> dict[str, NoulAnswer]:
103
+ """Yes/no answers keyed by question name."""
104
+ return {name: answer for name, answer in self.answers.items() if isinstance(answer, NoulAnswer)}
105
+
106
+ @cached_property
107
+ def choices(self) -> dict[str, ChoiceAnswer]:
108
+ """Choice answers keyed by question name."""
109
+ return {name: answer for name, answer in self.answers.items() if isinstance(answer, ChoiceAnswer)}
110
+
111
+ @cached_property
112
+ def scores(self) -> dict[str, ScoreAnswer]:
113
+ """Score answers keyed by question name."""
114
+ return {name: answer for name, answer in self.answers.items() if isinstance(answer, ScoreAnswer)}
115
+
116
+ @cached_property
117
+ def boxes(self) -> dict[str, BoxAnswer]:
118
+ """Box answers keyed by question name; always empty from yev."""
119
+ return {name: answer for name, answer in self.answers.items() if isinstance(answer, BoxAnswer)}
120
+
121
+
122
+ class ModelMetadata(_Schema):
123
+ """Metadata describing a single available model."""
124
+
125
+ name: str
126
+ """Model name, as answers report it."""
127
+ description: str
128
+ """What the model reads: its backbone, token budget and whether it takes images."""
129
+ release_date: str
130
+ """The date the export was written or downloaded, formatted as YYYY-MM-DD."""
131
+
132
+
133
+ class ListModelsResponse(_Schema):
134
+ """The models a client answers with."""
135
+
136
+ models: tuple[ModelMetadata, ...]
137
+ """The available models."""
src/yev/cli.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``yev`` command line: ask an exported model Choice, Score and Noul questions.
2
+
3
+ yev --export path/to/export --state "I was charged twice." \\
4
+ --noul billing "Is this ticket about billing?" \\
5
+ --choice tone "What is the tone?" "calm,frustrated,angry" \\
6
+ --score urgency "How urgent?" "can wait|this week|today"
7
+
8
+ yev --export path/to/export --request request.json --round
9
+ yev --export path/to/export --interactive
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ from pathlib import Path
15
+ import sys
16
+
17
+ from .client import Yev
18
+ from .errors import YevError
19
+ from .primitives import Choice, Image, Noul, Score
20
+
21
+
22
+ def parse_choice_criteria(text):
23
+ """``"a,b,c"`` or ``"a=description,b=description"`` into an ordered criteria map."""
24
+ criteria = {}
25
+ for part in text.split(","):
26
+ part = part.strip()
27
+ if not part:
28
+ continue
29
+ label, _, description = part.partition("=")
30
+ criteria[label.strip()] = description.strip() or None
31
+ if len(criteria) < 2:
32
+ raise YevError("A Choice needs at least two comma-separated options")
33
+ return criteria
34
+
35
+
36
+ def parse_score_levels(text):
37
+ """Pipe-separated level descriptions, lowest first."""
38
+ levels = [level.strip() for level in text.split("|") if level.strip()]
39
+ if not 2 <= len(levels) <= 10:
40
+ raise YevError("A Score needs 2 to 10 levels separated by '|'")
41
+ return levels
42
+
43
+
44
+ def build_request(arguments):
45
+ """A ``{"state": ..., "questions": {...}}`` request from a JSON file or the question flags."""
46
+ if arguments.request is not None:
47
+ stream = sys.stdin if str(arguments.request) == "-" else open(arguments.request, encoding="utf-8")
48
+ with stream:
49
+ request = json.load(stream)
50
+ if not isinstance(request, dict) or "state" not in request or not isinstance(request.get("questions"), dict):
51
+ raise YevError("Request JSON needs 'state' and a 'questions' object")
52
+ return request
53
+ if arguments.state is None and not arguments.image:
54
+ raise YevError("--state or --image is required unless --request or --interactive is used")
55
+ questions = {}
56
+ for identifier, instructions, options in arguments.choice or []:
57
+ questions[identifier] = Choice(instructions=instructions, criteria=parse_choice_criteria(options))
58
+ for identifier, instructions, levels in arguments.score or []:
59
+ questions[identifier] = Score(instructions=instructions, criteria=parse_score_levels(levels))
60
+ for identifier, instructions in arguments.noul or []:
61
+ questions[identifier] = Noul(instructions=instructions)
62
+ if not questions:
63
+ raise YevError("Add at least one --choice, --score or --noul question")
64
+ if arguments.image:
65
+ try:
66
+ images = [Image(path) for path in arguments.image]
67
+ except OSError as error:
68
+ raise YevError(f"Cannot read image: {error}") from error
69
+ return {"state": [arguments.state, *images] if arguments.state else images[0] if len(images) == 1 else images,
70
+ "questions": questions}
71
+ return {"state": arguments.state, "questions": questions}
72
+
73
+
74
+ def render(response, digits=None):
75
+ """The response as a JSON-ready dict, optionally rounded to the API's hundredth grid."""
76
+ payload = response.model_dump()
77
+ if digits is None:
78
+ return payload
79
+ for answer in payload["answers"].values():
80
+ for key in ("noul", "score", "confidence"):
81
+ if key in answer:
82
+ answer[key] = round(answer[key], digits)
83
+ if "probabilities" in answer:
84
+ answer["probabilities"] = {label: round(value, digits) for label, value in answer["probabilities"].items()}
85
+ return payload
86
+
87
+
88
+ def interactive(client, digits):
89
+ print("Enter a state, then questions as kind id instructions | criteria (blank line answers, Ctrl-D quits).")
90
+ print(" choice tone What is the tone? | calm,frustrated,angry")
91
+ print(" score urgency How urgent? | can wait|this week|today")
92
+ print(" noul billing Is this about billing?")
93
+ while True:
94
+ try:
95
+ state = input("\nstate> ").strip()
96
+ except EOFError:
97
+ return
98
+ if not state:
99
+ continue
100
+ questions = {}
101
+ while True:
102
+ try:
103
+ line = input("question> ").strip()
104
+ except EOFError:
105
+ return
106
+ if not line:
107
+ break
108
+ try:
109
+ kind, identifier, rest = line.split(None, 2)
110
+ instructions, _, criteria = rest.partition("|")
111
+ if kind == "choice":
112
+ questions[identifier] = Choice(instructions=instructions.strip(), criteria=parse_choice_criteria(criteria))
113
+ elif kind == "score":
114
+ questions[identifier] = Score(instructions=instructions.strip(), criteria=parse_score_levels(criteria))
115
+ elif kind == "noul":
116
+ questions[identifier] = Noul(instructions=instructions.strip())
117
+ else:
118
+ raise YevError("kind must be choice, score or noul")
119
+ except (ValueError, YevError) as error:
120
+ print(" ignored:", error)
121
+ if questions:
122
+ print(json.dumps(render(client.system_one(state, questions), digits), indent=2))
123
+
124
+
125
+ def parse_arguments(argv=None):
126
+ parser = argparse.ArgumentParser(prog="yev", description="Ask an exported yev model typed questions locally.")
127
+ parser.add_argument("--export", required=True, help="Exported model directory, or a Hugging Face 'owner/name'")
128
+ parser.add_argument("--revision", help="Hub branch, tag or commit (with an 'owner/name' export)")
129
+ parser.add_argument("--request", type=Path, help="JSON file with 'state' and 'questions' ('-' for stdin)")
130
+ parser.add_argument("--state", help="State text for flag-built requests")
131
+ parser.add_argument("--choice", nargs=3, action="append", metavar=("ID", "INSTRUCTIONS", "OPTIONS"),
132
+ help="Options as 'a,b,c' or 'a=description,b=description'")
133
+ parser.add_argument("--score", nargs=3, action="append", metavar=("ID", "INSTRUCTIONS", "LEVELS"),
134
+ help="Levels low to high separated by '|'")
135
+ parser.add_argument("--noul", nargs=2, action="append", metavar=("ID", "INSTRUCTIONS"))
136
+ parser.add_argument("--image", type=Path, action="append", default=[],
137
+ help="An image file to add to the state, after its text (v2 exports); repeatable")
138
+ parser.add_argument("--windows", choices=("mean", "max"),
139
+ help="Score a long state in overlapping windows: average them, or take the most likely Yes (Noul only)")
140
+ parser.add_argument("--overlap", type=int, help="Tokens shared by consecutive windows (default: a quarter of a window)")
141
+ parser.add_argument("--round", action="store_true", help="Round probabilities to hundredths, like the hosted API")
142
+ parser.add_argument("--micro-batch", type=int, default=8)
143
+ parser.add_argument("--quantize4bit", action="store_true", help="NF4 4-bit backbone (CUDA and bitsandbytes only)")
144
+ parser.add_argument("--interactive", action="store_true")
145
+ return parser.parse_args(argv)
146
+
147
+
148
+ def run(argv=None):
149
+ """Execute one invocation and return the response, or ``None`` in interactive mode."""
150
+ arguments = parse_arguments(argv)
151
+ digits = 2 if arguments.round else None
152
+ request = None if arguments.interactive else build_request(arguments)
153
+ client = Yev(arguments.export, quantize4bit=arguments.quantize4bit, micro_batch=arguments.micro_batch,
154
+ revision=arguments.revision)
155
+ if arguments.interactive:
156
+ interactive(client, digits)
157
+ return None
158
+ response = client.system_one(request["state"], request["questions"], windows=arguments.windows, overlap=arguments.overlap)
159
+ print(json.dumps(render(response, digits), indent=2))
160
+ return response
161
+
162
+
163
+ def main(argv=None) -> int:
164
+ """Console-script entry point: prints the response and returns a process exit code."""
165
+ try:
166
+ run(argv)
167
+ except YevError as error:
168
+ print(f"yev: {error}", file=sys.stderr)
169
+ return 2
170
+ except KeyboardInterrupt:
171
+ return 130
172
+ return 0
173
+
174
+
175
+ if __name__ == "__main__":
176
+ sys.exit(main())
src/yev/client.py ADDED
@@ -0,0 +1,564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A local System One client: the TypeSafe SDK's clients and call shape, answered by a local model.
2
+
3
+ ``YevClient`` is ``TypeSafeClient`` and ``AsyncYevClient`` is ``AsyncTypeSafeClient``: the same
4
+ constructor ``model`` argument and ``YEV_DEFAULT_MODEL`` environment default, the same
5
+ ``system_one(state, questions, *, model=None, response_model=None)``, the same ``models.list()``,
6
+ context managers and ``close()``. ``model`` names a Hugging Face Hub repository or an export
7
+ directory instead of a hosted model. The HTTP settings (API key, base URL, retries, timeouts,
8
+ headers, extra body) have nothing to act on and are not taken. Every question is one forward
9
+ pass; no text is generated and nothing leaves the machine.
10
+
11
+ Questions in one request never see each other: each is an independent sequence, so a fact
12
+ placed in one question cannot influence another. Answers do move by a few thousandths with
13
+ the batch shape, because bf16 kernels are not batch-invariant; that drift is below the
14
+ hosted API's hundredth display grid and is independent of what the neighbouring questions
15
+ say. Pass ``micro_batch=1`` for bitwise-stable answers at the cost of throughput.
16
+
17
+ from yev import Choice, Noul, Score, YevClient
18
+
19
+ with YevClient() as client: # YEV_DEFAULT_MODEL, else Yev-Decision-1 from the Hub
20
+ response = client.system_one(
21
+ state="I was charged twice. Please fix this ASAP.",
22
+ questions={
23
+ "billing": Noul(instructions="Is this ticket about billing?"),
24
+ "tone": Choice(instructions="What is the customer's tone?",
25
+ criteria={"calm": None, "frustrated": None, "angry": None}),
26
+ "urgency": Score(instructions="How urgent is this ticket?",
27
+ criteria=["can wait", "this week", "today"]),
28
+ },
29
+ )
30
+ response.nouls["billing"].noul
31
+ response.choices["tone"].choice
32
+ response.scores["urgency"].score
33
+
34
+ What yev adds:
35
+
36
+ - ``system_one_batch(states, questions)`` answers the same questions about many states in one
37
+ pass over the model, which is where a local model's throughput comes from.
38
+ - A state longer than the export's token budget is cut off after its start by default. Pass
39
+ ``windows="mean"`` to score overlapping windows that cover all of it and average them, or
40
+ ``windows="max"`` (Noul only) to answer from the window most likely to be Yes, for questions
41
+ such as "does any part of this contain an instruction aimed at the agent?". Make ``overlap``
42
+ longer than the longest span you are looking for, so each one sits whole inside some window.
43
+ - Images, as in the TypeSafe SDK (``Image``, ``assets=``, ``attach_assets``), in the state of a request
44
+ to an export with a vision backbone; yev does not read images in instructions or criteria.
45
+ - A Choice with one option, or a Score with one level, is answered as certain without the model.
46
+ """
47
+
48
+ import asyncio
49
+ from collections import Counter
50
+ from collections.abc import Mapping, Sequence
51
+ from contextlib import contextmanager
52
+ from datetime import date
53
+ from functools import cached_property
54
+ import json
55
+ import os
56
+ from pathlib import Path
57
+ import re
58
+ import threading
59
+ import time
60
+
61
+ from pydantic import ValidationError
62
+
63
+ from .answers import ChoiceAnswer, ListModelsResponse, ModelMetadata, NoulAnswer, ScoreAnswer, SystemOneResponse, Usage
64
+ from .constants import DEFAULT_MICRO_BATCH, DEFAULT_MODEL, DEFAULT_MODEL_ENV
65
+ from .errors import YevError, YevModelNotFoundError, YevResponseValidationError
66
+ from .primitives import (
67
+ Choice,
68
+ Noul,
69
+ content_parts,
70
+ content_text,
71
+ holds_image,
72
+ is_image,
73
+ normalize_questions,
74
+ question_content,
75
+ question_labels_and_descriptions,
76
+ )
77
+
78
+ WINDOW_MODES = (None, "mean", "max")
79
+ WINDOW_MARGIN = 8 # decoding a slice of tokens and encoding it again can merge or split a few
80
+ HUB_REPO_ID = re.compile(r"[A-Za-z0-9][\w.-]*/[A-Za-z0-9][\w.-]*")
81
+
82
+
83
+ def resolve_export(export, revision=None):
84
+ """A local export directory, downloading ``owner/name`` from the Hugging Face Hub if needed.
85
+
86
+ A path that exists locally always wins. Otherwise an ``owner/name`` string is fetched with
87
+ ``huggingface_hub.snapshot_download`` into the Hub cache, so later calls load offline;
88
+ private repositories use the token saved by ``hf auth login``.
89
+ """
90
+ local = Path(export)
91
+ if local.is_dir():
92
+ return local
93
+ if isinstance(export, str) and HUB_REPO_ID.fullmatch(export):
94
+ try:
95
+ from huggingface_hub import snapshot_download
96
+ from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError, RevisionNotFoundError
97
+ except ImportError as error:
98
+ raise YevError("Loading from the Hugging Face Hub needs the 'inference' extra") from error
99
+ try:
100
+ return Path(snapshot_download(repo_id=export, revision=revision, repo_type="model"))
101
+ except (RepositoryNotFoundError, RevisionNotFoundError) as error:
102
+ raise YevModelNotFoundError(f"No Hugging Face model repository {export!r} visible to this login") from error
103
+ except HfHubHTTPError as error:
104
+ raise YevError(f"Could not download {export!r} from the Hugging Face Hub: {error}") from error
105
+ raise YevModelNotFoundError(f"No exported model directory at {local}")
106
+
107
+
108
+ def state_windows(tokenizer, text, size, overlap=None):
109
+ """Overlapping slices of at most ``size`` tokens covering all of ``text``; consecutive slices
110
+ share ``overlap`` tokens, a quarter of a slice by default. Text that fits is returned whole."""
111
+ if type(size) is not int or size < 1:
112
+ raise YevError("The question leaves no room for the state in the token budget")
113
+ shared = size // 4 if overlap is None else overlap
114
+ if type(shared) is not int or not 0 <= shared < size:
115
+ raise YevError(f"overlap must be an integer from 0 to {size - 1} tokens for this question")
116
+ ids = tokenizer.encode(text, add_special_tokens=False)
117
+ if len(ids) <= size:
118
+ return [text]
119
+ starts = [0]
120
+ while starts[-1] + size < len(ids):
121
+ starts.append(starts[-1] + size - shared)
122
+ return [tokenizer.decode(ids[start:start + size]) for start in starts]
123
+
124
+
125
+ def _typed_answer(question, labels, probabilities, descriptions):
126
+ """Build the typed answer for one question from its normalized distribution."""
127
+ count = len(probabilities)
128
+ if isinstance(question, Noul):
129
+ return NoulAnswer(noul=probabilities[1])
130
+ mode = max(range(count), key=probabilities.__getitem__)
131
+ if isinstance(question, Choice):
132
+ confidence = 1.0 if count == 1 else (probabilities[mode] - 1 / count) / (1 - 1 / count)
133
+ return ChoiceAnswer(
134
+ choice=labels[mode],
135
+ probabilities=dict(zip(labels, probabilities)),
136
+ confidence=max(0.0, confidence),
137
+ )
138
+ distance = sum(value * abs(index - mode) for index, value in enumerate(probabilities))
139
+ uniform = sum(abs(index - (count - 1) / 2) for index in range(count)) / count
140
+ return ScoreAnswer(
141
+ score=sum(index * value for index, value in enumerate(probabilities)),
142
+ probabilities={index: value for index, value in enumerate(probabilities)},
143
+ legend=dict(enumerate(question.criteria)),
144
+ confidence=max(0.0, 1 - distance / uniform) if uniform else 1.0,
145
+ )
146
+
147
+
148
+ def requested_model(model=None):
149
+ """The model to load: the argument, else ``YEV_DEFAULT_MODEL`` unless it is blank, else ``DEFAULT_MODEL``."""
150
+ return model if model is not None else os.environ.get(DEFAULT_MODEL_ENV, "").strip() or DEFAULT_MODEL
151
+
152
+
153
+ def field_path(error):
154
+ """The first validation error's location as the TypeSafe SDK's dotted ``field_path``."""
155
+ location = list(error.errors(include_url=False)[0]["loc"])
156
+ if location[:1] == ["answers"] and len(location) > 2 and location[2] in ("noul", "choice", "score"):
157
+ del location[2] # the union member pydantic tried, not a field
158
+ path = ""
159
+ for segment in location:
160
+ if segment != "[key]":
161
+ path += f"[{segment}]" if isinstance(segment, int) else f".{segment}" if path else str(segment)
162
+ return path
163
+
164
+
165
+ def typed_response(response_model, model, usage, answers, elapsed):
166
+ """The response as ``response_model`` reads it: a ``SystemOneResponse`` subclass gets the answers it declares as
167
+ fields lifted to the top level, and any other model validates the plain response body, as in the TypeSafe SDK."""
168
+ if response_model is None:
169
+ return SystemOneResponse(model=model, usage=usage, answers=answers, elapsed_seconds=elapsed)
170
+ body = {"model": model, "usage": usage.model_dump(), "answers": {name: answer.model_dump() for name, answer in answers.items()}}
171
+ if isinstance(response_model, type) and issubclass(response_model, SystemOneResponse):
172
+ body["elapsed_seconds"] = elapsed
173
+ body |= {name: body["answers"][name] for name in set(response_model.model_fields) - set(SystemOneResponse.model_fields)
174
+ if name in answers}
175
+ try:
176
+ return response_model.model_validate_json(json.dumps(body))
177
+ except ValidationError as error:
178
+ raise YevResponseValidationError(field_path(error)) from error
179
+
180
+
181
+ def model_metadata(name, export):
182
+ """What ``models.list()`` reports for an export, from its ``metadata.json``."""
183
+ path = export / "metadata.json"
184
+ metadata = json.loads(path.read_text()) if path.exists() else {}
185
+ parts = [f"yev decision model on {metadata.get('base_model', 'an unknown backbone')}"]
186
+ if metadata.get("max_length"):
187
+ parts.append(f"{metadata['max_length']:,} tokens per question")
188
+ parts.append("text and images" if metadata.get("images") else "text")
189
+ written = date.fromtimestamp((path if path.exists() else export).stat().st_mtime)
190
+ return ModelMetadata(name=name, description=", ".join(parts), release_date=written.isoformat())
191
+
192
+
193
+ class Models:
194
+ """The models a client answers with, reached through ``YevClient.models``: the default model and any a call
195
+ named with ``model=``."""
196
+
197
+ def __init__(self, client):
198
+ self._client = client
199
+
200
+ def list(self) -> ListModelsResponse:
201
+ """List the models this client has: names, descriptions and export dates. Reads files only."""
202
+ clients = [self._client, *self._client._others.values()]
203
+ return ListModelsResponse(models=tuple(model_metadata(client.model, client.export) for client in clients))
204
+
205
+
206
+ class AttachedAssets:
207
+ """A client's attached assets, keyed by reference and shared by its calls from any thread: the TypeSafe SDK's."""
208
+
209
+ def __init__(self):
210
+ self._lock = threading.Lock()
211
+ self._assets = {}
212
+ self._holds = Counter() # overlapping blocks may attach the same asset; it stays until the last one exits
213
+
214
+ @contextmanager
215
+ def attach(self, assets):
216
+ attached = [assets] if is_image(assets) else list(assets)
217
+ for asset in attached:
218
+ if not is_image(asset):
219
+ raise YevError(f"Attached value {asset!r} must be an asset.")
220
+ with self._lock:
221
+ for asset in attached:
222
+ self._assets[str(asset)] = asset
223
+ self._holds[str(asset)] += 1
224
+ try:
225
+ yield
226
+ finally:
227
+ with self._lock:
228
+ for asset in attached:
229
+ self._holds[str(asset)] -= 1
230
+ if not self._holds[str(asset)]:
231
+ del self._holds[str(asset)], self._assets[str(asset)]
232
+
233
+ def snapshot(self):
234
+ with self._lock:
235
+ return dict(self._assets)
236
+
237
+
238
+ def named_assets(assets):
239
+ """The ``assets`` a call names, checked as the TypeSafe SDK checks them."""
240
+ for name, asset in (assets or {}).items():
241
+ if not isinstance(name, str) or not name.strip():
242
+ raise YevError("Asset aliases must be non-empty strings.")
243
+ if not is_image(asset):
244
+ raise YevError(f"Asset {name!r} must be an asset.")
245
+ return dict(assets or {})
246
+
247
+
248
+ def checked_request(states, questions, windows, overlap, known=()):
249
+ """The request's questions as yev questions, once ``states``, the window settings and where images are are
250
+ checked; ``known`` holds the references of the images the request can use."""
251
+ if isinstance(states, (str, Mapping)) or not isinstance(states, Sequence):
252
+ raise YevError("states must be a list of states")
253
+ if any(state is None for state in states):
254
+ raise YevError("State is required.")
255
+ questions = normalize_questions(questions)
256
+ for name, question in questions.items():
257
+ if any(holds_image(value, known) for value in question_content(question)):
258
+ raise YevError(f'Question "{name}" holds an image; yev reads images in the state only. Put the image in the '
259
+ "state and refer to it by name, as in Is the dog in `photo` wearing a collar?")
260
+ if windows not in WINDOW_MODES:
261
+ raise YevError(f"windows must be one of {WINDOW_MODES}")
262
+ if windows is None and overlap is not None:
263
+ raise YevError("overlap applies only when windows is set")
264
+ if windows == "max" and any(not isinstance(question, Noul) for question in questions.values()):
265
+ raise YevError("windows='max' answers Noul questions only")
266
+ return questions
267
+
268
+
269
+ def question_examples(name, question, state_text, digests, header_text, tokenizer, max_length, windows, overlap):
270
+ """The sequences one question about one state is answered from: the state, or each window of it. A question with
271
+ one option has none: it is certain, and the heads need two."""
272
+ from .training.data import DecisionExample
273
+ from .training.model import encode_example
274
+
275
+ labels, descriptions = question_labels_and_descriptions(question)
276
+ if len(labels) == 1:
277
+ return []
278
+ instructions = content_text(question.instructions)
279
+
280
+ def example(text):
281
+ return DecisionExample(uid=name, group=name, source="yev", split="test", kind=question.type, state=text,
282
+ instructions=instructions, criteria=descriptions, target=[1 / len(descriptions)] * len(descriptions),
283
+ images=digests) # empty whenever windows are used
284
+
285
+ if windows is None:
286
+ return [example(header_text + state_text)]
287
+ try:
288
+ overhead = len(encode_example(example(header_text), tokenizer, max_length)["input_ids"])
289
+ except ValueError as error:
290
+ raise YevError(f"Question {name!r} does not fit the token budget: {error}") from error
291
+ return [example(header_text + part) for part in state_windows(tokenizer, state_text, max_length - overhead - WINDOW_MARGIN, overlap)]
292
+
293
+
294
+ def combined_answer(question, rows, windows):
295
+ """The typed answer from a question's sequence distributions: the window most likely Yes for ``windows="max"``,
296
+ their mean otherwise, and certainty when there are none."""
297
+ labels = question_labels_and_descriptions(question)[0]
298
+ rows = [row[:len(labels)].tolist() for row in rows] or [[1.0]]
299
+ if windows == "max":
300
+ probabilities = max(rows, key=lambda row: row[1])
301
+ else:
302
+ probabilities = [sum(column) / len(rows) for column in zip(*rows)]
303
+ return _typed_answer(question, labels, probabilities, None)
304
+
305
+
306
+ class YevClient:
307
+ """A System One client backed by a local exported model: the TypeSafe SDK's ``TypeSafeClient``.
308
+
309
+ Args:
310
+ model: The model: a Hugging Face Hub id such as ``"owner/name"``, or an export directory
311
+ holding ``adapter/``, ``heads.pt``, ``config.json``, ``metadata.json`` and the
312
+ tokenizer. Defaults to ``YEV_DEFAULT_MODEL``, then ``yev.constants.DEFAULT_MODEL``.
313
+ revision: Hub branch, tag or commit to download; ignored for a local directory.
314
+ quantize4bit: Load the backbone's language model in NF4 4-bit (bitsandbytes 0.50 or later, the
315
+ ``quantized`` extra) on a CUDA GPU, a CPU or an Apple GPU; the vision encoder stays in bf16.
316
+ micro_batch: Question sequences evaluated per forward pass.
317
+ lazy: Defer loading weights until the first request.
318
+
319
+ Raises:
320
+ YevModelNotFoundError: The directory does not exist, or the Hub has no such repository.
321
+ YevError: ``micro_batch`` is not a positive integer, or the download failed.
322
+ """
323
+
324
+ def __init__(self, model=None, *, revision=None, quantize4bit=False, micro_batch=DEFAULT_MICRO_BATCH, lazy=True):
325
+ if type(micro_batch) is not int or micro_batch < 1:
326
+ raise YevError("micro_batch must be a positive integer")
327
+ requested = requested_model(model)
328
+ hub = not Path(requested).is_dir() and isinstance(requested, str) and HUB_REPO_ID.fullmatch(requested)
329
+ self.export = resolve_export(requested, revision)
330
+ self.quantize4bit = quantize4bit
331
+ self.micro_batch = micro_batch
332
+ resolved = self.export.resolve()
333
+ default = requested.split("/")[1] if hub else (
334
+ resolved.parent.name if resolved.name in ("export", "model") else resolved.name)
335
+ self.model = default or "yev"
336
+ """The name answers report."""
337
+ self._requested = str(requested)
338
+ self._model = None
339
+ self._tokenizer = None
340
+ self._others = {}
341
+ self._attached = AttachedAssets()
342
+ if not lazy:
343
+ self._load()
344
+
345
+ @cached_property
346
+ def models(self) -> Models:
347
+ """An accessor for the models this client answers with: ``client.models.list()``."""
348
+ return Models(self)
349
+
350
+ def _load(self):
351
+ if self._model is None:
352
+ from .training.run import reload_export
353
+
354
+ self._model, self._tokenizer = reload_export(self.export, quantize4bit=self.quantize4bit)
355
+ return self._model, self._tokenizer
356
+
357
+ def _other(self, model):
358
+ """The client for a model a call names that is not this one's, loaded once and kept until ``close``."""
359
+ if model not in self._others:
360
+ self._others[model] = YevClient(model, quantize4bit=self.quantize4bit, micro_batch=self.micro_batch)
361
+ self._others[model]._attached = self._attached # attached assets hold for every model the client calls
362
+ return self._others[model]
363
+
364
+ @property
365
+ def max_length(self) -> int:
366
+ """Token budget per question, from the export's training configuration."""
367
+ return self._load()[0].training_config.max_length
368
+
369
+ @property
370
+ def base_model(self) -> str:
371
+ """Hugging Face id of the frozen backbone this export adapts."""
372
+ return self._load()[0].training_config.model_id
373
+
374
+ def attach_assets(self, assets):
375
+ """Attach assets to calls made within a ``with`` block, for referencing them inside strings.
376
+
377
+ Until the block exits, a state holding an attached asset's reference, ``str(asset)``, reads the
378
+ asset in its place. Only references to attached assets, assets passed in ``assets`` and assets
379
+ placed in the content are recognised; any other is plain text.
380
+
381
+ Raises:
382
+ YevError: An attached value is not an asset.
383
+ """
384
+ return self._attached.attach(assets)
385
+
386
+ def system_one(self, state, questions, *, model=None, assets=None, response_model=None, windows=None, overlap=None,
387
+ header=""):
388
+ """Answer named questions about a state.
389
+
390
+ Args:
391
+ state: Text, a JSON object or an array to evaluate, which may hold ``Image``s.
392
+ questions: Nonempty mapping of names to ``Noul``, ``Choice`` or ``Score`` questions,
393
+ question dictionaries, or TypeSafe SDK question objects.
394
+ model: Model override, a Hub id or export directory; ``None`` uses this client's.
395
+ Another model is loaded on first use and kept until ``close``.
396
+ assets: Images keyed by the reference used for them in the state.
397
+ response_model: Optional Pydantic model for the response body; a ``SystemOneResponse``
398
+ subclass gets the answers it declares as fields, by question name.
399
+ windows: ``None`` answers from the start of a state too long for the token
400
+ budget; ``"mean"`` averages overlapping windows covering all of it; ``"max"``
401
+ takes the window most likely to be Yes (Noul questions only).
402
+ overlap: Tokens shared by consecutive windows; a quarter of a window by default.
403
+ header: Text placed before the state, and before every window of it, whole.
404
+
405
+ Returns:
406
+ A ``SystemOneResponse`` with answers keyed by question name, or a ``response_model``.
407
+
408
+ Raises:
409
+ YevError: The request is invalid before the model reads it: no state or questions, a
410
+ question that is malformed, holds an image or has more options than yev answers, an
411
+ asset that is not an image or not referenced, or window settings that do not fit.
412
+ YevResponseValidationError: The answers do not fit ``response_model``.
413
+ """
414
+ return self.system_one_batch([state], questions, model=model, assets=assets, response_model=response_model,
415
+ windows=windows, overlap=overlap, header=header)[0]
416
+
417
+ def system_one_batch(self, states, questions, *, model=None, assets=None, response_model=None, windows=None, overlap=None,
418
+ header=""):
419
+ """Answer the same named questions about each of ``states`` in one pass over the model, a response per state in
420
+ order; the arguments are ``system_one``'s."""
421
+ if model not in (None, self.model, self._requested):
422
+ return self._other(model).system_one_batch(states, questions, assets=assets, response_model=response_model,
423
+ windows=windows, overlap=overlap, header=header)
424
+ named = named_assets(assets)
425
+ known = {**self._attached.snapshot(), **{str(asset): asset for asset in named.values()}, **named}
426
+ questions = checked_request(states, questions, windows, overlap, known)
427
+ from .training.model import collator_for
428
+
429
+ loaded, tokenizer = self._load()
430
+ max_length = loaded.training_config.max_length
431
+ used = set()
432
+ parsed = [content_parts(state, known, used) for state in states]
433
+ unreferenced = sorted(named.keys() - used)
434
+ if unreferenced:
435
+ raise YevError(f"Asset {unreferenced[0]!r} is not referenced in the state.")
436
+ vision = self._vision(loaded, parsed, windows)
437
+ header_text = content_text(header) if header else ""
438
+ prepared, examples, owners = [], [], []
439
+ for index, (state_text, images) in enumerate(parsed):
440
+ digests = [vision.store.put(data) for data in images]
441
+ for name, question in questions.items():
442
+ parts = question_examples(name, question, state_text, digests, header_text, tokenizer, max_length, windows, overlap)
443
+ prepared.append((index, name, question, len(examples), len(parts)))
444
+ examples.extend(parts)
445
+ owners.extend([index] * len(parts))
446
+ started = time.perf_counter()
447
+ distributions, tokens = self._forward(loaded, collator_for(loaded, tokenizer, max_length, vision), examples, owners,
448
+ len(states))
449
+ elapsed = round(time.perf_counter() - started, 4)
450
+ answers = [{} for _ in states]
451
+ for index, name, question, first, count in prepared:
452
+ answers[index][name] = combined_answer(question, distributions[first:first + count], windows)
453
+ return [typed_response(response_model, self.model, Usage(input_tokens=tokens[index], output_tokens=0), answers[index],
454
+ elapsed) for index in range(len(states))]
455
+
456
+ def _vision(self, loaded, parsed, windows):
457
+ """The export's vision settings, with an image store of this request's own when a state holds images."""
458
+ vision = getattr(loaded, "vision", None)
459
+ if not any(images for _, images in parsed):
460
+ return vision
461
+ if vision is None:
462
+ raise YevError(f"{self.model} reads text only; images need a v2 export (and Pillow installed)")
463
+ if windows is not None:
464
+ raise YevError("windows cannot split a state that holds images")
465
+ from dataclasses import replace
466
+
467
+ from .training.images import ImageStore
468
+
469
+ return replace(vision, store=ImageStore())
470
+
471
+ def _forward(self, loaded, collator, examples, owners, count):
472
+ """Each example's distribution, in order, and the tokens read for each of ``count`` states."""
473
+ from .training.run import device_batch, evaluation_mode
474
+
475
+ distributions, tokens = [], [0] * count
476
+ if not examples:
477
+ return distributions, tokens
478
+ with evaluation_mode(loaded):
479
+ for start in range(0, len(examples), self.micro_batch):
480
+ batch = device_batch(loaded, collator, examples[start:start + self.micro_batch])
481
+ for owner, read in zip(owners[start:start + self.micro_batch], batch["attention_mask"].sum(-1).tolist()):
482
+ tokens[owner] += int(read)
483
+ distributions.extend(loaded(**batch)["logits"].float().softmax(-1).cpu())
484
+ return distributions, tokens
485
+
486
+ def close(self) -> None:
487
+ """Release the loaded weights, of every model this client loaded."""
488
+ for other in self._others.values():
489
+ other.close()
490
+ self._others = {}
491
+ self._model = None
492
+ self._tokenizer = None
493
+
494
+ def __enter__(self):
495
+ self._load()
496
+ return self
497
+
498
+ def __exit__(self, *exception):
499
+ self.close()
500
+ return False
501
+
502
+
503
+ Yev = YevClient
504
+ """The name yev's client had first; the same class."""
505
+
506
+
507
+ class AsyncModels:
508
+ """The models an ``AsyncYevClient`` answers with: ``await client.models.list()``."""
509
+
510
+ def __init__(self, client):
511
+ self._client = client
512
+
513
+ async def list(self) -> ListModelsResponse:
514
+ """List the models this client has: names, descriptions and export dates."""
515
+ return self._client.client.models.list()
516
+
517
+
518
+ class AsyncYevClient:
519
+ """The TypeSafe SDK's ``AsyncTypeSafeClient`` over a ``YevClient``, with the same arguments. Each call runs in a
520
+ worker thread, one at a time (a model answers one batch at a time), so the event loop stays free meanwhile."""
521
+
522
+ def __init__(self, model=None, *, revision=None, quantize4bit=False, micro_batch=DEFAULT_MICRO_BATCH, lazy=True):
523
+ self.client = YevClient(model, revision=revision, quantize4bit=quantize4bit, micro_batch=micro_batch, lazy=lazy)
524
+ """The synchronous client answering for this one."""
525
+ self._lock = asyncio.Lock()
526
+
527
+ @property
528
+ def model(self) -> str:
529
+ """The name answers report."""
530
+ return self.client.model
531
+
532
+ @cached_property
533
+ def models(self) -> AsyncModels:
534
+ """An accessor for the models this client answers with: ``await client.models.list()``."""
535
+ return AsyncModels(self)
536
+
537
+ def attach_assets(self, assets):
538
+ """``YevClient.attach_assets``: assets referenced inside strings by calls made within a ``with`` block."""
539
+ return self.client.attach_assets(assets)
540
+
541
+ async def system_one(self, state, questions, *, model=None, assets=None, response_model=None, windows=None, overlap=None,
542
+ header=""):
543
+ """``YevClient.system_one``, awaited."""
544
+ async with self._lock:
545
+ return await asyncio.to_thread(self.client.system_one, state, questions, model=model, assets=assets,
546
+ response_model=response_model, windows=windows, overlap=overlap, header=header)
547
+
548
+ async def system_one_batch(self, states, questions, *, model=None, assets=None, response_model=None, windows=None,
549
+ overlap=None, header=""):
550
+ """``YevClient.system_one_batch``, awaited."""
551
+ async with self._lock:
552
+ return await asyncio.to_thread(self.client.system_one_batch, states, questions, model=model, assets=assets,
553
+ response_model=response_model, windows=windows, overlap=overlap, header=header)
554
+
555
+ async def aclose(self) -> None:
556
+ """Release the loaded weights."""
557
+ self.client.close()
558
+
559
+ async def __aenter__(self):
560
+ await asyncio.to_thread(self.client._load)
561
+ return self
562
+
563
+ async def __aexit__(self, *exception):
564
+ await self.aclose()
src/yev/constants.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public environment-variable names and client defaults, as ``typesafe_sdk.constants`` holds them."""
2
+
3
+ DEFAULT_MODEL_ENV = "YEV_DEFAULT_MODEL"
4
+ """Environment variable for the default model: a Hugging Face Hub id or an export directory."""
5
+
6
+ DEFAULT_MODEL = "HallD/Yev-Decision-1"
7
+ """Default model: Yev-Decision-1, a 4B trained without Jev."""
8
+
9
+ DEFAULT_MICRO_BATCH = 8
10
+ """Default number of question sequences scored per forward pass."""
src/yev/errors.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exceptions raised by the yev client: ``YevError`` is the TypeSafe SDK's ``TypeSafeError``."""
2
+
3
+
4
+ class YevError(Exception):
5
+ """Base exception for every error raised by this package."""
6
+
7
+
8
+ class YevModelNotFoundError(YevError, FileNotFoundError):
9
+ """The requested export directory does not exist or is not a model export."""
10
+
11
+
12
+ class YevResponseValidationError(YevError):
13
+ """The answers do not fit the ``response_model`` the call asked for."""
14
+
15
+ def __init__(self, field_path: str) -> None:
16
+ super().__init__(f"Invalid response data at {field_path!r}.")
17
+ self.field_path = field_path
18
+ """Dotted path to the offending field, such as ``answers.tone.confidence``."""
src/yev/primitives.py ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Question primitives and images, ported from the TypeSafe SDK (0.7.5): ``Noul``, ``Choice``, ``Score``,
2
+ ``Box`` and ``Image``.
3
+
4
+ The same fields, types, defaults and wire form (an unset optional field is left out of
5
+ ``model_dump``), the same question dictionaries (``NoulModel``, ``ChoiceModel``, ``ScoreModel``,
6
+ ``BoxModel``) and the same checks, with the same messages. Instructions and criteria accept text,
7
+ JSON objects or arrays; non-string content is rendered as compact JSON before it reaches the
8
+ model. ``normalize_questions`` also takes TypeSafe SDK question objects, and states take TypeSafe
9
+ SDK images, so code written against ``typesafe_sdk`` runs on ``yev``. The differences: yev
10
+ questions are frozen; the number of options is also checked against what yev's heads answer; yev
11
+ reads images in the state only, not in instructions or criteria; and it has no head for ``Box``
12
+ questions, so asking one is an error.
13
+ """
14
+
15
+ from base64 import b64decode
16
+ from collections.abc import Mapping, Sequence
17
+ import io
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+ import re
22
+ from typing import Any, Literal, NotRequired
23
+ from uuid import uuid4
24
+
25
+ from pydantic import BaseModel, ConfigDict, Field, model_serializer
26
+ from pydantic.functional_serializers import SerializerFunctionWrapHandler
27
+ from pydantic_core import core_schema
28
+ from typing_extensions import TypedDict # closed=True, which typing's TypedDict takes from Python 3.15
29
+
30
+ from .errors import YevError
31
+
32
+ type JSONValue = str | int | float | bool | Sequence[JSONValue | None] | Mapping[str, JSONValue | None]
33
+ """A JSON-like value. May be nested and contain ``None``."""
34
+
35
+ type JSONContent = str | Mapping[str, JSONValue | None] | Sequence[JSONValue | None]
36
+ """Either a plain string or a mapping/sequence of ``JSONValue`` entries."""
37
+
38
+ CHOICE_LIMITS = (1, 255) # options yev answers; a single option is certain and needs no model
39
+ SCORE_LIMITS = (1, 10) # levels yev's heads were trained on, from zero
40
+ REFERENCE = re.compile(r"<image:[0-9a-f]{32}>") # str(image), in yev and in the TypeSafe SDK alike
41
+
42
+
43
+ class Image:
44
+ """An image asset: the TypeSafe SDK's ``Image``.
45
+
46
+ Made from image file bytes or any other buffer (``bytearray``, ``memoryview``, ``mmap``), a path,
47
+ base64 text (``Image.from_base64``) or a Pillow image (``Image.from_pil``); yev also takes a path
48
+ given as a string, a Pillow image directly and a TypeSafe SDK ``Image``. Put it in a state on its
49
+ own, in a list with text (``["Is this paid?", Image(Path("receipt.png"))]``) or as a value in a
50
+ JSON object; ``str(image)`` is its reference, for placing it inside text (see
51
+ ``YevClient.attach_assets``). Only an export with a vision backbone reads it.
52
+ """
53
+
54
+ __slots__ = ("_reference", "data")
55
+ type = "image"
56
+ """Asset type discriminator, always ``"image"``."""
57
+
58
+ def __init__(self, data):
59
+ if isinstance(data, Image):
60
+ data = data.data
61
+ elif isinstance(data, (str, os.PathLike)):
62
+ data = Path(data).read_bytes()
63
+ elif callable(getattr(data, "save", None)): # a Pillow image
64
+ buffer = io.BytesIO()
65
+ data.save(buffer, format="PNG")
66
+ data = buffer.getvalue()
67
+ elif getattr(data, "type", None) == "image" and hasattr(data, "data"): # a TypeSafe SDK Image
68
+ data = Image(data.data).data
69
+ else:
70
+ try:
71
+ data = memoryview(data).tobytes()
72
+ except TypeError as error:
73
+ raise TypeError(f"An Image is made from a path, bytes or a Pillow image, not {type(data).__name__}") from error
74
+ if not data:
75
+ raise ValueError("An Image needs non-empty data")
76
+ self.data = data
77
+ """The image file bytes."""
78
+ self._reference = f"<image:{uuid4().hex}>"
79
+
80
+ @classmethod
81
+ def from_base64(cls, data):
82
+ """An ``Image`` from base64 text or an ASCII buffer; whitespace, such as wrapped lines, is ignored.
83
+
84
+ Raises:
85
+ YevError: The data isn't valid base64 text.
86
+ """
87
+ text = data if isinstance(data, str) else memoryview(data).tobytes().decode("latin-1")
88
+ text = "".join(text.split()) if text.isascii() else ""
89
+ if not text:
90
+ raise YevError("Image data must contain non-empty ASCII base64 text.")
91
+ try:
92
+ return cls(b64decode(text, validate=True))
93
+ except ValueError as error:
94
+ raise YevError("Image data must contain valid base64 text.") from error
95
+
96
+ @classmethod
97
+ def from_pil(cls, image, format="jpeg"): # Pillow's own name for it
98
+ """An ``Image`` from a Pillow image, saved with Pillow's defaults except JPEG quality 95.
99
+
100
+ Raises:
101
+ YevError: Pillow can't encode the image in this format.
102
+ """
103
+ buffer = io.BytesIO()
104
+ try:
105
+ image.save(buffer, format=format, quality=95)
106
+ except (KeyError, OSError, ValueError) as error: # Pillow raises KeyError for a format it doesn't know
107
+ raise YevError(f"Pillow image could not be encoded as {format}: {error}") from error
108
+ return cls(buffer.getvalue())
109
+
110
+ def __str__(self):
111
+ """The unique reference to this image, for using it inside text, such as an f-string."""
112
+ return self._reference
113
+
114
+ def __repr__(self):
115
+ return f"Image({len(self.data)} bytes)"
116
+
117
+ @classmethod
118
+ def __get_pydantic_core_schema__(cls, source, handler):
119
+ return core_schema.is_instance_schema(cls) # question objects hold an Image as it is
120
+
121
+
122
+ type Asset = Image
123
+ """An asset referenced from request content; only images are supported."""
124
+
125
+ type ContentValue = str | int | float | bool | Sequence[ContentValue | None] | Mapping[str, ContentValue | None] | Image
126
+ """A JSON-like value that may also hold images. May be nested and contain ``None``."""
127
+
128
+ type Content = str | Mapping[str, ContentValue | None] | Sequence[ContentValue | None] | Image
129
+ """Text, an image, or a mapping/sequence of ``ContentValue`` entries."""
130
+
131
+
132
+ def is_image(value) -> bool:
133
+ """A yev ``Image`` or a TypeSafe SDK one."""
134
+ return isinstance(value, Image) or (not isinstance(value, Mapping) and getattr(value, "type", None) == "image"
135
+ and hasattr(value, "data"))
136
+
137
+
138
+ class NoulCriteria(TypedDict, total=False, closed=True):
139
+ """Optional descriptions of the yes and no outcomes."""
140
+
141
+ true: Content | None
142
+ """Description of the yes outcome as text, an asset, a JSON object, or an array; ``None`` leaves it undescribed."""
143
+ false: Content | None
144
+ """Description of the no outcome as text, an asset, a JSON object, or an array; ``None`` leaves it undescribed."""
145
+
146
+
147
+ class NoulModel(TypedDict, closed=True):
148
+ """A yes/no question dictionary with ``type="noul"``."""
149
+
150
+ type: Literal["noul"]
151
+ instructions: NotRequired[Content | None]
152
+ """The question to ask, as text, an asset, a JSON object, or an array; optional when ``criteria`` describes an outcome."""
153
+ criteria: NotRequired[NoulCriteria | None]
154
+ """Optional descriptions of the yes and no outcomes; required when ``instructions`` is ``None``."""
155
+
156
+
157
+ class ChoiceModel(TypedDict, closed=True):
158
+ """A choice question dictionary with ``type="choice"``."""
159
+
160
+ type: Literal["choice"]
161
+ instructions: NotRequired[Content | None]
162
+ """The question to ask, expressed as text, an asset, a JSON object, or an array; optional."""
163
+ criteria: Mapping[str, Content | None]
164
+ """A nonempty mapping of labels to text, asset, object, or array descriptions, or ``None`` for undescribed labels."""
165
+
166
+
167
+ class ScoreModel(TypedDict, closed=True):
168
+ """A score question dictionary with ``type="score"``."""
169
+
170
+ type: Literal["score"]
171
+ instructions: NotRequired[Content | None]
172
+ """The question to ask, expressed as text, an asset, a JSON object, or an array; optional."""
173
+ criteria: Sequence[Content]
174
+ """A nonempty, ordered list of text, asset, object, or array descriptions, one per score from zero."""
175
+
176
+
177
+ class BoxModel(TypedDict, closed=True):
178
+ """A box question dictionary with ``type="box"``."""
179
+
180
+ type: Literal["box"]
181
+ instructions: NotRequired[Content | None]
182
+ """What to locate in the request's image, expressed as text, an asset, a JSON object, or an array; optional."""
183
+
184
+
185
+ class _Question(BaseModel):
186
+ """Reject unknown fields, freeze, and omit optional fields left at their default from the wire form."""
187
+
188
+ model_config = ConfigDict(extra="forbid", frozen=True)
189
+
190
+ @model_serializer(mode="wrap")
191
+ def _omit_none(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
192
+ # An unset optional field is left off, while ``None`` values nested inside criteria or instructions are kept.
193
+ return {key: value for key, value in handler(self).items() if value is not None}
194
+
195
+
196
+ class Noul(_Question):
197
+ """A yes/no question with optional descriptions for either outcome."""
198
+
199
+ type: Literal["noul"] = "noul"
200
+ instructions: Content | None = None
201
+ """The question to ask, as text, an asset, a JSON object, or an array; optional when ``criteria`` describes an outcome."""
202
+ criteria: NoulCriteria | None = None
203
+ """Optional descriptions of the yes and no outcomes; required when ``instructions`` is ``None``."""
204
+
205
+
206
+ class Choice(_Question):
207
+ """A question that selects between named alternatives."""
208
+
209
+ type: Literal["choice"] = "choice"
210
+ criteria: Mapping[str, Content | None]
211
+ """A nonempty mapping of labels to text, asset, object, or array descriptions, or ``None`` for undescribed labels."""
212
+ instructions: Content | None = None
213
+ """The question to ask, expressed as text, an asset, a JSON object, or an array; optional."""
214
+
215
+
216
+ class Score(_Question):
217
+ """A question that assigns a score using an ordered rubric."""
218
+
219
+ type: Literal["score"] = "score"
220
+ criteria: Sequence[Content] = Field(min_length=1)
221
+ """A nonempty, ordered list of text, asset, object, or array descriptions, one per score from zero."""
222
+ instructions: Content | None = None
223
+ """The question to ask, expressed as text, an asset, a JSON object, or an array; optional."""
224
+
225
+
226
+ class Box(_Question):
227
+ """A question that locates what its instructions describe in the request's image. yev has no head for it: asking
228
+ one raises ``YevError``. It is here so TypeSafe SDK code that builds one runs until it asks."""
229
+
230
+ type: Literal["box"] = "box"
231
+ instructions: Content | None = None
232
+ """What to locate in the request's image, expressed as text, an asset, a JSON object, or an array; optional."""
233
+
234
+
235
+ type QuestionModel = NoulModel | ChoiceModel | ScoreModel | BoxModel
236
+ """A question dictionary identified by its ``type`` key."""
237
+ type Question = Noul | Choice | Score | Box | QuestionModel
238
+ """A question object or question dictionary."""
239
+ type Questions = Mapping[str, Question]
240
+ """Question inputs keyed by the names used to identify their answers."""
241
+ QUESTION_TYPES = {"noul": Noul, "choice": Choice, "score": Score, "box": Box}
242
+
243
+
244
+ class ContentReader:
245
+ """Reads content as the model's text, replacing each image with the image placeholder; see ``content_parts``."""
246
+
247
+ def __init__(self, known, used):
248
+ self.known, self.used, self.images = known, used, []
249
+ ordered = sorted(known, key=len, reverse=True) # at one position, the longest reference wins
250
+ self.pattern = re.compile("|".join(map(re.escape, ordered))) if known else None
251
+
252
+ def image(self, image, references=()):
253
+ if self.used is not None:
254
+ self.used.update(references)
255
+ self.images.append(Image(image).data)
256
+ from .training.images import IMAGE_PLACEHOLDER
257
+
258
+ return IMAGE_PLACEHOLDER
259
+
260
+ def reference(self, match):
261
+ return self.image(self.known[match[0]], [match[0]])
262
+
263
+ def text(self, value):
264
+ if self.pattern is None:
265
+ return value
266
+ return self.image(self.known[value], [value]) if value in self.known else self.pattern.sub(self.reference, value)
267
+
268
+ def swap(self, value):
269
+ if is_image(value):
270
+ return self.image(value, [reference for reference, image in self.known.items() if image is value])
271
+ if isinstance(value, str):
272
+ return self.text(value)
273
+ if isinstance(value, Mapping):
274
+ return {key: self.swap(item) for key, item in value.items()}
275
+ if isinstance(value, (list, tuple)):
276
+ return [self.swap(item) for item in value]
277
+ return value
278
+
279
+
280
+ def content_parts(content, assets=None, used=None) -> tuple[str, list[bytes]]:
281
+ """The model's text for a state, with each image replaced by the image placeholder, and the images' bytes in the
282
+ order their placeholders appear. Text is kept as is, an image alone is just its placeholder, a list of text and
283
+ images becomes lines, and any other object or array becomes compact JSON with the placeholders as string values.
284
+
285
+ ``assets`` maps references (``str(image)``, or the aliases a call names) to images. As the TypeSafe API reads them,
286
+ a string that is a reference becomes that image, longer strings have references replaced left to right, and
287
+ object keys stay text. Images placed in the content are known by their own references too. The references that
288
+ matched are added to ``used``, and so are the names of an image placed as itself.
289
+ """
290
+ reader = ContentReader({**dict(assets or {}), **{str(image): image for image in placed(content)}}, used)
291
+ if is_image(content):
292
+ return reader.swap(content), reader.images
293
+ if (isinstance(content, (list, tuple)) and any(is_image(item) for item in content)
294
+ and all(isinstance(item, str) or is_image(item) for item in content)):
295
+ return "\n".join(reader.swap(item) for item in content), reader.images
296
+ return content_text(reader.swap(content)), reader.images
297
+
298
+
299
+ def placed(content):
300
+ """The images placed in content as image objects, in order."""
301
+ if is_image(content):
302
+ yield content
303
+ elif isinstance(content, Mapping):
304
+ for value in content.values():
305
+ yield from placed(value)
306
+ elif isinstance(content, (list, tuple)):
307
+ for value in content:
308
+ yield from placed(value)
309
+
310
+
311
+ def holds_image(content, references=()) -> bool:
312
+ """Whether content holds an image: an image object, a TypeSafe-style reference, or one of ``references``."""
313
+ if is_image(content):
314
+ return True
315
+ if isinstance(content, str):
316
+ return bool(REFERENCE.search(content)) or any(reference in content for reference in references)
317
+ if isinstance(content, Mapping):
318
+ return any(holds_image(value, references) for value in content.values())
319
+ if isinstance(content, (list, tuple)):
320
+ return any(holds_image(value, references) for value in content)
321
+ return False
322
+
323
+
324
+ def content_text(content) -> str:
325
+ """Render text as is and JSON objects or arrays as compact JSON."""
326
+ if content is None:
327
+ return ""
328
+ if isinstance(content, str):
329
+ return content
330
+ return json.dumps(content, ensure_ascii=False, separators=(", ", ": "), sort_keys=False)
331
+
332
+
333
+ def normalize_question(question) -> Noul | Choice | Score | Box:
334
+ """Coerce a yev question, a TypeSafe SDK question or a ``{"type": ...}`` dict into a yev question."""
335
+ if isinstance(question, (Noul, Choice, Score, Box)):
336
+ return question
337
+ if isinstance(question, Mapping):
338
+ payload = dict(question)
339
+ else:
340
+ dump = getattr(question, "model_dump", None)
341
+ if dump is None:
342
+ raise TypeError(f"Unsupported question object: {type(question).__name__}")
343
+ payload = dump()
344
+ kind = payload.get("type")
345
+ if kind not in QUESTION_TYPES:
346
+ raise ValueError(f"Unknown question type: {kind!r}")
347
+ return QUESTION_TYPES[kind].model_validate(payload)
348
+
349
+
350
+ def checked_dictionary(name, question):
351
+ """The TypeSafe SDK's checks on a question dictionary, before it is read."""
352
+ kind = question.get("type")
353
+ if not isinstance(kind, str) or not kind:
354
+ raise YevError(f'Question "{name}" must be a question object or a dictionary with a nonempty string "type".')
355
+ if kind in ("choice", "score") and "criteria" not in question:
356
+ raise YevError(f'Question "{name}" requires "criteria".')
357
+ if kind in ("choice", "score") and not question["criteria"]:
358
+ raise YevError(f'{kind.title()} question "{name}" has no criteria; at least one {kind} is required.')
359
+ if kind == "noul" and question.get("criteria") is not None and not isinstance(question["criteria"], Mapping):
360
+ raise YevError(f'Noul question "{name}" criteria must be an object.')
361
+
362
+
363
+ def checked_question(name, question) -> Noul | Choice | Score:
364
+ """One question as a yev question, checked as the TypeSafe SDK checks it, then against what yev answers;
365
+ ``YevError`` names it."""
366
+ if isinstance(question, Mapping):
367
+ checked_dictionary(name, question)
368
+ try:
369
+ question = normalize_question(question)
370
+ except (TypeError, ValueError) as error:
371
+ raise YevError(f'Question "{name}" is not valid: {error}') from error
372
+ if isinstance(question, Box):
373
+ raise YevError(f'Box question "{name}" cannot be answered: yev has Noul, Choice and Score heads, and no box head.')
374
+ if isinstance(question, Noul):
375
+ if question.instructions is None and not any(value is not None for value in (question.criteria or {}).values()):
376
+ raise YevError(f'Noul question "{name}" has neither instructions nor criteria; at least one is required.')
377
+ return question
378
+ count = len(question.criteria)
379
+ low, high = CHOICE_LIMITS if question.type == "choice" else SCORE_LIMITS
380
+ if count < low:
381
+ raise YevError(f'{question.type.title()} question "{name}" has no criteria; at least one {question.type} is required.')
382
+ if count > high:
383
+ raise YevError(f'{question.type.title()} question "{name}" has {count} criteria; yev answers at most {high}.')
384
+ return question
385
+
386
+
387
+ def normalize_questions(questions: Questions) -> dict[str, Noul | Choice | Score]:
388
+ """Every question as a yev question, checked by ``checked_question``."""
389
+ if not questions:
390
+ raise YevError("At least one question is required.")
391
+ return {name: checked_question(name, question) for name, question in questions.items()}
392
+
393
+
394
+ def question_content(question):
395
+ """A question's instructions and criteria values: everything an image could hide in."""
396
+ criteria = getattr(question, "criteria", None) or {}
397
+ return [question.instructions, *(criteria.values() if isinstance(criteria, Mapping) else criteria)]
398
+
399
+
400
+ def question_labels_and_descriptions(question: Noul | Choice | Score) -> tuple[list[str], list[str]]:
401
+ """Labels the answer will be keyed by, and the criterion text shown to the model, in order."""
402
+ if isinstance(question, Noul):
403
+ criteria = question.criteria or {}
404
+ descriptions = [
405
+ content_text(criteria.get("false")) or "false",
406
+ content_text(criteria.get("true")) or "true",
407
+ ]
408
+ return ["false", "true"], descriptions
409
+ if isinstance(question, Choice):
410
+ labels = list(question.criteria)
411
+ descriptions = [
412
+ f"{label}: {content_text(description)}" if description not in (None, "") else label
413
+ for label, description in question.criteria.items()
414
+ ]
415
+ return labels, descriptions
416
+ descriptions = [content_text(level) for level in question.criteria]
417
+ return [str(index) for index in range(len(descriptions))], descriptions
src/yev/training/__init__.py ADDED
File without changes
src/yev/training/comparison.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Student-versus-teacher distribution comparison on held-out Jev-labelled questions.
2
+
3
+ Pure metrics plus a batched scorer. Nothing here calls the Jev API or downloads data.
4
+ """
5
+
6
+ from collections import defaultdict
7
+ import math
8
+ import random
9
+
10
+ from .model import PRIMITIVES, collator_for
11
+ from .run import device_batch, evaluation_mode
12
+
13
+
14
+ def stratified_sample(examples, limit, seed=42):
15
+ """Deterministically pick up to ``limit`` examples with primitives balanced."""
16
+ if type(limit) is not int or limit < 1:
17
+ raise ValueError("limit must be a positive integer")
18
+ pools = {kind: [example for example in examples if example.kind == kind] for kind in PRIMITIVES}
19
+ for kind in PRIMITIVES:
20
+ random.Random(f"{seed}:compare:{kind}").shuffle(pools[kind])
21
+ selected = []
22
+ cursor = 0
23
+ while len(selected) < limit and any(cursor < len(pool) for pool in pools.values()):
24
+ for kind in PRIMITIVES:
25
+ if cursor < len(pools[kind]) and len(selected) < limit:
26
+ selected.append(pools[kind][cursor])
27
+ cursor += 1
28
+ return selected
29
+
30
+
31
+ def comparison_metrics(kind, student, teacher):
32
+ """Per-question distances between two normalized distributions."""
33
+ if len(student) != len(teacher) or not student:
34
+ raise ValueError("Distributions must have the same nonzero length")
35
+ if not all(math.isfinite(value) and value >= 0 for value in student + teacher):
36
+ raise ValueError("Distributions must be finite and nonnegative")
37
+ student_mode = max(range(len(student)), key=student.__getitem__)
38
+ teacher_mode = max(range(len(teacher)), key=teacher.__getitem__)
39
+ metrics = {
40
+ "tvd": 0.5 * sum(abs(p - q) for p, q in zip(student, teacher)),
41
+ "agree": int(student_mode == teacher_mode),
42
+ "log_loss": -sum(q * math.log(max(p, 1e-12)) for p, q in zip(student, teacher)),
43
+ "brier": sum((p - q) ** 2 for p, q in zip(student, teacher)),
44
+ }
45
+ if kind == "score":
46
+ metrics["mean_abs_error"] = abs(sum(index * p for index, p in enumerate(student)) - sum(index * q for index, q in enumerate(teacher)))
47
+ if kind == "noul":
48
+ metrics["probability_abs_error"] = abs(student[1] - teacher[1])
49
+ return metrics
50
+
51
+
52
+ def aggregate(rows):
53
+ """Mean of every metric over rows of ``{"metrics": {...}}``."""
54
+ if not rows:
55
+ return {"count": 0}
56
+ totals = defaultdict(float)
57
+ counts = defaultdict(int)
58
+ for row in rows:
59
+ for name, value in row["metrics"].items():
60
+ totals[name] += value
61
+ counts[name] += 1
62
+ return {"count": len(rows), **{name: totals[name] / counts[name] for name in sorted(totals)}}
63
+
64
+
65
+ def domain_of(example):
66
+ return example.source.split(":", 1)[1] if ":" in example.source else example.source
67
+
68
+
69
+ def student_distributions(model, tokenizer, examples, max_length, micro_batch=8):
70
+ collator = collator_for(model, tokenizer, max_length)
71
+ results = []
72
+ with evaluation_mode(model):
73
+ for start in range(0, len(examples), micro_batch):
74
+ items = examples[start:start + micro_batch]
75
+ batch = device_batch(model, collator, items)
76
+ probabilities = model(**batch)["logits"].float().softmax(-1).cpu()
77
+ for index, item in enumerate(items):
78
+ results.append(probabilities[index, :len(item.target)].tolist())
79
+ return results
80
+
81
+
82
+ def offline_report(model, tokenizer, examples, max_length, micro_batch=8):
83
+ predictions = student_distributions(model, tokenizer, examples, max_length, micro_batch)
84
+ rows, baseline_rows = [], []
85
+ for example, student in zip(examples, predictions):
86
+ uniform = [1 / len(example.target)] * len(example.target)
87
+ rows.append({"uid": example.uid, "kind": example.kind, "domain": domain_of(example), "student": student,
88
+ "teacher": example.target, "metrics": comparison_metrics(example.kind, student, example.target)})
89
+ baseline_rows.append({"kind": example.kind, "domain": domain_of(example), "metrics": comparison_metrics(example.kind, uniform, example.target)})
90
+ by = lambda rows, key: {value: aggregate([row for row in rows if row[key] == value]) for value in sorted({row[key] for row in rows})} # noqa: E731
91
+ return {
92
+ "student": {"overall": aggregate(rows), "by_primitive": by(rows, "kind"), "by_domain": by(rows, "domain")},
93
+ "uniform_baseline": {"overall": aggregate(baseline_rows), "by_primitive": by(baseline_rows, "kind")},
94
+ "rows": rows,
95
+ }
96
+
97
+
98
+ def format_table(report):
99
+ lines = [f"{'slice':28} {'n':>6} {'TVD':>7} {'agree':>7} {'logloss':>8} {'brier':>7}"]
100
+ student, baseline = report["student"], report["uniform_baseline"]
101
+ def line(name, stats): # noqa: E306
102
+ return f"{name:28} {stats['count']:>6} {stats.get('tvd', float('nan')):>7.3f} {stats.get('agree', float('nan')):>7.3f} {stats.get('log_loss', float('nan')):>8.3f} {stats.get('brier', float('nan')):>7.3f}"
103
+ lines.append(line("student / overall", student["overall"]))
104
+ lines.append(line("uniform / overall", baseline["overall"]))
105
+ for kind in PRIMITIVES:
106
+ if kind in student["by_primitive"]:
107
+ lines.append(line(f"student / {kind}", student["by_primitive"][kind]))
108
+ lines.append(line(f"uniform / {kind}", baseline["by_primitive"][kind]))
109
+ for domain, stats in student["by_domain"].items():
110
+ lines.append(line(f"student / {domain}", stats))
111
+ return "\n".join(lines)
src/yev/training/data.py ADDED
@@ -0,0 +1,649 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import Counter
2
+ import csv
3
+ from dataclasses import asdict, dataclass, field
4
+ import hashlib
5
+ import io
6
+ import json
7
+ import math
8
+ from pathlib import Path
9
+ import random
10
+ import re
11
+ from urllib.request import Request, urlopen
12
+
13
+ from .images import IMAGE_PLACEHOLDER
14
+
15
+
16
+ SPLITS = ("train", "dev", "calibration", "test")
17
+ BANKING_REPOSITORY = "https://github.com/PolyAI-LDN/task-specific-datasets"
18
+ BANKING_REVISION_URL = "https://api.github.com/repos/PolyAI-LDN/task-specific-datasets/commits?path=banking_data&per_page=1"
19
+ SEVERITY_CRITERIA = ["Routine", "Low", "Moderate", "High", "Critical"]
20
+ PRIORITY_CRITERIA = ["P4 Low", "P3 Normal", "P2 High", "P1 Urgent"]
21
+ PRIORITY_INSTRUCTIONS = (
22
+ "Apply the highest matching ticket priority rule. P1 Urgent: a confirmed outage, or at least 100 customers affected. "
23
+ "P2 High: at least 20 customers affected, or the ticket has been open for at least 48 hours. "
24
+ "P3 Normal: at least 5 customers affected, or open for at least 8 hours. Otherwise P4 Low. "
25
+ "The criteria are ordered from lowest to highest priority."
26
+ )
27
+ BINOMIAL_INSTRUCTIONS = (
28
+ "How many of the drawn beads will be red? Preserve the actual mathematical outcome probabilities. "
29
+ "The criteria are ordered by the number of red beads drawn."
30
+ )
31
+ SEVERITY_INSTRUCTIONS = (
32
+ "Apply the highest matching incident severity rule. Critical: data loss, downtime >= 120 minutes, "
33
+ "or affected users >= 75%. High: downtime >= 60 minutes or affected users >= 40%. "
34
+ "Moderate: downtime >= 15 minutes or affected users >= 10%. "
35
+ "Low: downtime >= 5 minutes or affected users >= 1%. Otherwise Routine. "
36
+ "The criteria are ordered from least to most severe."
37
+ )
38
+
39
+
40
+ @dataclass
41
+ class DecisionExample:
42
+ uid: str
43
+ group: str
44
+ source: str
45
+ split: str
46
+ kind: str
47
+ state: str
48
+ instructions: str
49
+ criteria: list[str]
50
+ target: list[float]
51
+ images: list[str] = field(default_factory=list) # digests of the images the state's placeholders stand for, in order
52
+
53
+ def __post_init__(self):
54
+ limits = {"choice": (2, 255), "score": (2, 10), "noul": (2, 2)}
55
+ if self.kind not in limits or self.split not in {"train", "dev", "calibration", "test"}:
56
+ raise ValueError("Unknown primitive or split")
57
+ minimum, maximum = limits[self.kind]
58
+ if not minimum <= len(self.criteria) <= maximum or len(self.target) != len(self.criteria):
59
+ raise ValueError("Criteria and targets must match the primitive's size limits")
60
+ if any(not math.isfinite(value) or value < 0 for value in self.target):
61
+ raise ValueError("Targets must contain finite nonnegative probabilities")
62
+ if not math.isclose(sum(self.target), 1.0, abs_tol=1e-6):
63
+ raise ValueError("Target probabilities must sum to one")
64
+ self.images = list(self.images)
65
+ if self.state.count(IMAGE_PLACEHOLDER) != len(self.images):
66
+ raise ValueError(f"The state must hold one {IMAGE_PLACEHOLDER} for each image")
67
+
68
+ def to_dict(self):
69
+ """The record as stored; a text-only example has no ``images`` key, so text corpora and
70
+ their fingerprints are unchanged."""
71
+ record = asdict(self)
72
+ if not self.images:
73
+ record.pop("images")
74
+ return record
75
+
76
+
77
+ def is_certain(target):
78
+ """Whether one answer holds all of the target's probability."""
79
+ return max(target) > 0.999
80
+
81
+
82
+ def top_answer(target):
83
+ """The target as its single most likely answer."""
84
+ top = target.index(max(target))
85
+ return [float(index == top) for index in range(len(target))]
86
+
87
+
88
+ def document_group(source, state):
89
+ normalized = " ".join(state.lower().split())
90
+ digest = hashlib.sha256(normalized.encode()).hexdigest()
91
+ return f"{source}:{digest}"
92
+
93
+
94
+ def split_for_group(group, original_split):
95
+ if original_split == "test":
96
+ return "test"
97
+ bucket = int(hashlib.sha256(group.encode()).hexdigest()[:8], 16) % 100
98
+ if bucket < 80:
99
+ return "train"
100
+ return "dev" if bucket < 90 else "calibration"
101
+
102
+
103
+ def probability_examples(seed=42, count=1000):
104
+ generator = random.Random(seed)
105
+ examples = []
106
+ seen = set()
107
+ if not 1 <= count <= 10000:
108
+ raise ValueError("Synthetic count must be between 1 and 10000")
109
+ remaining = synthetic_split_quotas(count)
110
+ while len(seen) < count:
111
+ total = generator.randint(2, 1000)
112
+ red = generator.randint(0, total)
113
+ state = f"A bag contains {red} red beads and {total - red} blue beads. One bead is drawn uniformly at random."
114
+ group = document_group("synthetic_probability", state)
115
+ split = synthetic_split(group)
116
+ if group in seen or remaining[split] == 0:
117
+ continue
118
+ seen.add(group)
119
+ remaining[split] -= 1
120
+ probability = red / total
121
+ for kind in ("choice", "score", "noul"):
122
+ examples.append(DecisionExample(
123
+ uid=f"{group}:{kind}", group=group, source="synthetic_probability", split=split,
124
+ kind=kind, state=state,
125
+ instructions="Will the drawn bead be red? Preserve the actual mathematical outcome probabilities.",
126
+ criteria=["false", "true"] if kind == "noul" else ["The drawn bead is blue (no).", "The drawn bead is red (yes)."],
127
+ target=[1 - probability, probability],
128
+ ))
129
+ return examples
130
+
131
+
132
+ def synthetic_split(group):
133
+ bucket = int(hashlib.sha256(group.encode()).hexdigest()[:8], 16) % 100
134
+ if bucket < 70:
135
+ return "train"
136
+ if bucket < 80:
137
+ return "dev"
138
+ return "calibration" if bucket < 90 else "test"
139
+
140
+
141
+ def synthetic_split_quotas(count):
142
+ if count < 4:
143
+ return {split: int(index < count) for index, split in enumerate(SPLITS)}
144
+ heldout = max(1, count // 10)
145
+ return dict(zip(SPLITS, (count - 3 * heldout, heldout, heldout, heldout)))
146
+
147
+
148
+ def download_bytes(url, max_bytes=8 * 1024 * 1024):
149
+ request = Request(url, headers={"User-Agent": "jev-investigation-data-preparation"})
150
+ with urlopen(request, timeout=30) as response:
151
+ content = response.read(max_bytes + 1)
152
+ if len(content) > max_bytes:
153
+ raise ValueError(f"Source exceeds the {max_bytes}-byte download limit: {url}")
154
+ return content
155
+
156
+
157
+ def parse_banking_csv(content, categories):
158
+ reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig"), newline=""))
159
+ if reader.fieldnames != ["text", "category"]:
160
+ raise ValueError("BANKING77 CSV must have the original text,category header")
161
+ records = list(reader)
162
+ for row in records:
163
+ if set(row) != {"text", "category"} or not row["text"] or not row["text"].strip():
164
+ raise ValueError("BANKING77 CSV contains an empty or malformed document")
165
+ if row["category"] not in categories:
166
+ raise ValueError(f"Unknown BANKING77 category: {row['category']!r}")
167
+ return records
168
+
169
+
170
+ def load_banking77():
171
+ revision_content = download_bytes(BANKING_REVISION_URL, max_bytes=256 * 1024)
172
+ revision = json.loads(revision_content)[0]["sha"]
173
+ if not isinstance(revision, str) or re.fullmatch(r"[0-9a-f]{40}", revision) is None:
174
+ raise ValueError("GitHub did not return a valid BANKING77 commit SHA")
175
+ base_url = f"https://raw.githubusercontent.com/PolyAI-LDN/task-specific-datasets/{revision}/banking_data"
176
+ contents = {}
177
+ files = {}
178
+ for filename in ("categories.json", "train.csv", "test.csv"):
179
+ url = f"{base_url}/{filename}"
180
+ content = download_bytes(url)
181
+ contents[filename] = content
182
+ files[filename] = {"url": url, "sha256": hashlib.sha256(content).hexdigest(), "bytes": len(content)}
183
+ categories = json.loads(contents["categories.json"])
184
+ if (
185
+ not isinstance(categories, list) or len(categories) != 77
186
+ or any(not isinstance(category, str) or not category for category in categories)
187
+ or len(set(categories)) != 77
188
+ ):
189
+ raise ValueError("BANKING77 categories.json must contain 77 unique category names")
190
+ records = {split: parse_banking_csv(contents[f"{split}.csv"], categories) for split in ("train", "test")}
191
+ provenance = {
192
+ "url": BANKING_REPOSITORY,
193
+ "revision": revision,
194
+ "revision_url": BANKING_REVISION_URL,
195
+ "revision_response_sha256": hashlib.sha256(revision_content).hexdigest(),
196
+ "files": files,
197
+ "license": "CC-BY-4.0",
198
+ "license_url": "https://creativecommons.org/licenses/by/4.0/",
199
+ "license_evidence_url": "https://huggingface.co/datasets/PolyAI/banking77#licensing-information",
200
+ "attribution": "BANKING77 by PolyAI; Casanueva et al., Efficient Intent Detection with Dual Sentence Encoders (2020).",
201
+ "modifications": "Normalized-document deduplication; seeded subsampling; subset-choice and binary intent questions.",
202
+ "category_count": len(categories),
203
+ "raw_records": {split: len(rows) for split, rows in records.items()},
204
+ "target_type": "Observed single-label annotations, not calibrated soft probabilities.",
205
+ }
206
+ return records, categories, provenance
207
+
208
+
209
+ def banking_group_policy(records, categories):
210
+ if len(categories) < 2 or len(set(categories)) != len(categories):
211
+ raise ValueError("At least two distinct intent categories are required")
212
+ grouped_labels = {}
213
+ occurrences = Counter()
214
+ for original_split in ("train", "test"):
215
+ for row in records[original_split]:
216
+ if not row["text"].strip() or row["category"] not in categories:
217
+ raise ValueError("BANKING77 records require nonempty text and a known category")
218
+ group = document_group("banking77", row["text"])
219
+ grouped_labels.setdefault(group, {}).setdefault(original_split, set()).add(row["category"])
220
+ occurrences[group] += 1
221
+ owners = {group: "test" if "test" in labels else "train" for group, labels in grouped_labels.items()}
222
+ ambiguous = {group for group, labels in grouped_labels.items() if len(labels[owners[group]]) > 1}
223
+ audit = {
224
+ "raw_records": {split: len(records[split]) for split in ("train", "test")},
225
+ "unique_source_groups": len(grouped_labels),
226
+ "duplicate_groups": sum(count > 1 for count in occurrences.values()),
227
+ "conflicting_groups": sum(len(set().union(*labels.values())) > 1 for labels in grouped_labels.values()),
228
+ "ambiguous_owner_groups_dropped": len(ambiguous),
229
+ "test_priority_groups": sum(len(labels) == 2 for labels in grouped_labels.values()),
230
+ "sampled_raw_records": {},
231
+ "retained_documents": {},
232
+ "excluded_sampled_records": {},
233
+ }
234
+ return owners, ambiguous, audit
235
+
236
+
237
+ def banking_questions(state, group, split, label, categories, seed):
238
+ generator = random.Random(f"{seed}:{group}")
239
+ alternatives = [category for category in categories if category != label]
240
+ candidate_count = generator.randint(2, min(8, len(categories)))
241
+ candidates = generator.sample(alternatives, candidate_count - 1) + [label]
242
+ generator.shuffle(candidates)
243
+ examples = [DecisionExample(
244
+ uid=f"{group}:choice", group=group, source="banking77", split=split, kind="choice",
245
+ state=state, instructions="Select the customer's intent from the offered candidates.",
246
+ criteria=candidates, target=[float(candidate == label) for candidate in candidates],
247
+ )]
248
+ negative = generator.choice(alternatives)
249
+ for predicate, truth in ((label, True), (negative, False)):
250
+ examples.append(DecisionExample(
251
+ uid=f"{group}:noul:{str(truth).lower()}", group=group, source="banking77", split=split,
252
+ kind="noul", state=state,
253
+ instructions=f"Does the customer's request express the intent '{predicate}'?",
254
+ criteria=["false", "true"], target=[float(not truth), float(truth)],
255
+ ))
256
+ return examples
257
+
258
+
259
+ def banking_examples(records, categories, seed=42, bank_limit=3000):
260
+ if type(bank_limit) is not int or bank_limit < 0:
261
+ raise ValueError("bank_limit must be a nonnegative integer")
262
+ owners, ambiguous, audit = banking_group_policy(records, categories)
263
+ generator = random.Random(seed)
264
+ examples = []
265
+ seen = set()
266
+ for original_split in ("train", "test"):
267
+ shuffled = list(records[original_split])
268
+ generator.shuffle(shuffled)
269
+ sampled = shuffled[:bank_limit]
270
+ audit["sampled_raw_records"][original_split] = len(sampled)
271
+ retained = 0
272
+ excluded = Counter()
273
+ for row in sampled:
274
+ state = row["text"].strip()
275
+ group = document_group("banking77", state)
276
+ if owners[group] != original_split:
277
+ excluded["test_priority"] += 1
278
+ continue
279
+ if group in ambiguous:
280
+ excluded["ambiguous_label"] += 1
281
+ continue
282
+ if group in seen:
283
+ excluded["duplicate"] += 1
284
+ continue
285
+ seen.add(group)
286
+ retained += 1
287
+ split = split_for_group(group, original_split)
288
+ examples.extend(banking_questions(state, group, split, row["category"], categories, seed))
289
+ audit["retained_documents"][original_split] = retained
290
+ audit["excluded_sampled_records"][original_split] = dict(excluded)
291
+ return examples, audit
292
+
293
+
294
+ def severity_label(downtime_minutes, affected_percent, data_loss):
295
+ if data_loss or downtime_minutes >= 120 or affected_percent >= 75:
296
+ return 4
297
+ if downtime_minutes >= 60 or affected_percent >= 40:
298
+ return 3
299
+ if downtime_minutes >= 15 or affected_percent >= 10:
300
+ return 2
301
+ if downtime_minutes >= 5 or affected_percent >= 1:
302
+ return 1
303
+ return 0
304
+
305
+
306
+ def severity_examples(seed=42, count=200):
307
+ if type(count) is not int or not 1 <= count <= 200:
308
+ raise ValueError("Severity count must be between 1 and 200")
309
+ generator = random.Random(seed)
310
+ remaining = synthetic_split_quotas(count)
311
+ templates = {
312
+ "train": "Service incident: downtime_minutes={downtime}; affected_percent={affected}; data_loss={loss}.",
313
+ "dev": "Monitoring summary: {affected}% of users affected; downtime {downtime} minutes; data loss {loss}.",
314
+ "calibration": "Support audit records data loss {loss}, {downtime} minutes of downtime and {affected}% affected users.",
315
+ "test": "An operations review measured downtime of {downtime} minutes. The affected share was {affected}% of users. Data loss: {loss}.",
316
+ }
317
+ bands = ((0, 4, 0, 0), (5, 14, 1, 9), (15, 59, 10, 39), (60, 119, 40, 74), (120, 240, 75, 100))
318
+ examples = []
319
+ seen = set()
320
+ while len(examples) < count:
321
+ minimum_time, maximum_time, minimum_impact, maximum_impact = generator.choice(bands)
322
+ downtime = generator.randint(minimum_time, maximum_time)
323
+ affected = generator.randint(minimum_impact, maximum_impact)
324
+ loss = generator.random() < 0.1
325
+ scenario = json.dumps([downtime, affected, loss])
326
+ group = document_group("synthetic_severity", scenario)
327
+ split = synthetic_split(group)
328
+ if group in seen or remaining[split] == 0:
329
+ continue
330
+ seen.add(group)
331
+ remaining[split] -= 1
332
+ label = severity_label(downtime, affected, loss)
333
+ examples.append(DecisionExample(
334
+ uid=f"{group}:score", group=group, source="synthetic_severity", split=split, kind="score",
335
+ state=templates[split].format(downtime=downtime, affected=affected, loss=str(loss).lower()),
336
+ instructions=SEVERITY_INSTRUCTIONS, criteria=list(SEVERITY_CRITERIA),
337
+ target=[float(index == label) for index in range(len(SEVERITY_CRITERIA))],
338
+ ))
339
+ return examples
340
+
341
+
342
+ def binomial_examples(seed=42, count=500):
343
+ """Ordered Score questions with exact binomial targets over 3 to 6 levels.
344
+
345
+ A bag with known red/blue counts is sampled with replacement ``draws`` times; level ``i``
346
+ is "exactly i red beads". The target is Binomial(draws, red / total), so calibration sees
347
+ genuinely spread ordinal distributions rather than one-hot rules. Templates are split-exclusive.
348
+ """
349
+ if type(count) is not int or not 1 <= count <= 10000:
350
+ raise ValueError("Binomial count must be between 1 and 10000")
351
+ generator = random.Random(seed)
352
+ remaining = synthetic_split_quotas(count)
353
+ templates = {
354
+ "train": "A bag contains {red} red beads and {blue} blue beads. {draws} beads are drawn one at a time, each returned to the bag before the next draw.",
355
+ "dev": "There are {blue} blue and {red} red beads in a bag. A bead is drawn uniformly at random and put back; this is repeated {draws} times in total.",
356
+ "calibration": "Bag contents: {red} red, {blue} blue. Procedure: draw one bead at random, note its colour, replace it; perform {draws} draws.",
357
+ "test": "{draws} independent draws with replacement are made from a bag holding {red} red beads and {blue} blue beads.",
358
+ }
359
+ examples = []
360
+ seen = set()
361
+ while len(examples) < count:
362
+ total = generator.randint(2, 60)
363
+ red = generator.randint(0, total)
364
+ draws = generator.randint(2, 5)
365
+ scenario = json.dumps([red, total - red, draws])
366
+ group = document_group("synthetic_binomial", scenario)
367
+ split = synthetic_split(group)
368
+ if group in seen or remaining[split] == 0:
369
+ continue
370
+ seen.add(group)
371
+ remaining[split] -= 1
372
+ probability = red / total
373
+ target = [math.comb(draws, hits) * probability ** hits * (1 - probability) ** (draws - hits) for hits in range(draws + 1)]
374
+ examples.append(DecisionExample(
375
+ uid=f"{group}:score", group=group, source="synthetic_binomial", split=split, kind="score",
376
+ state=templates[split].format(red=red, blue=total - red, draws=draws),
377
+ instructions=BINOMIAL_INSTRUCTIONS,
378
+ criteria=[f"Exactly {hits} of the {draws} drawn beads are red." for hits in range(draws + 1)],
379
+ target=target,
380
+ ))
381
+ return examples
382
+
383
+
384
+ def priority_label(customers_affected, hours_open, outage):
385
+ if outage or customers_affected >= 100:
386
+ return 3
387
+ if customers_affected >= 20 or hours_open >= 48:
388
+ return 2
389
+ if customers_affected >= 5 or hours_open >= 8:
390
+ return 1
391
+ return 0
392
+
393
+
394
+ def priority_examples(seed=42, count=400):
395
+ """Four-level one-hot Score questions from executable ticket-priority rules with held-out wording."""
396
+ if type(count) is not int or not 1 <= count <= 400:
397
+ raise ValueError("Priority count must be between 1 and 400")
398
+ generator = random.Random(seed)
399
+ remaining = synthetic_split_quotas(count)
400
+ templates = {
401
+ "train": "Support ticket: customers_affected={customers}; hours_open={hours}; confirmed_outage={outage}.",
402
+ "dev": "Ticket status: open for {hours} hours, {customers} customers affected, confirmed outage {outage}.",
403
+ "calibration": "The queue report lists a confirmed outage flag of {outage}, {customers} affected customers and {hours} hours since the ticket was opened.",
404
+ "test": "A ticket has been open {hours} hours. It affects {customers} customers. Outage confirmed: {outage}.",
405
+ }
406
+ customer_bands = ((0, 4), (5, 19), (20, 99), (100, 500))
407
+ hour_bands = ((0, 7), (8, 47), (48, 200))
408
+ examples = []
409
+ seen = set()
410
+ while len(examples) < count:
411
+ customers = generator.randint(*generator.choice(customer_bands))
412
+ hours = generator.randint(*generator.choice(hour_bands))
413
+ outage = generator.random() < 0.1
414
+ scenario = json.dumps([customers, hours, outage])
415
+ group = document_group("synthetic_priority", scenario)
416
+ split = synthetic_split(group)
417
+ if group in seen or remaining[split] == 0:
418
+ continue
419
+ seen.add(group)
420
+ remaining[split] -= 1
421
+ label = priority_label(customers, hours, outage)
422
+ examples.append(DecisionExample(
423
+ uid=f"{group}:score", group=group, source="synthetic_priority", split=split, kind="score",
424
+ state=templates[split].format(customers=customers, hours=hours, outage=str(outage).lower()),
425
+ instructions=PRIORITY_INSTRUCTIONS, criteria=list(PRIORITY_CRITERIA),
426
+ target=[float(index == label) for index in range(len(PRIORITY_CRITERIA))],
427
+ ))
428
+ return examples
429
+
430
+
431
+ def jsonl_content(examples):
432
+ return "".join(json.dumps(example.to_dict(), sort_keys=True, ensure_ascii=True) + "\n" for example in examples).encode("utf-8")
433
+
434
+
435
+ def partition_examples(examples, seed, require_all_primitives):
436
+ splits = {split: [] for split in SPLITS}
437
+ group_splits = {}
438
+ uids = set()
439
+ for example in examples:
440
+ if group_splits.setdefault(example.group, example.split) != example.split or example.uid in uids:
441
+ raise ValueError("Duplicate example UID or source-document leakage across splits")
442
+ uids.add(example.uid)
443
+ splits[example.split].append(example)
444
+ for split, examples in splits.items():
445
+ random.Random(f"{seed}:{split}").shuffle(examples)
446
+ if require_all_primitives and {example.kind for example in examples} != {"choice", "score", "noul"}:
447
+ raise ValueError(f"Missing primitive in {split}")
448
+ return splits
449
+
450
+
451
+ def prepare_data(output_dir, seed=42, bank_limit=3000, synthetic_count=500, include_sst5=False) -> dict[str, list[DecisionExample]]:
452
+ if include_sst5:
453
+ raise ValueError("SST5 is deferred: its dataset card has no verified license metadata; include_sst5=True is unsupported")
454
+ if type(seed) is not int:
455
+ raise ValueError("seed must be an integer")
456
+ if type(bank_limit) is not int or bank_limit < 0:
457
+ raise ValueError("bank_limit must be a nonnegative integer")
458
+ if type(synthetic_count) is not int or not 1 <= synthetic_count <= 10000:
459
+ raise ValueError("synthetic_count must be between 1 and 10000")
460
+ records, categories, provenance = load_banking77()
461
+ bank, audit = banking_examples(records, categories, seed=seed, bank_limit=bank_limit)
462
+ probabilities = probability_examples(seed=seed, count=synthetic_count)
463
+ severity = severity_examples(seed=seed, count=min(200, synthetic_count))
464
+ binomial = binomial_examples(seed=seed, count=synthetic_count)
465
+ priority = priority_examples(seed=seed, count=min(400, synthetic_count))
466
+ splits = partition_examples(bank + probabilities + severity + binomial + priority, seed, require_all_primitives=synthetic_count >= 4)
467
+ sources = {"banking77": {**provenance, "audit": audit}}
468
+ target_types = {
469
+ "synthetic_probability": "Exact mathematical probabilities",
470
+ "synthetic_severity": "Executable severity_label rule, one-hot targets",
471
+ "synthetic_binomial": "Exact binomial probabilities over 3-6 ordered levels",
472
+ "synthetic_priority": "Executable priority_label rule, one-hot targets over 4 levels",
473
+ }
474
+ for name, examples, generator_revision in (
475
+ ("synthetic_probability", probabilities, "uniform-bags-v1"),
476
+ ("synthetic_severity", severity, "ordered-incident-rules-v1"),
477
+ ("synthetic_binomial", binomial, "binomial-draws-v1"),
478
+ ("synthetic_priority", priority, "ordered-ticket-priority-rules-v1"),
479
+ ):
480
+ sources[name] = {
481
+ "urls": [], "origin": "Locally generated; no external dataset or teacher model",
482
+ "license": "Not specified for locally authored synthetic data",
483
+ "generator_revision": generator_revision,
484
+ "sha256": hashlib.sha256(jsonl_content(examples)).hexdigest(),
485
+ "documents": len({example.group for example in examples}), "examples": len(examples),
486
+ "target_type": target_types[name],
487
+ }
488
+ manifest = {
489
+ "format_version": 1,
490
+ "config": {"seed": seed, "bank_limit": bank_limit, "synthetic_count": synthetic_count, "include_sst5": include_sst5},
491
+ "sources": sources,
492
+ "counts": {
493
+ "examples": {split: len(examples) for split, examples in splits.items()},
494
+ "groups": {split: len({example.group for example in examples}) for split, examples in splits.items()},
495
+ "by_source": {split: dict(Counter(example.source for example in examples)) for split, examples in splits.items()},
496
+ "by_primitive": {split: dict(Counter(example.kind for example in examples)) for split, examples in splits.items()},
497
+ },
498
+ "policies": {
499
+ "banking_task": "Subset-choice classification with 2-8 candidates, NOT a full-77 benchmark; one positive and one negative Noul per document.",
500
+ "groups": "Source plus SHA256 of lowercase, whitespace-collapsed document; no punctuation removal or semantic deduplication.",
501
+ "banking_splits": "Original test groups reserved before sampling. Original train groups hash to train/dev/calibration at 80/10/10.",
502
+ "duplicates": "Test labels win cross-split conflicts; conflicting labels within the owning split drop the whole group; no refill after raw-record caps.",
503
+ "synthetic_splits": "Canonical groups hash to 70/10/10/10; rejection sampling meets quotas without moving or copying groups between splits.",
504
+ "severity_holdout": "Four split-exclusive rendering templates, shared rules; canonical numeric scenarios grouped independently of template.",
505
+ "gold_score": "Score calibration/test draw on binary bags, exact binomial draws (3-6 levels), and two executable rule tasks (4 and 5 levels); no human ratings.",
506
+ "small_counts": "Every primitive in every split guaranteed for synthetic_count >= 4; fewer groups can leave splits empty.",
507
+ "sst5": "Deferred, license unverified; enabling it raises ValueError.",
508
+ },
509
+ "outputs": {},
510
+ }
511
+ destination = Path(output_dir)
512
+ destination.mkdir(parents=True, exist_ok=True)
513
+ for split, examples in splits.items():
514
+ filename = f"{split}.jsonl"
515
+ content = jsonl_content(examples)
516
+ (destination / filename).write_bytes(content)
517
+ manifest["outputs"][filename] = {"sha256": hashlib.sha256(content).hexdigest(), "bytes": len(content)}
518
+ (destination / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
519
+ return splits
520
+
521
+
522
+ def validate_split_integrity(splits):
523
+ group_splits = {}
524
+ uids = set()
525
+ for split, examples in splits.items():
526
+ if split not in SPLITS:
527
+ raise ValueError(f"Unknown split: {split}")
528
+ for example in examples:
529
+ if example.split != split:
530
+ raise ValueError(f"Example {example.uid} belongs to {example.split}, not {split}")
531
+ if not isinstance(example.uid, str) or not example.uid.strip():
532
+ raise ValueError("Example UID must be a nonempty string")
533
+ if not isinstance(example.group, str) or not example.group.strip():
534
+ raise ValueError("Example group must be a nonempty string")
535
+ if example.uid in uids:
536
+ raise ValueError(f"Duplicate example UID: {example.uid}")
537
+ if group_splits.setdefault(example.group, split) != split:
538
+ raise ValueError(f"Semantic group leakage across splits: {example.group}")
539
+ uids.add(example.uid)
540
+
541
+
542
+ # Where a label came from: Jev's distribution, or people (dataset labels and annotator vote shares).
543
+ LABEL_SOURCES = ("jev_teacher:", "human:", "logit_teacher:")
544
+ # Manifest ``model`` values name the labellers a dataset's targets come from, joined by "+": Jev,
545
+ # people, and generative models read through their answer-token logits ("logit:<model>").
546
+ LABELLERS = ("jev-1.13.0", "human")
547
+ LOGIT_LABELLER = "logit:"
548
+
549
+
550
+ def labeller_parts(model):
551
+ """The labellers in a manifest ``model`` value, or None when any of them is unrecognised."""
552
+ if not isinstance(model, str) or not model:
553
+ return None
554
+ parts = model.split("+")
555
+ if any(part not in LABELLERS and not (part.startswith(LOGIT_LABELLER) and len(part) > len(LOGIT_LABELLER))
556
+ for part in parts):
557
+ return None
558
+ return parts
559
+
560
+
561
+ def join_labellers(parts):
562
+ """One manifest ``model`` value for several labellers: Jev, then people, then logit teachers."""
563
+ unique = set(parts)
564
+ return "+".join([name for name in LABELLERS if name in unique] + sorted(unique - set(LABELLERS)))
565
+
566
+
567
+ def label_source(source):
568
+ """The labeller prefix of a source string, or None when it has no recognised prefix."""
569
+ return next((prefix for prefix in LABEL_SOURCES if isinstance(source, str) and source.startswith(prefix)), None)
570
+
571
+
572
+ def validate_teacher_splits(splits):
573
+ if set(splits) != {"train", "dev"}:
574
+ raise ValueError("Teacher data must contain only train and dev splits")
575
+ validate_split_integrity(splits)
576
+ for examples in splits.values():
577
+ for example in examples:
578
+ prefix = label_source(example.source)
579
+ if prefix is None or not example.source.removeprefix(prefix).strip():
580
+ raise ValueError(f"Invalid teacher source for {example.uid}: {example.source!r}")
581
+
582
+
583
+ def load_teacher_data(directory) -> dict[str, list[DecisionExample]]:
584
+ directory = Path(directory)
585
+ manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
586
+ if not isinstance(manifest, dict) or manifest.get("complete") is not True:
587
+ raise ValueError("Teacher dataset must be complete before import")
588
+ if type(manifest.get("format_version")) is not int or manifest["format_version"] != 1:
589
+ raise ValueError("Unsupported teacher manifest format_version")
590
+ if labeller_parts(manifest.get("model")) is None:
591
+ raise ValueError(f"Teacher manifest model must name {' or '.join(LABELLERS)} or {LOGIT_LABELLER}<model>, joined by '+'")
592
+ by_split = manifest.get("by_split")
593
+ outputs = manifest.get("outputs")
594
+ if not isinstance(by_split, dict) or set(by_split) != {"train", "dev"} or not isinstance(outputs, dict):
595
+ raise ValueError("Teacher manifest requires train/dev counts and outputs")
596
+ splits = {}
597
+ for split in ("train", "dev"):
598
+ filename = f"{split}.jsonl"
599
+ integrity = outputs.get(filename)
600
+ if not isinstance(integrity, dict):
601
+ raise ValueError(f"Missing teacher output integrity: {filename}")
602
+ content = (directory / filename).read_bytes()
603
+ if hashlib.sha256(content).hexdigest() != integrity.get("sha256"):
604
+ raise ValueError(f"Teacher output SHA256 mismatch: {filename}")
605
+ examples = [DecisionExample(**json.loads(line)) for line in content.decode("utf-8").splitlines()]
606
+ for count in (by_split[split], integrity.get("examples")):
607
+ if type(count) is not int or count != len(examples):
608
+ raise ValueError(f"Teacher example count mismatch: {filename}")
609
+ splits[split] = examples
610
+ accepted = manifest.get("accepted_examples")
611
+ if type(accepted) is not int or accepted != sum(map(len, splits.values())):
612
+ raise ValueError("Teacher manifest accepted_examples mismatch")
613
+ validate_teacher_splits(splits)
614
+ return splits
615
+
616
+
617
+ def write_teacher_data(splits, destination, manifest):
618
+ """Write ``splits`` as a teacher dataset ``load_teacher_data`` reads: ``manifest`` (the source's,
619
+ with any notes of what was filtered) gets the counts and hashes of what is written."""
620
+ destination = Path(destination)
621
+ destination.mkdir(parents=True, exist_ok=True)
622
+ outputs = {}
623
+ for split in ("train", "dev"):
624
+ content = "".join(json.dumps(example.to_dict(), ensure_ascii=True, allow_nan=False) + "\n" for example in splits[split])
625
+ (destination / f"{split}.jsonl").write_text(content, encoding="utf-8")
626
+ outputs[f"{split}.jsonl"] = {"sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), "examples": len(splits[split])}
627
+ manifest = {**manifest, "complete": True, "outputs": outputs,
628
+ "accepted_examples": sum(len(splits[split]) for split in ("train", "dev")),
629
+ "by_split": {split: len(splits[split]) for split in ("train", "dev")},
630
+ "by_primitive": dict(Counter(example.kind for split in ("train", "dev") for example in splits[split]))}
631
+ (destination / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=True, allow_nan=False) + "\n",
632
+ encoding="utf-8")
633
+ load_teacher_data(destination) # a written directory must be a loadable one
634
+ return manifest
635
+
636
+
637
+ def merge_teacher_data(base_splits, teacher_splits, seed=42) -> dict[str, list[DecisionExample]]:
638
+ if set(base_splits) != set(SPLITS):
639
+ raise ValueError("Base data must contain train, dev, calibration and test splits")
640
+ if type(seed) is not int:
641
+ raise ValueError("seed must be an integer")
642
+ validate_teacher_splits(teacher_splits)
643
+ merged = {split: list(base_splits[split]) for split in SPLITS}
644
+ for split in ("train", "dev"):
645
+ merged[split].extend(teacher_splits[split])
646
+ validate_split_integrity(merged)
647
+ for split in ("train", "dev"):
648
+ random.Random(f"{seed}:{split}").shuffle(merged[split])
649
+ return merged
src/yev/training/images.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Images in System One states (v2 plan, Phase 1).
2
+
3
+ A state carries its images as ``IMAGE_PLACEHOLDER`` markers in its text, one for each entry of the
4
+ example's ``images``: content hashes (the SHA-256 of the encoded file) that an ``ImageStore``
5
+ resolves, so a dataset keeps its images beside its JSONL rather than inside it, in an ``images/``
6
+ folder of files named by digest or of uncompressed zip shards (``images-00000.zip``...) holding
7
+ such files, which keeps a Hub repository's file count small.
8
+
9
+ A Qwen3.5 backbone reads each image as ``<|vision_start|>``, one ``<|image_pad|>`` per merged
10
+ patch, then ``<|vision_end|>``. The image processor scales every image to between
11
+ ``MIN_IMAGE_TOKENS`` and ``max_image_tokens`` of those, so images have their own budget and never
12
+ push the question or its criteria out of the sequence; ``max_length`` still bounds the text.
13
+ Pillow is imported only when an image is actually read.
14
+ """
15
+
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from dataclasses import dataclass, field
18
+ import hashlib
19
+ import io
20
+ from pathlib import Path
21
+ import zipfile
22
+
23
+ IMAGE_PLACEHOLDER = "<|image|>"
24
+ DEFAULT_MAX_IMAGE_TOKENS = 256
25
+ MIN_IMAGE_TOKENS = 16
26
+
27
+
28
+ def image_digest(data):
29
+ return hashlib.sha256(data).hexdigest()
30
+
31
+
32
+ def save_image(directory, data):
33
+ """Write encoded image bytes to ``directory`` under their digest; returns the digest."""
34
+ digest = image_digest(data)
35
+ path = Path(directory) / digest
36
+ if not path.exists():
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+ temporary = path.with_suffix(".tmp")
39
+ temporary.write_bytes(data)
40
+ temporary.replace(path)
41
+ return digest
42
+
43
+
44
+ class ImageShards:
45
+ """Writes encoded images into ``images-NNNNN.zip`` shards of at most ``shard_bytes`` each,
46
+ stored uncompressed (images are compressed already) under their digest."""
47
+
48
+ def __init__(self, directory, shard_bytes=1 << 30):
49
+ self.directory, self.shard_bytes = Path(directory), shard_bytes
50
+ self.directory.mkdir(parents=True, exist_ok=True)
51
+ self.written, self.archive, self.size = set(), None, 0
52
+
53
+ def add(self, data):
54
+ digest = image_digest(data)
55
+ if digest in self.written:
56
+ return digest
57
+ if self.archive is None or self.size + len(data) > self.shard_bytes:
58
+ self.close()
59
+ index = len(list(self.directory.glob("images-*.zip")))
60
+ self.archive, self.size = zipfile.ZipFile(self.directory / f"images-{index:05d}.zip", "w", zipfile.ZIP_STORED), 0
61
+ self.archive.writestr(digest, data)
62
+ self.written.add(digest)
63
+ self.size += len(data)
64
+ return digest
65
+
66
+ def close(self):
67
+ if self.archive is not None:
68
+ self.archive.close()
69
+ self.archive = None
70
+
71
+ def __enter__(self):
72
+ return self
73
+
74
+ def __exit__(self, *exception):
75
+ self.close()
76
+
77
+
78
+ class ImageStore:
79
+ """Encoded images by digest: added in memory, or read from registered directories holding files
80
+ named by their digest and zip shards of such files (a dataset's ``images/`` folder)."""
81
+
82
+ def __init__(self, *directories):
83
+ self.directories = []
84
+ self.memory = {}
85
+ self.shards = {} # digest -> zip path
86
+ self.open = {} # zip path -> open ZipFile
87
+ for directory in directories:
88
+ self.add_directory(directory)
89
+
90
+ def add_directory(self, directory):
91
+ directory = Path(directory)
92
+ if directory not in self.directories:
93
+ self.directories.append(directory)
94
+ for archive in sorted(directory.glob("images-*.zip")) if directory.is_dir() else []:
95
+ with zipfile.ZipFile(archive) as opened:
96
+ self.shards.update(dict.fromkeys(opened.namelist(), archive))
97
+
98
+ def put(self, data):
99
+ digest = image_digest(data)
100
+ self.memory[digest] = data
101
+ return digest
102
+
103
+ def read(self, digest):
104
+ if digest in self.memory:
105
+ return self.memory[digest]
106
+ archive = self.shards.get(digest)
107
+ if archive is not None:
108
+ if archive not in self.open:
109
+ self.open[archive] = zipfile.ZipFile(archive)
110
+ return self.open[archive].read(digest)
111
+ for directory in self.directories:
112
+ path = directory / digest
113
+ if path.is_file():
114
+ return path.read_bytes()
115
+ raise KeyError(f"No image {digest} in memory or in {[str(directory) for directory in self.directories]}")
116
+
117
+ def load(self, digest):
118
+ from PIL import Image
119
+
120
+ with Image.open(io.BytesIO(self.read(digest))) as image:
121
+ return image.convert("RGB")
122
+
123
+
124
+ def image_processor(source, max_image_tokens=DEFAULT_MAX_IMAGE_TOKENS, revision=None):
125
+ """The backbone's image processor (PIL backend on every platform, so outputs never depend on
126
+ whether torchvision is installed), sized to ``MIN_IMAGE_TOKENS``..``max_image_tokens`` per image."""
127
+ from transformers.models.qwen2_vl.image_processing_pil_qwen2_vl import Qwen2VLImageProcessorPil
128
+
129
+ processor = Qwen2VLImageProcessorPil.from_pretrained(source, revision=revision)
130
+ pixels_per_token = (processor.patch_size * processor.merge_size) ** 2
131
+ return Qwen2VLImageProcessorPil.from_pretrained(
132
+ source, revision=revision,
133
+ size={"shortest_edge": MIN_IMAGE_TOKENS * pixels_per_token, "longest_edge": max_image_tokens * pixels_per_token})
134
+
135
+
136
+ @dataclass
137
+ class VisionInputs:
138
+ """What the collator needs to turn an example's images into backbone inputs."""
139
+ processor: object
140
+ start_id: int
141
+ pad_id: int
142
+ end_id: int
143
+ max_image_tokens: int = DEFAULT_MAX_IMAGE_TOKENS
144
+ store: ImageStore = field(default_factory=ImageStore)
145
+
146
+ @classmethod
147
+ def for_backbone(cls, backbone_config, processor, max_image_tokens=DEFAULT_MAX_IMAGE_TOKENS, store=None):
148
+ return cls(processor, backbone_config.vision_start_token_id, backbone_config.image_token_id,
149
+ backbone_config.vision_end_token_id, max_image_tokens, store or ImageStore())
150
+
151
+ def prepare(self, examples):
152
+ """Per example, the token block of each of its images; and the batch's pixel values and grids."""
153
+ digests = [digest for example in examples for digest in example.images]
154
+ if not digests:
155
+ return [[] for _ in examples], None
156
+ encoded = self.processor(images=[self.store.load(digest) for digest in digests], return_tensors="pt")
157
+ counts = (encoded["image_grid_thw"].prod(-1) // self.processor.merge_size ** 2).tolist()
158
+ blocks, cursor = [], 0
159
+ for example in examples:
160
+ blocks.append([[self.start_id] + [self.pad_id] * count + [self.end_id]
161
+ for count in counts[cursor:cursor + len(example.images)]])
162
+ cursor += len(example.images)
163
+ return blocks, (encoded["pixel_values"], encoded["image_grid_thw"])
164
+
165
+
166
+ class ImagePrefetcher:
167
+ """Prepares the images of upcoming micro-batches in background threads while the model trains on
168
+ the current one, and stands in for the ``VisionInputs`` it wraps.
169
+
170
+ Only image work runs in the threads: reading from the store, decoding, resizing and patching,
171
+ which Pillow and NumPy do with the GIL released. Tokenizing stays on the caller's thread. A
172
+ micro-batch nobody submitted is prepared on the spot, so the result never depends on what was
173
+ prefetched.
174
+ """
175
+
176
+ def __init__(self, vision, workers=2):
177
+ self.vision = vision
178
+ self.pool = ThreadPoolExecutor(workers, thread_name_prefix="yev-images")
179
+ self.pending = {}
180
+
181
+ def __getattr__(self, name):
182
+ return getattr(self.vision, name)
183
+
184
+ @staticmethod
185
+ def key(examples):
186
+ return tuple(example.uid for example in examples) if any(example.images for example in examples) else None
187
+
188
+ def submit(self, examples):
189
+ key = self.key(examples)
190
+ if key is not None and key not in self.pending:
191
+ self.pending[key] = self.pool.submit(self.vision.prepare, list(examples))
192
+
193
+ def submit_update(self, train, indices, micro_batch):
194
+ """The update's image micro-batches, grouped as ``run.micro_batches`` groups them."""
195
+ pictures = [index for index in indices if train[index].images]
196
+ for start in range(0, len(pictures), micro_batch):
197
+ self.submit([train[index] for index in pictures[start:start + micro_batch]])
198
+
199
+ def prepare(self, examples):
200
+ future = self.pending.pop(self.key(examples), None)
201
+ return future.result() if future is not None else self.vision.prepare(examples)
202
+
203
+ def close(self):
204
+ self.pool.shutdown(wait=False, cancel_futures=True)
205
+ self.pending.clear()
206
+
src/yev/training/model.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import replace
2
+ import math
3
+
4
+ import torch
5
+ from torch import nn
6
+
7
+ from .data import DecisionExample
8
+ from .images import IMAGE_PLACEHOLDER
9
+
10
+
11
+ PRIMITIVES = ("choice", "score", "noul")
12
+ # "mlp": v1's per-primitive MLPs over single token positions. "bilinear": MoJev's readout, one
13
+ # rank-r dot product between pooled context-plus-question and each pooled candidate span.
14
+ HEADS = ("mlp", "bilinear")
15
+ # The bilinear head's optional last-token readout (``last_token``), one projection per role.
16
+ LAST_TOKEN_HEADS = ("option_last", "question_last")
17
+
18
+
19
+ def supports_native_bfloat16():
20
+ return torch.cuda.is_available() and torch.cuda.get_device_capability()[0] >= 8 and torch.cuda.is_bf16_supported()
21
+
22
+
23
+ def candidate_order(example, canonical=False):
24
+ """The order criteria are laid out in: sorted by text for a canonical Choice, otherwise as given.
25
+
26
+ Candidates share one causal sequence, so a candidate's score moves with the rivals placed
27
+ before it. Sorting makes the sequence a function of the candidate set, so a Choice answer
28
+ cannot depend on the order the caller listed its criteria in (MoJev's ``sort_candidates``).
29
+ Score keeps its scale order and Noul its (false, true) order: there the order is the question.
30
+ """
31
+ count = len(example.criteria)
32
+ if canonical and example.kind == "choice":
33
+ return sorted(range(count), key=example.criteria.__getitem__)
34
+ return list(range(count))
35
+
36
+
37
+ def fits_context(example, tokenizer, max_length):
38
+ """Whether a question's instructions and criteria fit ``max_length``. A longer state is cut to
39
+ fit, but answer options never are, so a question that fails this cannot be asked at that length."""
40
+ try:
41
+ encode_example(replace(example, state=".", images=[]), tokenizer, max_length)
42
+ except ValueError:
43
+ return False
44
+ return True
45
+
46
+
47
+ def encode_example(example, tokenizer, max_length, canonical=False, image_blocks=None):
48
+ """Token layout of one question. ``image_blocks`` holds the token block of each of the
49
+ example's images, which replaces its placeholder; images are outside ``max_length``, which
50
+ bounds the text, and when the text is cut the images all stay."""
51
+ order = candidate_order(example, canonical)
52
+ prefix = tokenizer.encode(f"Task: {example.kind}\nState:\n", add_special_tokens=False)
53
+ if example.images:
54
+ if image_blocks is None or len(image_blocks) != len(example.images):
55
+ raise ValueError("A state with images needs a backbone with a vision encoder")
56
+ state_parts = [tokenizer.encode(part, add_special_tokens=False) for part in example.state.split(IMAGE_PLACEHOLDER)]
57
+ else:
58
+ state_parts, image_blocks = [tokenizer.encode(example.state, add_special_tokens=False)], []
59
+ question = tokenizer.encode(f"\nQuestion: {example.instructions}\nCriteria:", add_special_tokens=False)
60
+ criteria = [tokenizer.encode(f"\n{index}: {example.criteria[original]}\n", add_special_tokens=False) for index, original in enumerate(order)]
61
+ suffix = tokenizer.encode("\nDecision probabilities:", add_special_tokens=False)
62
+ state_budget = max_length - len(prefix) - len(question) - sum(map(len, criteria)) - len(suffix)
63
+ if state_budget < 1:
64
+ raise ValueError("Instructions and criteria exceed max_length; increase it instead of truncating answer options")
65
+ truncated = max(0, sum(map(len, state_parts)) - state_budget)
66
+ input_ids, room = list(prefix), state_budget
67
+ for index, part in enumerate(state_parts):
68
+ input_ids.extend(part[:room])
69
+ room = max(0, room - len(part))
70
+ if index < len(image_blocks):
71
+ input_ids.extend(image_blocks[index])
72
+ context_end = len(input_ids)
73
+ input_ids.extend(question)
74
+ question_span = (context_end, len(input_ids))
75
+ positions, spans = [], []
76
+ for criterion in criteria:
77
+ start = len(input_ids)
78
+ input_ids.extend(criterion)
79
+ positions.append(len(input_ids) - 1)
80
+ spans.append((start, len(input_ids)))
81
+ input_ids.extend(suffix)
82
+ return {
83
+ "input_ids": input_ids,
84
+ "criterion_positions": positions,
85
+ "criterion_spans": spans,
86
+ "criterion_order": order,
87
+ "context_end": context_end,
88
+ "question_span": question_span,
89
+ "decision_position": len(input_ids) - 1,
90
+ "kind": PRIMITIVES.index(example.kind),
91
+ "target": example.target,
92
+ "truncated_state_tokens": truncated,
93
+ }
94
+
95
+
96
+ class DecisionCollator:
97
+ """Pads encoded examples into a batch. Criteria arrive in layout order; ``criterion_order`` maps
98
+ each layout slot back to the caller's index, and targets stay in the caller's order."""
99
+
100
+ def __init__(self, tokenizer, max_length=512, canonical=False, vision=None):
101
+ self.tokenizer = tokenizer
102
+ self.max_length = max_length
103
+ self.canonical = canonical
104
+ self.vision = vision
105
+
106
+ def __call__(self, examples):
107
+ blocks, pixels = ([None] * len(examples), None)
108
+ if self.vision is not None and any(example.images for example in examples):
109
+ blocks, pixels = self.vision.prepare(examples)
110
+ encoded = [encode_example(example, self.tokenizer, self.max_length, self.canonical, image_blocks)
111
+ for example, image_blocks in zip(examples, blocks)]
112
+ length = max(len(item["input_ids"]) for item in encoded)
113
+ count = max(len(item["target"]) for item in encoded)
114
+ batch_size = len(encoded)
115
+ batch = {
116
+ "input_ids": torch.full((batch_size, length), self.tokenizer.pad_token_id, dtype=torch.long),
117
+ "attention_mask": torch.zeros(batch_size, length, dtype=torch.long),
118
+ "criterion_positions": torch.zeros(batch_size, count, dtype=torch.long),
119
+ "criterion_mask": torch.zeros(batch_size, count, dtype=torch.bool),
120
+ "criterion_spans": torch.zeros(batch_size, count, 2, dtype=torch.long),
121
+ "criterion_order": torch.arange(count).repeat(batch_size, 1),
122
+ "context_ends": torch.tensor([item["context_end"] for item in encoded]),
123
+ "question_spans": torch.tensor([item["question_span"] for item in encoded]),
124
+ "decision_positions": torch.tensor([item["decision_position"] for item in encoded]),
125
+ "kinds": torch.tensor([item["kind"] for item in encoded]),
126
+ "targets": torch.zeros(batch_size, count, dtype=torch.float32),
127
+ }
128
+ for index, item in enumerate(encoded):
129
+ token_count = len(item["input_ids"])
130
+ option_count = len(item["target"])
131
+ batch["input_ids"][index, :token_count] = torch.tensor(item["input_ids"])
132
+ batch["attention_mask"][index, :token_count] = 1
133
+ batch["criterion_positions"][index, :option_count] = torch.tensor(item["criterion_positions"])
134
+ batch["criterion_spans"][index, :option_count] = torch.tensor(item["criterion_spans"])
135
+ batch["criterion_order"][index, :option_count] = torch.tensor(item["criterion_order"])
136
+ batch["criterion_mask"][index, :option_count] = True
137
+ batch["targets"][index, :option_count] = torch.tensor(item["target"])
138
+ if pixels is not None: # a text-only batch carries no image keys and runs exactly as before
139
+ batch["pixel_values"], batch["image_grid_thw"] = pixels
140
+ batch["mm_token_type_ids"] = (batch["input_ids"] == self.vision.pad_id).int()
141
+ return batch
142
+
143
+
144
+ def collator_for(model, tokenizer, max_length, vision=None):
145
+ """The collator matching ``model``: its candidate layout, and its vision inputs when it reads images."""
146
+ return DecisionCollator(tokenizer, max_length, model.canonical_order, vision or getattr(model, "vision", None))
147
+
148
+
149
+ def distribution_loss(logits, targets, kinds, criterion_mask, ordinal_weight=0.1, brier_weight=0.0):
150
+ """Plackett-Luce top-1 (cross-entropy against the target distribution), plus the multi-class
151
+ Brier score at ``brier_weight`` (RLCD's calibration term; MoJev trains at 1.0), plus the
152
+ ordinal CDF term on Score. Absent criteria have probability and target 0, so add nothing."""
153
+ cross_entropy = -(targets * logits.log_softmax(-1)).sum(-1)
154
+ probabilities = logits.softmax(-1)
155
+ brier = (probabilities - targets).square().sum(-1)
156
+ counts = criterion_mask.sum(-1)
157
+ cdf_mask = torch.arange(logits.shape[-1], device=logits.device)[None, :] < (counts - 1)[:, None]
158
+ cdf_error = ((probabilities.cumsum(-1) - targets.cumsum(-1)).square() * cdf_mask).sum(-1)
159
+ ordinal = cdf_error / (counts - 1).clamp_min(1)
160
+ return (cross_entropy + brier_weight * brier + ordinal_weight * ordinal * (kinds == PRIMITIVES.index("score"))).mean()
161
+
162
+
163
+ def span_mask(starts, ends, length):
164
+ positions = torch.arange(length, device=starts.device)
165
+ return ((positions >= starts[..., None]) & (positions < ends[..., None])).float()
166
+
167
+
168
+ def mean_pool(hidden, mask):
169
+ """Mean hidden state over each span of ``mask`` (B, S, L); an empty span pools to zero."""
170
+ return torch.matmul(mask, hidden) / mask.sum(-1, keepdim=True).clamp_min(1)
171
+
172
+
173
+ class DecisionModel(nn.Module):
174
+ def __init__(self, backbone, head_width=128, ordinal_weight=0.1, head="mlp", brier_weight=0.0, last_token=False):
175
+ super().__init__()
176
+ if head not in HEADS:
177
+ raise ValueError(f"head must be one of {HEADS}")
178
+ if last_token and head != "bilinear":
179
+ raise ValueError("The last-token readout extends the bilinear head")
180
+ self.backbone = backbone
181
+ self.head = head
182
+ self.head_width = head_width
183
+ self.ordinal_weight = ordinal_weight
184
+ self.brier_weight = brier_weight
185
+ self.last_token = last_token
186
+ # The bilinear head reads candidate text, so its layout is canonical; v1's MLP heads keep
187
+ # the order they were trained with.
188
+ self.canonical_order = head == "bilinear"
189
+ dimension = backbone.config.get_text_config().hidden_size # multimodal configs nest it
190
+ if head == "bilinear":
191
+ # No per-primitive parameters: the primitive is in the text ("Task: noul"), and a Noul
192
+ # is a two-candidate preference scored exactly like a Choice.
193
+ self.heads = nn.ModuleDict({
194
+ "norm": nn.LayerNorm(dimension),
195
+ "context": nn.Linear(dimension, head_width, bias=False),
196
+ "option": nn.Linear(dimension, head_width, bias=False),
197
+ })
198
+ if last_token:
199
+ # A span's mean blurs texts that differ by one word ("The food is a taco, meaning
200
+ # ..." against "The food is not a taco, meaning it does not ..."); the span's last
201
+ # token has read all of it and tells them apart. Zero-initialised, so adding it to a
202
+ # trained head leaves the model's answers unchanged until it is trained.
203
+ for name in LAST_TOKEN_HEADS:
204
+ self.heads[name] = nn.Linear(dimension, head_width, bias=False)
205
+ nn.init.zeros_(self.heads[name].weight)
206
+ else:
207
+ self.heads = nn.ModuleDict({
208
+ "choice": nn.Sequential(nn.Linear(3 * dimension, head_width), nn.SiLU(), nn.Linear(head_width, 1)),
209
+ "score": nn.Sequential(nn.Linear(3 * dimension, head_width), nn.SiLU(), nn.Linear(head_width, 1)),
210
+ "noul": nn.Sequential(nn.Linear(dimension, head_width), nn.SiLU(), nn.Linear(head_width, 1)),
211
+ })
212
+ self.register_buffer("temperatures", torch.ones(len(PRIMITIVES)))
213
+
214
+ def bilinear_logits(self, hidden, context_ends, question_spans, criterion_spans):
215
+ """MoJev's readout: LayerNorm, mean-pool the context, question and candidate spans, then
216
+ score each candidate as (proj(context) + proj(question)) . proj(candidate) / sqrt(rank).
217
+ With ``last_token``, the question's and each candidate's last token add their own projections."""
218
+ hidden = self.heads["norm"](hidden.float())
219
+ length = hidden.shape[1]
220
+ context = mean_pool(hidden, span_mask(torch.zeros_like(context_ends), context_ends, length)[:, None]).squeeze(1)
221
+ question = mean_pool(hidden, span_mask(question_spans[:, :1], question_spans[:, 1:], length)).squeeze(1)
222
+ options = mean_pool(hidden, span_mask(criterion_spans[..., 0], criterion_spans[..., 1], length))
223
+ query = self.heads["context"](context) + self.heads["context"](question)
224
+ keys = self.heads["option"](options)
225
+ if self.last_token:
226
+ rows = torch.arange(hidden.shape[0], device=hidden.device)
227
+ query = query + self.heads["question_last"](hidden[rows, question_spans[:, 1] - 1])
228
+ ends = (criterion_spans[..., 1] - 1).clamp_min(0) # an absent candidate's (0, 0) span reads token 0, then is masked
229
+ keys = keys + self.heads["option_last"](hidden[rows[:, None], ends])
230
+ return (query[:, None, :] * keys).sum(-1) / self.head_width ** 0.5
231
+
232
+ def forward(self, input_ids, attention_mask, criterion_positions, criterion_mask, decision_positions, kinds,
233
+ context_ends, question_spans, criterion_spans, criterion_order, targets=None,
234
+ pixel_values=None, image_grid_thw=None, mm_token_type_ids=None):
235
+ dtype = torch.bfloat16 if supports_native_bfloat16() else torch.float16
236
+ images = {} if pixel_values is None else {"pixel_values": pixel_values, "image_grid_thw": image_grid_thw,
237
+ "mm_token_type_ids": mm_token_type_ids}
238
+ with torch.autocast("cuda", dtype=dtype, enabled=input_ids.is_cuda):
239
+ hidden = self.backbone(input_ids=input_ids, attention_mask=attention_mask, use_cache=False, return_dict=True,
240
+ **images).last_hidden_state
241
+ if self.head == "bilinear":
242
+ logits = self.bilinear_logits(hidden, context_ends, question_spans, criterion_spans)
243
+ else:
244
+ logits = self.mlp_logits(hidden, criterion_positions, decision_positions, kinds)
245
+ logits = torch.zeros_like(logits).scatter(1, criterion_order, logits) # layout slots -> caller's order
246
+ logits = (logits / self.temperatures[kinds, None]).masked_fill(~criterion_mask, torch.finfo(torch.float32).min)
247
+ result = {"logits": logits}
248
+ if targets is not None:
249
+ result["loss"] = distribution_loss(logits, targets, kinds, criterion_mask, self.ordinal_weight, self.brier_weight)
250
+ return result
251
+
252
+ def mlp_logits(self, hidden, criterion_positions, decision_positions, kinds):
253
+ rows = torch.arange(hidden.shape[0], device=hidden.device)
254
+ question = hidden[rows, decision_positions].float()
255
+ options = hidden[rows[:, None], criterion_positions].float()
256
+ shared = question[:, None, :].expand_as(options)
257
+ features = torch.cat([shared, options, shared * options], dim=-1)
258
+ choice_logits = self.heads["choice"](features).squeeze(-1)
259
+ score_logits = self.heads["score"](features).squeeze(-1)
260
+ logits = torch.where((kinds == 1)[:, None], score_logits, choice_logits)
261
+ binary_logits = torch.zeros_like(logits)
262
+ binary_logits[:, 1] = self.heads["noul"](question).squeeze(-1)
263
+ return torch.where((kinds == 2)[:, None], binary_logits, logits)
264
+
265
+
266
+ def typed_answer(kind, probabilities, labels):
267
+ if len(probabilities) != len(labels) or not probabilities:
268
+ raise ValueError("One label is required for each probability")
269
+ if not all(math.isfinite(value) and value >= 0 for value in probabilities) or not math.isclose(sum(probabilities), 1, abs_tol=1e-5):
270
+ raise ValueError("Expected a normalized finite probability vector")
271
+ count = len(probabilities)
272
+ if kind == "noul":
273
+ if count != 2:
274
+ raise ValueError("Noul requires false and true outcomes")
275
+ return {"type": "noul", "noul": probabilities[1]}
276
+ mode = max(range(count), key=probabilities.__getitem__)
277
+ if kind == "choice":
278
+ confidence = 1.0 if count == 1 else (probabilities[mode] - 1 / count) / (1 - 1 / count)
279
+ return {"type": kind, "choice": labels[mode], "probabilities": dict(zip(labels, probabilities)), "confidence": confidence}
280
+ if kind != "score" or count < 2:
281
+ raise ValueError("Expected Choice, Score or Noul")
282
+ distance = sum(value * abs(index - mode) for index, value in enumerate(probabilities))
283
+ uniform = sum(abs(index - (count - 1) / 2) for index in range(count)) / count
284
+ return {
285
+ "type": kind,
286
+ "score": sum(index * value for index, value in enumerate(probabilities)),
287
+ "probabilities": {str(index): value for index, value in enumerate(probabilities)},
288
+ "legend": {str(index): label for index, label in enumerate(labels)},
289
+ "confidence": max(0.0, 1 - distance / uniform),
290
+ }
291
+
292
+
293
+ @torch.no_grad()
294
+ def predict(model, tokenizer, kind, state, instructions, criteria, max_length=512):
295
+ if isinstance(criteria, dict):
296
+ labels = list(criteria)
297
+ descriptions = [f"{label}: {description}" if description else label for label, description in criteria.items()]
298
+ else:
299
+ descriptions = list(criteria)
300
+ labels = descriptions
301
+ if kind == "choice" and len(labels) == 1:
302
+ return typed_answer(kind, [1.0], labels)
303
+ example = DecisionExample("inference", "inference", "user", "test", kind, state, instructions, descriptions, [1 / len(labels)] * len(labels))
304
+ batch = collator_for(model, tokenizer, max_length)([example])
305
+ device = next(model.heads.parameters()).device
306
+ batch = {name: value.to(device) for name, value in batch.items()}
307
+ model.eval()
308
+ probabilities = model(**batch)["logits"].softmax(-1)[0].tolist()
309
+ return typed_answer(kind, probabilities, labels)
src/yev/training/run.py ADDED
@@ -0,0 +1,782 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import contextmanager
2
+ from dataclasses import asdict, dataclass
3
+ import hashlib
4
+ import importlib.util
5
+ from importlib.metadata import PackageNotFoundError, version
6
+ import json
7
+ import math
8
+ import os
9
+ from pathlib import Path
10
+ import random
11
+ import tempfile
12
+ from time import perf_counter
13
+
14
+ import numpy as np
15
+ import torch
16
+
17
+ from .images import DEFAULT_MAX_IMAGE_TOKENS, IMAGE_PLACEHOLDER, MIN_IMAGE_TOKENS, ImagePrefetcher, VisionInputs, image_processor
18
+ from .model import HEADS, LAST_TOKEN_HEADS, DecisionModel, PRIMITIVES, collator_for, encode_example, supports_native_bfloat16
19
+
20
+
21
+ STAGES = ("head_warmup", "decision_adaptation", "distribution_adaptation")
22
+ LORA_TARGETS = ("q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj")
23
+ # Gated DeltaNet (linear-attention) projections in hybrid backbones such as Qwen3.5. The vision
24
+ # tower uses other names (qkv, proj, linear_fc*), so these adapt the language model only.
25
+ LINEAR_ATTENTION_TARGETS = ("in_proj_qkv", "in_proj_z", "in_proj_a", "in_proj_b", "out_proj")
26
+
27
+
28
+ def lora_targets(backbone):
29
+ """Attention and MLP projections, plus linear-attention projections when the backbone has them."""
30
+ names = {name.rsplit(".", 1)[-1] for name, _ in backbone.named_modules()}
31
+ return list(LORA_TARGETS) + [name for name in LINEAR_ATTENTION_TARGETS if name in names]
32
+
33
+
34
+ @dataclass
35
+ class TrainingConfig:
36
+ model_id: str = "Qwen/Qwen3-4B-Base"
37
+ model_revision: str | None = None
38
+ output_dir: str = "artifacts/training"
39
+ max_length: int = 512
40
+ micro_batch: int = 1
41
+ gradient_accumulation: int = 16
42
+ seed: int = 42
43
+ head_width: int = 128
44
+ head: str = "mlp"
45
+ head_last_token: bool = False # the bilinear head's last-token readout (model.LAST_TOKEN_HEADS)
46
+ brier_weight: float = 0.0
47
+ lora_rank: int = 16
48
+ eval_limit: int = 128
49
+ eval_every: int = 50
50
+ save_every: int = 50
51
+ quantize4bit: bool = True
52
+ gradient_checkpointing: bool = True
53
+ stage_steps: tuple[int, int, int] = (25, 200, 100)
54
+ stream_start: int = 0 # distribution-stage updates of the data stream to skip before the first one
55
+ human_eval_limit: int = 0 # human-labelled dev examples per primitive scored to select best_human.pt; 0 is off
56
+ max_image_tokens: int = DEFAULT_MAX_IMAGE_TOKENS # tokens per image, outside max_length (vision backbones only)
57
+ snapshot_every: int = 0 # also keep the adapters and heads every this many updates under snapshots/; 0 is off
58
+ dataset_fingerprint: str | None = None
59
+ head_learning_rate: float = 5e-4
60
+ learning_rate: float = 1e-4
61
+ weight_decay: float = 0.01
62
+ max_grad_norm: float = 1.0
63
+
64
+ def __post_init__(self):
65
+ self.output_dir = str(self.output_dir)
66
+ self.stage_steps = tuple(self.stage_steps)
67
+ for name in ("max_length", "micro_batch", "gradient_accumulation", "head_width",
68
+ "lora_rank", "eval_limit", "eval_every", "save_every"):
69
+ if type(getattr(self, name)) is not int or getattr(self, name) < 1:
70
+ raise ValueError(f"{name} must be a positive integer")
71
+ if len(self.stage_steps) != 3 or any(type(steps) is not int or steps < 0 for steps in self.stage_steps):
72
+ raise ValueError("stage_steps must contain three nonnegative optimizer-step limits")
73
+ for name in ("head_learning_rate", "learning_rate", "max_grad_norm"):
74
+ if not math.isfinite(getattr(self, name)) or getattr(self, name) <= 0:
75
+ raise ValueError(f"{name} must be finite and positive")
76
+ if not math.isfinite(self.weight_decay) or self.weight_decay < 0:
77
+ raise ValueError("weight_decay must be finite and nonnegative")
78
+ if self.head not in HEADS:
79
+ raise ValueError(f"head must be one of {HEADS}")
80
+ if self.head_last_token and self.head != "bilinear":
81
+ raise ValueError("head_last_token extends the bilinear head")
82
+ if not math.isfinite(self.brier_weight) or self.brier_weight < 0:
83
+ raise ValueError("brier_weight must be finite and nonnegative")
84
+ if type(self.seed) is not int or not 0 <= self.seed < 2**32:
85
+ raise ValueError("seed must be an integer in [0, 2**32)")
86
+ if type(self.stream_start) is not int or self.stream_start < 0:
87
+ raise ValueError("stream_start must be a nonnegative integer")
88
+ if type(self.human_eval_limit) is not int or self.human_eval_limit < 0:
89
+ raise ValueError("human_eval_limit must be a nonnegative integer")
90
+ if type(self.snapshot_every) is not int or self.snapshot_every < 0:
91
+ raise ValueError("snapshot_every must be a nonnegative integer")
92
+ if type(self.max_image_tokens) is not int or self.max_image_tokens < MIN_IMAGE_TOKENS:
93
+ raise ValueError(f"max_image_tokens must be an integer of at least {MIN_IMAGE_TOKENS}")
94
+
95
+
96
+ def seed_rng(seed):
97
+ random.seed(seed)
98
+ np.random.seed(seed)
99
+ torch.manual_seed(seed)
100
+
101
+
102
+ def select_device():
103
+ """CUDA when present, then Apple MPS, otherwise CPU."""
104
+ if torch.cuda.is_available():
105
+ return torch.device("cuda", torch.cuda.current_device())
106
+ if torch.backends.mps.is_available():
107
+ return torch.device("mps")
108
+ return torch.device("cpu")
109
+
110
+
111
+ def compute_dtype(device_type):
112
+ if device_type == "mps":
113
+ return torch.bfloat16
114
+ if device_type != "cuda":
115
+ return torch.float32
116
+ return torch.bfloat16 if supports_native_bfloat16() else torch.float16
117
+
118
+
119
+ def prepare_backbone(backbone, config, trainable):
120
+ checkpointing = trainable and config.gradient_checkpointing
121
+ if config.quantize4bit and trainable: # for training only: it casts every bf16 weight left unquantized to fp32
122
+ from peft import prepare_model_for_kbit_training
123
+
124
+ return prepare_model_for_kbit_training(
125
+ backbone, use_gradient_checkpointing=checkpointing,
126
+ gradient_checkpointing_kwargs={"use_reentrant": False},
127
+ )
128
+ if checkpointing:
129
+ backbone.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
130
+ return backbone
131
+
132
+
133
+ def load_model(config, adapter_path=None, trainable=True, processor_source=None):
134
+ """Load a revision-pinned feature backbone; quantized loads require CUDA. A backbone with a
135
+ vision encoder also gets ``model.vision``, its image processor read from ``processor_source``
136
+ (an export) or the base model."""
137
+ if config.quantize4bit and trainable and not torch.cuda.is_available(): # serving in 4-bit also runs on CPU and Apple GPUs
138
+ raise RuntimeError("4-bit training requires CUDA; select a Colab GPU before loading weights")
139
+ from peft import LoraConfig, PeftModel, TaskType, get_peft_model
140
+ from transformers import AutoConfig, AutoModel, AutoTokenizer, BitsAndBytesConfig
141
+
142
+ seed_rng(config.seed)
143
+ device = select_device()
144
+ cuda = device.type == "cuda"
145
+ dtype = compute_dtype(device.type)
146
+ if cuda:
147
+ torch.backends.cuda.matmul.allow_tf32 = True
148
+ torch.backends.cudnn.allow_tf32 = True
149
+ base_config = AutoConfig.from_pretrained(config.model_id, revision=config.model_revision)
150
+ config.model_revision = getattr(base_config, "_commit_hash", None) or config.model_revision
151
+ if config.model_revision is None and not Path(config.model_id).is_dir():
152
+ raise ValueError("Could not resolve the base model revision")
153
+ base_config.use_cache = False
154
+ tokenizer = AutoTokenizer.from_pretrained(config.model_id, revision=config.model_revision)
155
+ if tokenizer.pad_token_id is None:
156
+ if tokenizer.eos_token_id is None:
157
+ raise ValueError("Tokenizer needs a padding or EOS token")
158
+ tokenizer.pad_token = tokenizer.eos_token
159
+ options = {"config": base_config, "revision": config.model_revision, "dtype": dtype}
160
+ if cuda:
161
+ options["device_map"] = {"": device}
162
+ if config.quantize4bit:
163
+ options["quantization_config"] = BitsAndBytesConfig(
164
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
165
+ bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=dtype,
166
+ llm_int8_skip_modules=["visual"], # the vision encoder has no adapters to learn around its rounding
167
+ )
168
+ backbone = AutoModel.from_pretrained(config.model_id, **options)
169
+ if not cuda and device.type != "cpu":
170
+ backbone = backbone.to(device) # device_map loading onto Apple MPS segfaults under transformers 5
171
+ backbone = prepare_backbone(backbone, config, trainable)
172
+ if adapter_path is not None:
173
+ backbone = PeftModel.from_pretrained(backbone, str(adapter_path), is_trainable=trainable)
174
+ else:
175
+ backbone = get_peft_model(backbone, LoraConfig(
176
+ task_type=TaskType.FEATURE_EXTRACTION, r=config.lora_rank,
177
+ lora_alpha=2 * config.lora_rank, lora_dropout=0.05,
178
+ target_modules=lora_targets(backbone), inference_mode=not trainable,
179
+ ))
180
+ model = DecisionModel(backbone, head_width=config.head_width, head=config.head, brier_weight=config.brier_weight,
181
+ last_token=config.head_last_token)
182
+ device = backbone.get_input_embeddings().weight.device
183
+ model.heads.to(device=device, dtype=torch.float32)
184
+ model.temperatures = model.temperatures.to(device)
185
+ model.training_config = config
186
+ if (getattr(base_config, "vision_config", None) is not None and getattr(base_config, "image_token_id", None) is not None
187
+ and importlib.util.find_spec("PIL") is not None): # without Pillow the model still answers text
188
+ processor = image_processor(processor_source or config.model_id, config.max_image_tokens,
189
+ None if processor_source else config.model_revision)
190
+ model.vision = VisionInputs.for_backbone(base_config, processor, config.max_image_tokens)
191
+ if not trainable:
192
+ model.requires_grad_(False)
193
+ model.train(trainable)
194
+ return model, tokenizer
195
+
196
+
197
+ def forward_batch(model, batch):
198
+ return model(**batch)
199
+
200
+
201
+ def device_batch(model, collator, examples):
202
+ device = next(model.heads.parameters()).device
203
+ return {name: value.to(device) for name, value in collator(examples).items()}
204
+
205
+
206
+ @contextmanager
207
+ def evaluation_mode(model):
208
+ modes = [(module, module.training) for module in model.modules()]
209
+ model.eval()
210
+ try:
211
+ with torch.no_grad():
212
+ yield
213
+ finally:
214
+ for module, mode in modes:
215
+ module.training = mode
216
+
217
+
218
+ def one_hot(target):
219
+ return sum(value == 1 for value in target) == 1 and all(value in (0, 1) for value in target)
220
+
221
+
222
+ def summarize_metrics(rows):
223
+ hard = [row[2] for row in rows if row[2] is not None]
224
+ return {
225
+ "count": len(rows),
226
+ "soft_log_loss": sum(row[0] for row in rows) / len(rows) if rows else None,
227
+ "brier": sum(row[1] for row in rows) / len(rows) if rows else None,
228
+ "hard_count": len(hard),
229
+ "hard_accuracy": sum(hard) / len(hard) if hard else None,
230
+ }
231
+
232
+
233
+ def select_per_primitive(examples, limit):
234
+ """The first ``limit`` examples of each primitive, in their original order."""
235
+ if type(limit) is not int or limit < 1:
236
+ raise ValueError("Evaluation limit must be a positive integer")
237
+ return [item for kind in PRIMITIVES for item in [item for item in examples if item.kind == kind][:limit]]
238
+
239
+
240
+ def human_dev_sample(dev, limit):
241
+ """``limit`` human-labelled dev examples per primitive, spread over their datasets by a stable hash of the uid."""
242
+ human = sorted((item for item in dev if item.source.startswith("human:")),
243
+ key=lambda item: hashlib.sha256(item.uid.encode("utf-8")).hexdigest())
244
+ return select_per_primitive(human, limit) if human else []
245
+
246
+
247
+ def scored(items, batch, logits):
248
+ """(kind, (log loss, Brier, correct or None)) for each question of a scored micro-batch."""
249
+ losses = -(batch["targets"] * logits.log_softmax(-1)).sum(-1)
250
+ briers = (logits.softmax(-1) - batch["targets"]).square().sum(-1)
251
+ predictions = logits.argmax(-1).tolist()
252
+ for index, item in enumerate(items):
253
+ loss, brier = float(losses[index]), float(briers[index])
254
+ if not math.isfinite(loss) or not math.isfinite(brier):
255
+ raise FloatingPointError("Nonfinite evaluation scores")
256
+ yield item.kind, (loss, brier, int(predictions[index] == item.target.index(1)) if one_hot(item.target) else None)
257
+
258
+
259
+ def eval_model(model, tokenizer, examples, config, limit=None):
260
+ """Mean proper scores and one-hot-only accuracy, overall and per primitive.
261
+
262
+ ``limit`` caps the sample **per primitive**, so a split dominated by one primitive still
263
+ yields a meaningful Score or Noul estimate.
264
+ """
265
+ selected = select_per_primitive(examples, config.eval_limit if limit is None else limit)
266
+ collator = collator_for(model, tokenizer, config.max_length)
267
+ rows = {kind: [] for kind in PRIMITIVES}
268
+ groups = micro_batches(selected, range(len(selected)), config.micro_batch, config.micro_batch * config.max_length,
269
+ token_lengths(collator))
270
+ with evaluation_mode(model):
271
+ for group in groups:
272
+ items = [selected[index] for index in group]
273
+ batch = device_batch(model, collator, items)
274
+ for kind, row in scored(items, batch, forward_batch(model, batch)["logits"].float()):
275
+ rows[kind].append(row)
276
+ result = summarize_metrics([row for values in rows.values() for row in values])
277
+ result["per_primitive"] = {kind: summarize_metrics(values) for kind, values in rows.items()}
278
+ return result
279
+
280
+
281
+ def best_temperature(rows, candidates):
282
+ """The candidate with the lowest multi-class Brier score over (logits, target) rows."""
283
+ losses = [sum(float(((logits / candidate).softmax(-1) - target).square().sum()) for logits, target in rows)
284
+ for candidate in candidates]
285
+ if not all(math.isfinite(loss) for loss in losses):
286
+ raise FloatingPointError("Nonfinite calibration logits")
287
+ return float(candidates[min(range(len(losses)), key=losses.__getitem__)])
288
+
289
+
290
+ def calibrate_model(model, tokenizer, examples, config, split="calibration", tied=()):
291
+ """Fit temperatures on at most eval_limit examples per primitive from one named split.
292
+
293
+ Each primitive's temperature minimises the multi-class Brier score of the tempered
294
+ distributions (RLCD's calibration step). The default fits on gold calibration data. Passing ``split="dev"`` with teacher-labelled
295
+ development examples fits temperatures for teacher imitation instead; the caller is
296
+ responsible for keeping those examples out of any later scoring sample. The primitives in ``tied`` share
297
+ one temperature fitted on their examples together, so a Noul and its two-option Choice stay equivalent.
298
+ """
299
+ if split not in ("calibration", "dev"):
300
+ raise ValueError("Temperatures may be fitted on the calibration split or on held-out dev examples only")
301
+ if not examples or any(item.split != split for item in examples):
302
+ raise ValueError(f"Temperature fitting requires {split} examples ONLY")
303
+ collator = collator_for(model, tokenizer, config.max_length)
304
+ collected = {kind: [] for kind in PRIMITIVES}
305
+ previous = model.temperatures.detach().clone()
306
+ try:
307
+ with evaluation_mode(model):
308
+ model.temperatures.fill_(1)
309
+ selected = select_per_primitive(examples, config.eval_limit)
310
+ for start in range(0, len(selected), config.micro_batch):
311
+ items = selected[start:start + config.micro_batch]
312
+ batch = device_batch(model, collator, items)
313
+ logits = forward_batch(model, batch)["logits"].cpu().double()
314
+ for index, item in enumerate(items):
315
+ collected[item.kind].append((logits[index, :len(item.target)], torch.tensor(item.target, dtype=torch.float64)))
316
+ candidates = [1.0] + [2 ** (index / 8) for index in range(-24, 25) if index != 0] # 0.125 to 8.0
317
+ temperatures = {}
318
+ groups = ([tuple(tied)] if tied else []) + [(kind,) for kind in PRIMITIVES if kind not in tied]
319
+ for group in groups:
320
+ temperatures.update(dict.fromkeys(group, best_temperature([row for kind in group for row in collected[kind]], candidates)))
321
+ model.temperatures.copy_(model.temperatures.new_tensor([temperatures[kind] for kind in PRIMITIVES]))
322
+ except BaseException:
323
+ model.temperatures.copy_(previous)
324
+ raise
325
+ return temperatures
326
+
327
+
328
+ def fingerprint(value):
329
+ content = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
330
+ return hashlib.sha256(content.encode()).hexdigest()
331
+
332
+
333
+ def data_fingerprint(train, dev):
334
+ digest = hashlib.sha256()
335
+ for split, examples in (("train", train), ("dev", dev)):
336
+ digest.update(split.encode())
337
+ for item in examples:
338
+ digest.update(fingerprint(item.to_dict()).encode())
339
+ return digest.hexdigest()
340
+
341
+
342
+ def config_fingerprint(config, model):
343
+ settings = asdict(config)
344
+ settings.pop("output_dir")
345
+ for name in ("stream_start", "human_eval_limit", "snapshot_every"):
346
+ if not settings[name]: # runs from before these settings existed keep their fingerprint
347
+ settings.pop(name)
348
+ if settings["max_image_tokens"] == DEFAULT_MAX_IMAGE_TOKENS:
349
+ settings.pop("max_image_tokens")
350
+ settings["ordinal_weight"] = model.ordinal_weight
351
+ return fingerprint(settings)
352
+
353
+
354
+ def stage_pool_size(train, stage):
355
+ """Presentations in one epoch of a stage's stream, mirroring ``stage_indices``."""
356
+ hard = sum(1 for item in train if one_hot(item.target))
357
+ soft = len(train) - hard
358
+ if stage < 2 or not hard or not soft:
359
+ return hard or soft
360
+ return 2 * max(hard, soft)
361
+
362
+
363
+ def plan_stage_steps(train, config, head_steps=25, decision_epochs=1, distribution_epochs=1):
364
+ """Derive stage limits that correspond to whole passes over the label pools.
365
+
366
+ Stage 1 warms the frozen-backbone heads, stage 2 adapts on hard labels only, and
367
+ stage 3 replays the balanced hard/soft stream that ``stage_indices`` builds, so one
368
+ distribution epoch costs ``2 * max(hard, soft)`` presentations. Counters describe
369
+ presentations rather than unique examples, and the caller must still measure real
370
+ throughput: this is arithmetic, not a runtime prediction.
371
+ """
372
+ for name, value in (("head_steps", head_steps), ("decision_epochs", decision_epochs),
373
+ ("distribution_epochs", distribution_epochs)):
374
+ if type(value) is not int or value < 0:
375
+ raise ValueError(f"{name} must be a nonnegative integer")
376
+ if not train:
377
+ raise ValueError("A nonempty training split is required to plan a schedule")
378
+ batch = config.micro_batch * config.gradient_accumulation
379
+ hard = sum(1 for item in train if one_hot(item.target))
380
+ soft = len(train) - hard
381
+ decision_updates = -(-stage_pool_size(train, 1) // batch)
382
+ distribution_updates = -(-stage_pool_size(train, 2) // batch)
383
+ return {
384
+ "stage_steps": (head_steps, decision_updates * decision_epochs, distribution_updates * distribution_epochs),
385
+ "effective_batch": batch,
386
+ "hard_examples": hard,
387
+ "soft_examples": soft,
388
+ "head_warmup_presentations": head_steps * batch,
389
+ "decision_epochs": decision_epochs,
390
+ "decision_updates_per_epoch": decision_updates,
391
+ "distribution_epochs": distribution_epochs,
392
+ "distribution_updates_per_epoch": distribution_updates,
393
+ "total_updates": head_steps + decision_updates * decision_epochs + distribution_updates * distribution_epochs,
394
+ }
395
+
396
+
397
+ def stage_indices(train, stage, seed, epoch):
398
+ generator = random.Random(f"{seed}:{stage}:{epoch}")
399
+ hard = [index for index, item in enumerate(train) if one_hot(item.target)]
400
+ soft = [index for index, item in enumerate(train) if not one_hot(item.target)]
401
+ generator.shuffle(hard)
402
+ generator.shuffle(soft)
403
+ if stage < 2:
404
+ return hard or soft
405
+ if not hard or not soft:
406
+ return hard or soft
407
+ return [pool[index % len(pool)] for index in range(max(len(hard), len(soft))) for pool in (soft, hard)]
408
+
409
+
410
+ def adapter_parameters(model):
411
+ return {name: parameter for name, parameter in model.named_parameters() if "lora_" in name}
412
+
413
+
414
+ def checkpoint_tensors(model):
415
+ return {**adapter_parameters(model),
416
+ **{f"heads.{name}": value for name, value in model.heads.state_dict().items()},
417
+ "temperatures": model.temperatures}
418
+
419
+
420
+ def tensor_state(model):
421
+ return {name: value.detach().cpu().clone() for name, value in checkpoint_tensors(model).items()}
422
+
423
+
424
+ def with_new_heads(model, tensors):
425
+ """Checkpoint ``tensors`` completed with the model's zero-initialised last-token readout where
426
+ the checkpoint predates it, so a continuation can add the readout and start where it left off."""
427
+ added = {name: value for name, value in tensor_state(model).items()
428
+ if name not in tensors and name.startswith("heads.") and name.split(".")[1] in LAST_TOKEN_HEADS}
429
+ return {**tensors, **added}
430
+
431
+
432
+ def restore_tensors(model, tensors):
433
+ expected = checkpoint_tensors(model)
434
+ if tensors.keys() != expected.keys() or any(tensors[name].shape != value.shape for name, value in expected.items()):
435
+ raise ValueError("Checkpoint adapter/head layout does not match the model")
436
+ with torch.no_grad():
437
+ for name, value in expected.items():
438
+ value.copy_(tensors[name])
439
+
440
+
441
+ def capture_rng():
442
+ numpy_state = np.random.get_state()
443
+ return {
444
+ "python": random.getstate(), "torch": torch.get_rng_state(),
445
+ "numpy": (numpy_state[0], numpy_state[1].tolist(), *numpy_state[2:]),
446
+ "cuda": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else [],
447
+ }
448
+
449
+
450
+ def restore_rng(states):
451
+ random.setstate(states["python"])
452
+ torch.set_rng_state(states["torch"])
453
+ numpy_state = states["numpy"]
454
+ np.random.set_state((numpy_state[0], np.array(numpy_state[1], dtype=np.uint32), *numpy_state[2:]))
455
+ if states["cuda"]:
456
+ if not torch.cuda.is_available() or len(states["cuda"]) != torch.cuda.device_count():
457
+ raise ValueError("Resume requires the same CUDA device count")
458
+ torch.cuda.set_rng_state_all(states["cuda"])
459
+
460
+
461
+ def atomic_save(payload, path):
462
+ path = Path(path)
463
+ path.parent.mkdir(parents=True, exist_ok=True)
464
+ temporary = None
465
+ try:
466
+ with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", delete=False) as stream:
467
+ temporary = Path(stream.name)
468
+ torch.save(payload, stream)
469
+ stream.flush()
470
+ os.fsync(stream.fileno())
471
+ os.replace(temporary, path)
472
+ finally:
473
+ if temporary is not None:
474
+ temporary.unlink(missing_ok=True)
475
+
476
+
477
+ def stage_optimizer(model, config, steps, stage=0):
478
+ head_rate = config.head_learning_rate * (1.0, 0.6, 0.2)[stage]
479
+ adapter_rate = config.learning_rate * (1.0, 1.0, 0.3)[stage]
480
+ groups = [
481
+ {"params": [parameter for name, parameter in model.heads.named_parameters() if not name.endswith("bias")], "lr": head_rate},
482
+ {"params": [parameter for name, parameter in model.heads.named_parameters() if name.endswith("bias")], "lr": head_rate, "weight_decay": 0.0},
483
+ ]
484
+ adapters = list(adapter_parameters(model).values())
485
+ if adapters:
486
+ groups.append({"params": adapters, "lr": adapter_rate})
487
+ optimizer = torch.optim.AdamW(groups, lr=config.learning_rate, weight_decay=config.weight_decay)
488
+ warmup = max(1, math.ceil(0.03 * steps))
489
+
490
+ def schedule(step):
491
+ if step < warmup:
492
+ return (step + 1) / warmup
493
+ progress = min(1.0, (step - warmup) / max(1, steps - warmup))
494
+ return 0.5 * (1 + math.cos(math.pi * progress))
495
+
496
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, schedule)
497
+ return optimizer, scheduler
498
+
499
+
500
+ def validate_training(model, splits, config):
501
+ train, dev = splits["train"], splits["dev"]
502
+ if not train or not dev:
503
+ raise ValueError("Nonempty train and dev splits are required")
504
+ if any(item.split != "train" for item in train) or any(item.split != "dev" for item in dev):
505
+ raise ValueError("Training and dev selection must use their own splits")
506
+ if {item.group for item in train} & {item.group for item in dev}:
507
+ raise ValueError("Train and dev document groups must be disjoint")
508
+ actual_fingerprint = data_fingerprint(train, dev)
509
+ if config.dataset_fingerprint not in (None, actual_fingerprint):
510
+ raise ValueError("Dataset fingerprint mismatch")
511
+ config.dataset_fingerprint = actual_fingerprint
512
+ if model.head_width != config.head_width:
513
+ raise ValueError("Config head_width does not match the model")
514
+ if model.head != config.head or model.brier_weight != config.brier_weight:
515
+ raise ValueError("Config head and brier_weight must match the model")
516
+ if model.last_token != config.head_last_token:
517
+ raise ValueError("Config head_last_token must match the model")
518
+ if any(config.stage_steps[1:]) and not adapter_parameters(model):
519
+ raise ValueError("Adaptation stages require a LoRA backbone")
520
+ return train, dev
521
+
522
+
523
+ def token_lengths(collator):
524
+ """Each text question's encoded length, counted once."""
525
+ lengths = {}
526
+
527
+ def length(example):
528
+ if example.uid not in lengths:
529
+ lengths[example.uid] = len(encode_example(example, collator.tokenizer, collator.max_length, collator.canonical)["input_ids"])
530
+ return lengths[example.uid]
531
+ return length
532
+
533
+
534
+ def micro_batches(train, indices, micro_batch, budget, length):
535
+ """An update's micro-batches. Image questions go in fixed groups of ``micro_batch``, the groups the
536
+ image prefetcher prepares; text questions are sorted by length and packed while a group's padded
537
+ size (its longest question times its count) stays within ``budget`` tokens, so short questions no
538
+ longer run one or two to a forward pass. The update's gradient is the same however it is split."""
539
+ pictures = [index for index in indices if train[index].images]
540
+ groups = [pictures[start:start + micro_batch] for start in range(0, len(pictures), micro_batch)]
541
+ group = []
542
+ for index in sorted((index for index in indices if not train[index].images), key=lambda index: length(train[index])):
543
+ if group and length(train[index]) * (len(group) + 1) > budget:
544
+ groups.append(group)
545
+ group = []
546
+ group.append(index)
547
+ return groups + ([group] if group else [])
548
+
549
+
550
+ def optimizer_update(model, collator, train, indices, config, optimizer, scaler, length):
551
+ optimizer.zero_grad(set_to_none=True)
552
+ update_loss = 0.0
553
+ for micro_indices in micro_batches(train, indices, config.micro_batch, config.micro_batch * config.max_length, length):
554
+ batch = device_batch(model, collator, [train[index] for index in micro_indices])
555
+ loss = forward_batch(model, batch)["loss"]
556
+ if not torch.isfinite(loss):
557
+ raise FloatingPointError("Nonfinite training loss; last.pt remains at its saved optimizer boundary")
558
+ weighted_loss = loss * (len(micro_indices) / len(indices))
559
+ scaler.scale(weighted_loss).backward()
560
+ update_loss += float(weighted_loss.detach())
561
+ scaler.unscale_(optimizer)
562
+ torch.nn.utils.clip_grad_norm_(
563
+ [parameter for parameter in model.parameters() if parameter.requires_grad],
564
+ config.max_grad_norm, error_if_nonfinite=not scaler.is_enabled(),
565
+ )
566
+ previous_scale = scaler.get_scale()
567
+ scaler.step(optimizer)
568
+ scaler.update()
569
+ optimizer.zero_grad(set_to_none=True)
570
+ return None if scaler.get_scale() < previous_scale else update_loss
571
+
572
+
573
+ def run_training(model, tokenizer, splits, config, resume=True):
574
+ """Bounded optimizer steps; last.pt resumes only through its last saved update.
575
+
576
+ Warmup and decision adaptation use hard labels when available. Distribution
577
+ adaptation balances soft targets and hard replay, repeating the smaller pool.
578
+ Each stage has a fresh AdamW optimizer, 3% warmup and cosine decay. Changed schedules
579
+ are refused on resume. ``stream_start`` begins distribution adaptation that many updates
580
+ into its data stream, so continuing a run on the same data and seed skips what it trained on.
581
+ With ``human_eval_limit`` each evaluation also scores a fixed sample of human-labelled dev
582
+ examples and keeps best_human.pt, the update with their lowest log loss. best.pt is selected by dev soft log loss, while the
583
+ returned model remains at the last update, not automatically at the best.
584
+ """
585
+ train, dev = validate_training(model, splits, config)
586
+ actual_fingerprint = config.dataset_fingerprint
587
+ adapters = adapter_parameters(model)
588
+ device = next(model.heads.parameters()).device
589
+ model.heads.to(dtype=torch.float32)
590
+ model.training_config = config
591
+ signature = config_fingerprint(config, model)
592
+ directory = Path(config.output_dir)
593
+ directory.mkdir(parents=True, exist_ok=True)
594
+ last_path = directory / "last.pt"
595
+ saved = torch.load(last_path, map_location="cpu", weights_only=True) if resume and last_path.exists() else None
596
+ if saved is not None:
597
+ if saved["format_version"] != 1 or saved["config_fingerprint"] != signature:
598
+ raise ValueError("Checkpoint config/schedule fingerprint mismatch; restart explicitly with resume=False")
599
+ if saved["data_fingerprint"] != actual_fingerprint:
600
+ raise ValueError("Checkpoint dataset fingerprint mismatch")
601
+ if saved["device_type"] != device.type:
602
+ raise ValueError("Exact continuation requires the same device type")
603
+ restore_tensors(model, saved["tensors"])
604
+ history, best, best_human = saved["history"], saved["best"], saved.get("best_human")
605
+ history.setdefault("best_human_dev_log_loss", None)
606
+ history.setdefault("best_human_step", None)
607
+ if best is not None:
608
+ atomic_save(best, directory / "best.pt")
609
+ if best_human is not None:
610
+ atomic_save(best_human, directory / "best_human.pt")
611
+ else:
612
+ seed_rng(config.seed)
613
+ model.temperatures.fill_(1)
614
+ history = {"completed_steps": 0, "best_dev_log_loss": None, "best_step": None,
615
+ "best_human_dev_log_loss": None, "best_human_step": None, "updates": []}
616
+ best = best_human = None
617
+ human_dev = human_dev_sample(dev, config.human_eval_limit) if config.human_eval_limit else []
618
+ # Image questions are prepared one update ahead in background threads, so decoding and resizing
619
+ # overlap the GPU's work on the current update; text-only training is unchanged.
620
+ prefetcher = (ImagePrefetcher(model.vision) if getattr(model, "vision", None) is not None
621
+ and any(example.images for example in train) else None)
622
+ collator = collator_for(model, tokenizer, config.max_length, prefetcher)
623
+ length = token_lengths(collator)
624
+ update_size = config.micro_batch * config.gradient_accumulation
625
+ global_step = history["completed_steps"]
626
+ start_stage = saved["stage"] if saved is not None else 0
627
+ for stage in range(start_stage, len(STAGES)):
628
+ steps = config.stage_steps[stage]
629
+ if steps == 0:
630
+ continue
631
+ for name, parameter in model.named_parameters():
632
+ parameter.requires_grad_(name.startswith("heads.") or (stage > 0 and name in adapters))
633
+ model.train()
634
+ if stage == 0:
635
+ model.backbone.eval()
636
+ print(f"{STAGES[stage]}: {steps} optimizer steps", flush=True)
637
+ optimizer, scheduler = stage_optimizer(model, config, steps, stage)
638
+ scaler = torch.amp.GradScaler("cuda", enabled=device.type == "cuda" and not supports_native_bfloat16())
639
+ stage_step, epoch, offset = 0, 0, 0
640
+ resumed = saved is not None and stage == saved["stage"]
641
+ if resumed:
642
+ optimizer.load_state_dict(saved["optimizer"])
643
+ scheduler.load_state_dict(saved["scheduler"])
644
+ scaler.load_state_dict(saved["scaler"])
645
+ stage_step, epoch, offset = saved["stage_step"], saved["epoch"], saved["offset"]
646
+ restore_rng(saved["rng"])
647
+ saved = None
648
+ order = stage_indices(train, stage, config.seed, epoch)
649
+ if stage == 2 and not resumed:
650
+ for _ in range(config.stream_start): # advance as that many updates did, short epoch-end batches included
651
+ offset += min(config.micro_batch * config.gradient_accumulation, len(order) - offset)
652
+ if offset == len(order):
653
+ epoch, offset = epoch + 1, 0
654
+ order = stage_indices(train, stage, config.seed, epoch)
655
+ overflows = 0
656
+ stage_clock, stage_start_step = perf_counter(), stage_step
657
+ while stage_step < steps:
658
+ indices = order[offset:offset + update_size]
659
+ if prefetcher is not None:
660
+ following = offset + len(indices)
661
+ upcoming = (order[following:following + update_size] if following < len(order)
662
+ else stage_indices(train, stage, config.seed, epoch + 1)[:update_size])
663
+ prefetcher.submit_update(train, indices, config.micro_batch)
664
+ prefetcher.submit_update(train, upcoming, config.micro_batch)
665
+ update_loss = optimizer_update(model, collator, train, indices, config, optimizer, scaler, length)
666
+ if update_loss is None:
667
+ overflows += 1
668
+ if overflows > 8:
669
+ raise FloatingPointError("Repeated fp16 gradient overflow; resume from last.pt")
670
+ continue
671
+ overflows = 0
672
+ scheduler.step()
673
+ stage_step += 1
674
+ global_step += 1
675
+ offset += len(indices)
676
+ if offset == len(order):
677
+ epoch += 1
678
+ offset = 0
679
+ order = stage_indices(train, stage, config.seed, epoch)
680
+ record = {"stage": STAGES[stage], "stage_step": stage_step, "step": global_step,
681
+ "loss": update_loss, "examples": len(indices)}
682
+ history["completed_steps"] = global_step
683
+ history["updates"].append(record)
684
+ if stage_step % config.eval_every == 0 or stage_step == steps:
685
+ metrics = eval_model(model, tokenizer, dev, config)
686
+ record["dev"] = metrics
687
+ progress = {"stage": STAGES[stage], "step": global_step, "train_loss": update_loss, "dev_log_loss": metrics["soft_log_loss"]}
688
+ human = eval_model(model, tokenizer, human_dev, config, limit=config.human_eval_limit) if human_dev else None
689
+ if human is not None:
690
+ record["human_dev"] = human
691
+ progress.update(human_dev_log_loss=human["soft_log_loss"], human_dev_accuracy=human["hard_accuracy"])
692
+ progress["seconds_per_update"] = round((perf_counter() - stage_clock) / max(1, stage_step - stage_start_step), 3)
693
+ if device.type == "cuda":
694
+ progress["max_memory_gib"] = round(torch.cuda.max_memory_allocated(device) / 2**30, 2)
695
+ print(json.dumps(progress), flush=True)
696
+ if history["best_dev_log_loss"] is None or metrics["soft_log_loss"] < history["best_dev_log_loss"]:
697
+ history["best_dev_log_loss"] = metrics["soft_log_loss"]
698
+ history["best_step"] = global_step
699
+ best = {"format_version": 1, "tensors": tensor_state(model), "config": asdict(config),
700
+ "config_fingerprint": signature, "data_fingerprint": actual_fingerprint,
701
+ "step": global_step, "dev": metrics}
702
+ atomic_save(best, directory / "best.pt")
703
+ if human is not None and (history["best_human_dev_log_loss"] is None
704
+ or human["soft_log_loss"] < history["best_human_dev_log_loss"]):
705
+ history["best_human_dev_log_loss"] = human["soft_log_loss"]
706
+ history["best_human_step"] = global_step
707
+ best_human = {"format_version": 1, "tensors": tensor_state(model), "config": asdict(config),
708
+ "config_fingerprint": signature, "data_fingerprint": actual_fingerprint,
709
+ "step": global_step, "dev": metrics, "human_dev": human}
710
+ atomic_save(best_human, directory / "best_human.pt")
711
+ if config.snapshot_every and global_step % config.snapshot_every == 0:
712
+ # Same layout as best.pt, so any snapshot can be exported, and snapshots can be blended
713
+ atomic_save({"format_version": 1, "tensors": tensor_state(model), "config": asdict(config),
714
+ "config_fingerprint": signature, "data_fingerprint": actual_fingerprint,
715
+ "step": global_step}, directory / "snapshots" / f"update-{global_step:06d}.pt")
716
+ if stage_step % config.save_every == 0 or stage_step == steps:
717
+ checkpoint = {
718
+ "format_version": 1, "tensors": tensor_state(model), "config": asdict(config),
719
+ "config_fingerprint": signature, "data_fingerprint": actual_fingerprint,
720
+ "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(),
721
+ "scaler": scaler.state_dict(), "stage": stage, "stage_step": stage_step,
722
+ "global_step": global_step, "epoch": epoch, "offset": offset,
723
+ "rng": capture_rng(), "device_type": device.type, "history": history, "best": best,
724
+ "best_human": best_human,
725
+ }
726
+ atomic_save(checkpoint, last_path)
727
+ if prefetcher is not None:
728
+ prefetcher.close()
729
+ return history
730
+
731
+
732
+ def export_model(model, tokenizer, config, destination):
733
+ """Export only PEFT weights, float32 heads, temperatures, tokenizer and provenance."""
734
+ from peft import PeftModel
735
+
736
+ if not isinstance(model.backbone, PeftModel):
737
+ raise ValueError("Export requires a PEFT backbone, not full frozen model weights")
738
+ if config.head_width != model.head_width:
739
+ raise ValueError("Config head_width does not match the exported heads")
740
+ destination = Path(destination)
741
+ destination.mkdir(parents=True, exist_ok=True)
742
+ model.backbone.save_pretrained(destination / "adapter", safe_serialization=True, save_embedding_layers=False)
743
+ tokenizer.save_pretrained(destination)
744
+ atomic_save({"heads": {name: value.detach().float().cpu() for name, value in model.heads.state_dict().items()},
745
+ "temperatures": model.temperatures.detach().float().cpu(),
746
+ "ordinal_weight": model.ordinal_weight}, destination / "heads.pt")
747
+ dependencies = {}
748
+ for package in ("torch", "transformers", "peft", "accelerate", "bitsandbytes", "numpy"):
749
+ try:
750
+ dependencies[package] = version(package)
751
+ except PackageNotFoundError:
752
+ dependencies[package] = None
753
+ vision = getattr(model, "vision", None)
754
+ if vision is not None: # export format 2: the image processor and its token budget travel with the weights
755
+ vision.processor.save_pretrained(destination)
756
+ metadata = {"format_version": 2 if vision is not None else 1, "base_model": config.model_id, "base_revision": config.model_revision,
757
+ "head": model.head, "head_width": model.head_width, "head_last_token": model.last_token,
758
+ "brier_weight": model.brier_weight,
759
+ "max_length": config.max_length,
760
+ "dataset_fingerprint": config.dataset_fingerprint, "dependencies": dependencies,
761
+ "temperatures": dict(zip(PRIMITIVES, model.temperatures.tolist()))}
762
+ if vision is not None:
763
+ metadata["images"] = {"max_image_tokens": vision.max_image_tokens, "placeholder": IMAGE_PLACEHOLDER}
764
+ (destination / "config.json").write_text(json.dumps(asdict(config), indent=2, allow_nan=False) + "\n")
765
+ (destination / "metadata.json").write_text(json.dumps(metadata, indent=2, allow_nan=False) + "\n")
766
+
767
+
768
+ def reload_export(destination, quantize4bit=True):
769
+ """Reload inference weights; saved config is available as model.training_config."""
770
+ from transformers import AutoTokenizer
771
+
772
+ destination = Path(destination)
773
+ config = TrainingConfig(**json.loads((destination / "config.json").read_text()))
774
+ config.quantize4bit = quantize4bit
775
+ processor = destination if (destination / "preprocessor_config.json").exists() else None
776
+ model, tokenizer = load_model(config, adapter_path=destination / "adapter", trainable=False, processor_source=processor)
777
+ tokenizer = AutoTokenizer.from_pretrained(destination)
778
+ payload = torch.load(destination / "heads.pt", map_location="cpu", weights_only=True)
779
+ model.heads.load_state_dict(payload["heads"])
780
+ model.temperatures.copy_(payload["temperatures"].to(model.temperatures.device))
781
+ model.ordinal_weight = payload["ordinal_weight"]
782
+ return model, tokenizer