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.
@@ -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