classone 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- classone/__init__.py +48 -0
- classone/data/__init__.py +1 -0
- classone/data/dataset.py +175 -0
- classone/modeling/__init__.py +27 -0
- classone/modeling/configuration_classone.py +29 -0
- classone/modeling/loss.py +131 -0
- classone/modeling/modeling_classone.py +357 -0
- classone/schemas.py +143 -0
- classone/sdk.py +290 -0
- classone/server/__init__.py +1 -0
- classone/server/app.py +131 -0
- classone/tokenizer.py +215 -0
- classone/train/__init__.py +1 -0
- classone/train/trainer.py +334 -0
- classone-0.1.0.dist-info/METADATA +152 -0
- classone-0.1.0.dist-info/RECORD +19 -0
- classone-0.1.0.dist-info/WHEEL +5 -0
- classone-0.1.0.dist-info/licenses/LICENSE +189 -0
- classone-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Model architecture for ClassOne System One decision models.
|
|
2
|
+
|
|
3
|
+
Integrates a transformer backbone (e.g. Gemma 4 E2B) with lightweight decision heads
|
|
4
|
+
for parallel single-pass evaluation of Noul, Choice, and Score primitives.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
import torch.nn.functional as F
|
|
14
|
+
from torch import nn
|
|
15
|
+
from transformers import AutoModel, PreTrainedModel
|
|
16
|
+
|
|
17
|
+
from classone.modeling.configuration_classone import ClassOneConfig
|
|
18
|
+
from classone.schemas import ChoiceResult, NoulResult, Result, ScoreResult
|
|
19
|
+
from classone.tokenizer import PackedSequence
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NoulHead(nn.Module):
|
|
23
|
+
"""Boolean decision head predicting calibrated probability P(true)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, hidden_size: int, head_hidden_size: int, dropout: float = 0.1):
|
|
26
|
+
super().__init__()
|
|
27
|
+
self.net = nn.Sequential(
|
|
28
|
+
nn.Linear(hidden_size, head_hidden_size),
|
|
29
|
+
nn.LayerNorm(head_hidden_size),
|
|
30
|
+
nn.GELU(),
|
|
31
|
+
nn.Dropout(dropout),
|
|
32
|
+
nn.Linear(head_hidden_size, 1),
|
|
33
|
+
)
|
|
34
|
+
# Smooth strictly positive temperature parameterization: T = softplus(raw) + 0.1
|
|
35
|
+
self.temperature_raw = nn.Parameter(torch.zeros(1))
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def temperature(self) -> torch.Tensor:
|
|
39
|
+
return F.softplus(self.temperature_raw) + 0.1
|
|
40
|
+
|
|
41
|
+
def forward(self, h_query: torch.Tensor) -> torch.Tensor:
|
|
42
|
+
"""Args:
|
|
43
|
+
|
|
44
|
+
h_query: [batch_size, hidden_size] or [hidden_size]
|
|
45
|
+
Returns:
|
|
46
|
+
probability tensor in [0, 1]
|
|
47
|
+
"""
|
|
48
|
+
if h_query.dim() == 1:
|
|
49
|
+
h_query = h_query.unsqueeze(0)
|
|
50
|
+
logit = self.net(h_query).squeeze(-1)
|
|
51
|
+
prob = torch.sigmoid(logit / self.temperature)
|
|
52
|
+
return prob
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ChoiceHead(nn.Module):
|
|
56
|
+
"""Categorical decision head computing calibrated distribution over dynamic options."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, hidden_size: int, head_hidden_size: int, dropout: float = 0.1):
|
|
59
|
+
super().__init__()
|
|
60
|
+
self.q_proj = nn.Sequential(
|
|
61
|
+
nn.Linear(hidden_size, head_hidden_size),
|
|
62
|
+
nn.LayerNorm(head_hidden_size),
|
|
63
|
+
nn.Dropout(dropout),
|
|
64
|
+
)
|
|
65
|
+
self.k_proj = nn.Sequential(
|
|
66
|
+
nn.Linear(hidden_size, head_hidden_size),
|
|
67
|
+
nn.LayerNorm(head_hidden_size),
|
|
68
|
+
nn.Dropout(dropout),
|
|
69
|
+
)
|
|
70
|
+
self.scale = 1.0 / math.sqrt(head_hidden_size)
|
|
71
|
+
self.temperature_raw = nn.Parameter(torch.zeros(1))
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def temperature(self) -> torch.Tensor:
|
|
75
|
+
return F.softplus(self.temperature_raw) + 0.1
|
|
76
|
+
|
|
77
|
+
def forward(self, h_query: torch.Tensor, h_options: torch.Tensor) -> torch.Tensor:
|
|
78
|
+
"""Args:
|
|
79
|
+
|
|
80
|
+
h_query: Query token vector [hidden_size]
|
|
81
|
+
h_options: Candidate option token vectors [num_options, hidden_size]
|
|
82
|
+
Returns:
|
|
83
|
+
Probability distribution over options [num_options]
|
|
84
|
+
"""
|
|
85
|
+
if h_query.dim() == 1:
|
|
86
|
+
h_query = h_query.unsqueeze(0) # [1, hidden_size]
|
|
87
|
+
|
|
88
|
+
q = self.q_proj(h_query) # [1, head_hidden_size]
|
|
89
|
+
k = self.k_proj(h_options) # [num_options, head_hidden_size]
|
|
90
|
+
|
|
91
|
+
# Dot-product similarity
|
|
92
|
+
logits = torch.matmul(q, k.transpose(0, 1)).squeeze(0) * self.scale
|
|
93
|
+
probs = F.softmax(logits / self.temperature, dim=-1)
|
|
94
|
+
return probs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ScoreHead(nn.Module):
|
|
98
|
+
"""Ordinal decision head predicting expected score over an ordered rubric."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, hidden_size: int, head_hidden_size: int, dropout: float = 0.1):
|
|
101
|
+
super().__init__()
|
|
102
|
+
self.choice_evaluator = ChoiceHead(hidden_size, head_hidden_size, dropout)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def temperature(self) -> torch.Tensor:
|
|
106
|
+
return self.choice_evaluator.temperature
|
|
107
|
+
|
|
108
|
+
def forward(self, h_query: torch.Tensor, h_levels: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
109
|
+
"""Args:
|
|
110
|
+
|
|
111
|
+
h_query: Query token vector [hidden_size]
|
|
112
|
+
h_levels: Rubric level vectors [num_levels, hidden_size]
|
|
113
|
+
Returns:
|
|
114
|
+
(expected_score, probabilities)
|
|
115
|
+
"""
|
|
116
|
+
probs = self.choice_evaluator(h_query, h_levels)
|
|
117
|
+
num_levels = probs.shape[-1]
|
|
118
|
+
# Match level_weights dtype to probs dtype to prevent Half/Float runtime crash
|
|
119
|
+
level_weights = torch.arange(1, num_levels + 1, dtype=probs.dtype, device=probs.device)
|
|
120
|
+
expected_score = torch.sum(probs * level_weights)
|
|
121
|
+
return expected_score, probs
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ClassOneModel(PreTrainedModel):
|
|
125
|
+
"""Complete ClassOne decision model wrapping a transformer backbone with System 1 heads."""
|
|
126
|
+
|
|
127
|
+
config_class = ClassOneConfig
|
|
128
|
+
|
|
129
|
+
def __init__(self, config: ClassOneConfig, backbone: nn.Module | None = None):
|
|
130
|
+
super().__init__(config)
|
|
131
|
+
self.config = config
|
|
132
|
+
|
|
133
|
+
if backbone is not None:
|
|
134
|
+
self.backbone = backbone
|
|
135
|
+
else:
|
|
136
|
+
# Standalone lightweight transformer encoder for local testing/CPU
|
|
137
|
+
encoder_layer = nn.TransformerEncoderLayer(
|
|
138
|
+
d_model=config.hidden_size,
|
|
139
|
+
nhead=8,
|
|
140
|
+
dim_feedforward=config.hidden_size * 2,
|
|
141
|
+
dropout=config.dropout,
|
|
142
|
+
batch_first=True,
|
|
143
|
+
)
|
|
144
|
+
self.backbone = nn.TransformerEncoder(encoder_layer, num_layers=4, enable_nested_tensor=False)
|
|
145
|
+
self.embed = nn.Embedding(32000, config.hidden_size)
|
|
146
|
+
|
|
147
|
+
# Decision Heads
|
|
148
|
+
self.noul_head = NoulHead(config.hidden_size, config.head_hidden_size, config.dropout)
|
|
149
|
+
self.choice_head = ChoiceHead(config.hidden_size, config.head_hidden_size, config.dropout)
|
|
150
|
+
self.score_head = ScoreHead(config.hidden_size, config.head_hidden_size, config.dropout)
|
|
151
|
+
|
|
152
|
+
def forward(
|
|
153
|
+
self,
|
|
154
|
+
input_ids: torch.Tensor,
|
|
155
|
+
attention_mask: torch.Tensor | None = None,
|
|
156
|
+
**kwargs,
|
|
157
|
+
) -> torch.Tensor:
|
|
158
|
+
"""Standard PreTrainedModel forward pass extracting hidden states."""
|
|
159
|
+
return self.extract_hidden_states(input_ids=input_ids, attention_mask=attention_mask)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def device(self) -> torch.device:
|
|
163
|
+
"""Returns the device of the model parameters."""
|
|
164
|
+
return next(self.parameters()).device
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_backbone(
|
|
168
|
+
cls,
|
|
169
|
+
base_model_name_or_path: str | nn.Module,
|
|
170
|
+
config: ClassOneConfig | None = None,
|
|
171
|
+
tokenizer: Any | None = None,
|
|
172
|
+
torch_dtype: torch.dtype | None = None,
|
|
173
|
+
device: str | torch.device | None = None,
|
|
174
|
+
**backbone_kwargs,
|
|
175
|
+
) -> ClassOneModel:
|
|
176
|
+
"""Instantiates ClassOne with an existing HuggingFace backbone (e.g. Gemma 4 E2B).
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
base_model_name_or_path: HuggingFace model identifier, local directory, or existing nn.Module.
|
|
180
|
+
config: Optional ClassOneConfig override.
|
|
181
|
+
tokenizer: Optional tokenizer; if provided, backbone token embeddings are resized.
|
|
182
|
+
torch_dtype: Precision dtype (e.g. torch.bfloat16, torch.float16, torch.float32).
|
|
183
|
+
device: Target device ('mps', 'cuda', 'cpu'); if None, detects automatically.
|
|
184
|
+
**backbone_kwargs: Extra keyword arguments forwarded to AutoModel.from_pretrained.
|
|
185
|
+
"""
|
|
186
|
+
if isinstance(base_model_name_or_path, nn.Module):
|
|
187
|
+
backbone = base_model_name_or_path
|
|
188
|
+
model_name = getattr(backbone, "name_or_path", "custom_module")
|
|
189
|
+
else:
|
|
190
|
+
model_name = str(base_model_name_or_path)
|
|
191
|
+
kwargs = dict(backbone_kwargs)
|
|
192
|
+
if torch_dtype is not None:
|
|
193
|
+
kwargs["torch_dtype"] = torch_dtype
|
|
194
|
+
backbone = AutoModel.from_pretrained(model_name, **kwargs)
|
|
195
|
+
|
|
196
|
+
# Resize token embeddings if tokenizer includes ClassOne delimiter tokens
|
|
197
|
+
if tokenizer is not None and hasattr(backbone, "resize_token_embeddings"):
|
|
198
|
+
try:
|
|
199
|
+
backbone.resize_token_embeddings(len(tokenizer), mean_resizing=False)
|
|
200
|
+
except TypeError:
|
|
201
|
+
backbone.resize_token_embeddings(len(tokenizer))
|
|
202
|
+
|
|
203
|
+
text_config = getattr(backbone.config, "text_config", None)
|
|
204
|
+
hidden_size = (
|
|
205
|
+
getattr(text_config, "hidden_size", None)
|
|
206
|
+
or getattr(backbone.config, "hidden_size", None)
|
|
207
|
+
or getattr(backbone.config, "d_model", None)
|
|
208
|
+
or 2048
|
|
209
|
+
)
|
|
210
|
+
if config is None:
|
|
211
|
+
config = ClassOneConfig(
|
|
212
|
+
base_model_name_or_path=model_name,
|
|
213
|
+
hidden_size=hidden_size,
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
config.hidden_size = hidden_size
|
|
217
|
+
|
|
218
|
+
model = cls(config=config, backbone=backbone)
|
|
219
|
+
|
|
220
|
+
# Determine target device
|
|
221
|
+
if device is None:
|
|
222
|
+
if torch.cuda.is_available():
|
|
223
|
+
target_device = torch.device("cuda")
|
|
224
|
+
elif torch.backends.mps.is_available():
|
|
225
|
+
target_device = torch.device("mps")
|
|
226
|
+
else:
|
|
227
|
+
target_device = torch.device("cpu")
|
|
228
|
+
else:
|
|
229
|
+
target_device = torch.device(device)
|
|
230
|
+
|
|
231
|
+
model.to(target_device)
|
|
232
|
+
if torch_dtype is not None:
|
|
233
|
+
model.to(dtype=torch_dtype)
|
|
234
|
+
|
|
235
|
+
return model
|
|
236
|
+
|
|
237
|
+
def extract_hidden_states(
|
|
238
|
+
self, input_ids: torch.Tensor, attention_mask: torch.Tensor | None = None
|
|
239
|
+
) -> torch.Tensor:
|
|
240
|
+
"""Runs the backbone forward pass and retrieves sequence hidden states."""
|
|
241
|
+
target_device = self.device
|
|
242
|
+
input_ids = input_ids.to(target_device)
|
|
243
|
+
if attention_mask is not None:
|
|
244
|
+
attention_mask = attention_mask.to(target_device)
|
|
245
|
+
|
|
246
|
+
if hasattr(self, "embed"):
|
|
247
|
+
# Standalone mode: pass padding mask to prevent attention over padded tokens
|
|
248
|
+
x = self.embed(input_ids)
|
|
249
|
+
has_padding = attention_mask is not None and bool((attention_mask == 0).any())
|
|
250
|
+
padding_mask = (attention_mask == 0) if has_padding else None
|
|
251
|
+
return self.backbone(x, src_key_padding_mask=padding_mask)
|
|
252
|
+
else:
|
|
253
|
+
# Hugging Face backbone
|
|
254
|
+
outputs = self.backbone(input_ids=input_ids, attention_mask=attention_mask)
|
|
255
|
+
if hasattr(outputs, "last_hidden_state"):
|
|
256
|
+
return outputs.last_hidden_state
|
|
257
|
+
return outputs[0]
|
|
258
|
+
|
|
259
|
+
def evaluate_batch(
|
|
260
|
+
self,
|
|
261
|
+
batch: list[PackedSequence],
|
|
262
|
+
pad_token_id: int = 0,
|
|
263
|
+
) -> list[dict[str, Result]]:
|
|
264
|
+
"""Evaluates a batch of packed sequences simultaneously in a single batched forward pass.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
batch: List of PackedSequence objects.
|
|
268
|
+
pad_token_id: Token ID used for padding sequences to uniform length.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
List of answer dictionaries, one per input packed sequence.
|
|
272
|
+
"""
|
|
273
|
+
if not batch:
|
|
274
|
+
return []
|
|
275
|
+
|
|
276
|
+
self.eval()
|
|
277
|
+
batch_size = len(batch)
|
|
278
|
+
max_len = max(p.input_ids.shape[1] for p in batch)
|
|
279
|
+
|
|
280
|
+
# Allocate padded tensors
|
|
281
|
+
batched_input_ids = torch.full((batch_size, max_len), pad_token_id, dtype=torch.long, device=self.device)
|
|
282
|
+
batched_attention_mask = torch.zeros((batch_size, max_len), dtype=torch.long, device=self.device)
|
|
283
|
+
|
|
284
|
+
for b, p in enumerate(batch):
|
|
285
|
+
seq_len = p.input_ids.shape[1]
|
|
286
|
+
batched_input_ids[b, :seq_len] = p.input_ids[0].to(self.device)
|
|
287
|
+
batched_attention_mask[b, :seq_len] = p.attention_mask[0].to(self.device)
|
|
288
|
+
|
|
289
|
+
with torch.no_grad():
|
|
290
|
+
hidden_states = self.extract_hidden_states(
|
|
291
|
+
batched_input_ids, batched_attention_mask
|
|
292
|
+
) # [batch_size, max_len, hidden_size]
|
|
293
|
+
|
|
294
|
+
head_dtype = next(self.noul_head.parameters()).dtype
|
|
295
|
+
batch_answers: list[dict[str, Result]] = []
|
|
296
|
+
|
|
297
|
+
for b, p in enumerate(batch):
|
|
298
|
+
seq_hidden = hidden_states[b]
|
|
299
|
+
if seq_hidden.dtype != head_dtype:
|
|
300
|
+
seq_hidden = seq_hidden.to(head_dtype)
|
|
301
|
+
|
|
302
|
+
answers: dict[str, Result] = {}
|
|
303
|
+
|
|
304
|
+
for q_id, span in p.questions.items():
|
|
305
|
+
if span.question_type == "noul":
|
|
306
|
+
h_query = seq_hidden[span.query_token_idx]
|
|
307
|
+
prob = self.noul_head(h_query).item()
|
|
308
|
+
answers[q_id] = NoulResult(noul=round(float(prob), 4))
|
|
309
|
+
|
|
310
|
+
elif span.question_type == "choice":
|
|
311
|
+
h_query = seq_hidden[span.query_token_idx]
|
|
312
|
+
h_opts = torch.stack([seq_hidden[idx] for idx in span.option_token_indices])
|
|
313
|
+
probs = self.choice_head(h_query, h_opts)
|
|
314
|
+
probs_list = probs.tolist()
|
|
315
|
+
|
|
316
|
+
prob_dict = {key: round(float(val), 4) for key, val in zip(span.option_keys, probs_list)}
|
|
317
|
+
best_idx = int(torch.argmax(probs).item())
|
|
318
|
+
best_key = span.option_keys[best_idx]
|
|
319
|
+
|
|
320
|
+
# Confidence: margin between top-1 and top-2
|
|
321
|
+
if len(probs_list) > 1:
|
|
322
|
+
top2 = torch.topk(probs, k=2).values
|
|
323
|
+
conf = float((top2[0] - top2[1]).item())
|
|
324
|
+
else:
|
|
325
|
+
conf = float(probs[0].item())
|
|
326
|
+
|
|
327
|
+
conf = 0.0 if math.isnan(conf) else max(0.0, min(1.0, conf))
|
|
328
|
+
|
|
329
|
+
answers[q_id] = ChoiceResult(
|
|
330
|
+
choice=best_key,
|
|
331
|
+
probabilities=prob_dict,
|
|
332
|
+
confidence=round(conf, 4),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
elif span.question_type == "score":
|
|
336
|
+
h_query = seq_hidden[span.query_token_idx]
|
|
337
|
+
h_levels = torch.stack([seq_hidden[idx] for idx in span.option_token_indices])
|
|
338
|
+
expected_score, probs = self.score_head(h_query, h_levels)
|
|
339
|
+
probs_list = probs.tolist()
|
|
340
|
+
|
|
341
|
+
prob_dict = {key: round(float(val), 4) for key, val in zip(span.option_keys, probs_list)}
|
|
342
|
+
conf = float(torch.max(probs).item())
|
|
343
|
+
conf = 0.0 if math.isnan(conf) else max(0.0, min(1.0, conf))
|
|
344
|
+
|
|
345
|
+
answers[q_id] = ScoreResult(
|
|
346
|
+
score=round(float(expected_score.item()), 4),
|
|
347
|
+
probabilities=prob_dict,
|
|
348
|
+
confidence=round(conf, 4),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
batch_answers.append(answers)
|
|
352
|
+
|
|
353
|
+
return batch_answers
|
|
354
|
+
|
|
355
|
+
def evaluate_packed(self, packed: PackedSequence) -> dict[str, Result]:
|
|
356
|
+
"""Evaluates all questions in a packed sequence in a single forward pass."""
|
|
357
|
+
return self.evaluate_batch([packed])[0]
|
classone/schemas.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Schema definitions for ClassOne System 1 decision models.
|
|
2
|
+
|
|
3
|
+
Defines:
|
|
4
|
+
- Noul: Boolean evaluation returning calibrated P(true) in [0.0, 1.0]
|
|
5
|
+
- Choice: Categorical selection over 2 to 255 options with calibrated probability distribution
|
|
6
|
+
- Score: Continuous ordinal rubric scoring over 2 to 10 levels returning weighted expected value
|
|
7
|
+
- DecisionRequest & DecisionResponse: API payload models
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Literal, Union
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field, field_validator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NoulQuestion(BaseModel):
|
|
18
|
+
"""Boolean yes/no question evaluated to a calibrated probability P(true)."""
|
|
19
|
+
|
|
20
|
+
type: Literal["noul"] = "noul"
|
|
21
|
+
instructions: str = Field(..., description="Prompt/instruction for the boolean check.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChoiceQuestion(BaseModel):
|
|
25
|
+
"""Categorical classification over 2 to 255 options."""
|
|
26
|
+
|
|
27
|
+
type: Literal["choice"] = "choice"
|
|
28
|
+
instructions: str = Field(..., description="Prompt/instruction for selecting an option.")
|
|
29
|
+
criteria: dict[str, str] = Field(
|
|
30
|
+
...,
|
|
31
|
+
description="Dictionary mapping choice identifier to criteria/definition.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@field_validator("criteria")
|
|
35
|
+
@classmethod
|
|
36
|
+
def validate_criteria(cls, v: dict[str, str]) -> dict[str, str]:
|
|
37
|
+
if len(v) < 2:
|
|
38
|
+
raise ValueError("Choice question must have at least 2 options.")
|
|
39
|
+
if len(v) > 255:
|
|
40
|
+
raise ValueError("Choice question cannot exceed 255 options.")
|
|
41
|
+
for key, desc in v.items():
|
|
42
|
+
if not key or not desc:
|
|
43
|
+
raise ValueError("Choice keys and descriptions must not be empty.")
|
|
44
|
+
return v
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ScoreQuestion(BaseModel):
|
|
48
|
+
"""Ordinal evaluation along an ordered rubric of 2 to 10 levels."""
|
|
49
|
+
|
|
50
|
+
type: Literal["score"] = "score"
|
|
51
|
+
instructions: str = Field(..., description="Prompt/instruction for scoring.")
|
|
52
|
+
criteria: list[str] = Field(
|
|
53
|
+
...,
|
|
54
|
+
description="Ordered list of rubric levels from lowest to highest.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@field_validator("criteria")
|
|
58
|
+
@classmethod
|
|
59
|
+
def validate_levels(cls, v: list[str]) -> list[str]:
|
|
60
|
+
if len(v) < 2:
|
|
61
|
+
raise ValueError("Score question must have at least 2 rubric levels.")
|
|
62
|
+
if len(v) > 10:
|
|
63
|
+
raise ValueError("Score question cannot exceed 10 rubric levels.")
|
|
64
|
+
return v
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Question = Union[NoulQuestion, ChoiceQuestion, ScoreQuestion]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NoulResult(BaseModel):
|
|
71
|
+
"""Result of a Noul question."""
|
|
72
|
+
|
|
73
|
+
type: Literal["noul"] = "noul"
|
|
74
|
+
noul: float = Field(..., ge=0.0, le=1.0, description="Calibrated probability P(true).")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ChoiceResult(BaseModel):
|
|
78
|
+
"""Result of a Choice question."""
|
|
79
|
+
|
|
80
|
+
type: Literal["choice"] = "choice"
|
|
81
|
+
choice: str = Field(..., description="Selected option key.")
|
|
82
|
+
probabilities: dict[str, float] = Field(
|
|
83
|
+
...,
|
|
84
|
+
description="Probability distribution across all available choices.",
|
|
85
|
+
)
|
|
86
|
+
confidence: float = Field(..., ge=0.0, le=1.0, description="Calibrated decision confidence.")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ScoreResult(BaseModel):
|
|
90
|
+
"""Result of a Score question."""
|
|
91
|
+
|
|
92
|
+
type: Literal["score"] = "score"
|
|
93
|
+
score: float = Field(..., description="Probability-weighted fractional score.")
|
|
94
|
+
probabilities: dict[str, float] = Field(
|
|
95
|
+
...,
|
|
96
|
+
description="Probability distribution across each ordered rubric level.",
|
|
97
|
+
)
|
|
98
|
+
confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence in the score outcome.")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
Result = Union[NoulResult, ChoiceResult, ScoreResult]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DecisionUsage(BaseModel):
|
|
105
|
+
"""Token usage metrics."""
|
|
106
|
+
|
|
107
|
+
input_tokens: int = 0
|
|
108
|
+
output_tokens: int = 0 # In System 1 models this is 0 because there is no autoregressive decode
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class DecisionRequest(BaseModel):
|
|
112
|
+
"""Request payload for ClassOne decision inference."""
|
|
113
|
+
|
|
114
|
+
model: str = Field("class-one-gemma-4-e2b-it", description="Model name or pinned checkpoint.")
|
|
115
|
+
state: str | dict[str, Any] | list[Any] = Field(
|
|
116
|
+
...,
|
|
117
|
+
description="Unstructured or semi-structured state (text, JSON object, or list).",
|
|
118
|
+
)
|
|
119
|
+
questions: dict[str, Question] = Field(
|
|
120
|
+
...,
|
|
121
|
+
description="Map of question key to typed Question definition.",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@field_validator("questions")
|
|
125
|
+
@classmethod
|
|
126
|
+
def validate_questions_non_empty(cls, v: dict[str, Question]) -> dict[str, Question]:
|
|
127
|
+
if not v:
|
|
128
|
+
raise ValueError("At least one question must be provided in the request.")
|
|
129
|
+
return v
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DecisionResponse(BaseModel):
|
|
133
|
+
"""Response payload for ClassOne decision inference."""
|
|
134
|
+
|
|
135
|
+
model: str
|
|
136
|
+
answers: dict[str, Result]
|
|
137
|
+
usage: DecisionUsage = Field(default_factory=DecisionUsage)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Ergonomic Aliases
|
|
141
|
+
ClassOneUsage = DecisionUsage
|
|
142
|
+
ClassOneRequest = DecisionRequest
|
|
143
|
+
ClassOneResponse = DecisionResponse
|