constraintloop 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,397 @@
1
+ """Strict public models for contracts, results, and evaluator payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from enum import StrEnum
7
+ from pathlib import PurePosixPath
8
+ from typing import Annotated, Any, Literal, Protocol
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
11
+
12
+
13
+ class StrictModel(BaseModel):
14
+ model_config = ConfigDict(extra="forbid")
15
+
16
+
17
+ class Verdict(StrEnum):
18
+ PASS = "pass"
19
+ PENDING = "pending"
20
+ FAIL = "fail"
21
+ ERROR = "error"
22
+ SKIPPED = "skipped"
23
+ UNCERTAIN = "uncertain"
24
+ WAIVED = "waived"
25
+
26
+
27
+ class Enforcement(StrEnum):
28
+ REQUIRED = "required"
29
+ ADVISORY = "advisory"
30
+
31
+
32
+ class Phase(StrEnum):
33
+ CHANGE = "change"
34
+ STOP = "stop"
35
+ CI = "ci"
36
+
37
+
38
+ class ContractSettings(StrictModel):
39
+ max_auto_retries: int = Field(default=2, ge=0, le=20)
40
+ concurrency: int = Field(default=4, ge=1, le=32)
41
+ evidence_output_limit: int = Field(default=65_536, ge=1_024, le=1_048_576)
42
+ evaluation_bundle_limit: int = Field(default=102_400, ge=4_096, le=2_097_152)
43
+
44
+
45
+ class BaseConstraint(StrictModel):
46
+ description: str | None = None
47
+ enforcement: Enforcement = Enforcement.REQUIRED
48
+ phases: list[Phase] = Field(default_factory=lambda: [Phase.STOP, Phase.CI])
49
+ watch: list[str] = Field(default_factory=lambda: ["**/*"])
50
+ needs: list[str] = Field(default_factory=list)
51
+ timeout_seconds: float = Field(default=300.0, gt=0, le=7_200)
52
+ enabled: bool = True
53
+
54
+ @field_validator("watch")
55
+ @classmethod
56
+ def validate_watch(cls, patterns: list[str]) -> list[str]:
57
+ return _relative_patterns(patterns, "watch")
58
+
59
+
60
+ class CommandConstraint(BaseConstraint):
61
+ kind: Literal["command"]
62
+ command: list[str] | str
63
+ shell: bool = False
64
+ cwd: str = "."
65
+ success_codes: list[int] = Field(default_factory=lambda: [0])
66
+ pending_codes: list[int] = Field(default_factory=lambda: [75])
67
+
68
+ @model_validator(mode="after")
69
+ def validate_command(self) -> CommandConstraint:
70
+ if isinstance(self.command, str) and not self.shell:
71
+ raise ValueError("string commands require shell: true; prefer argv lists")
72
+ if isinstance(self.command, list) and not self.command:
73
+ raise ValueError("command argv cannot be empty")
74
+ if set(self.success_codes) & set(self.pending_codes):
75
+ raise ValueError("success_codes and pending_codes must not overlap")
76
+ return self
77
+
78
+
79
+ class MetricParser(StrictModel):
80
+ type: Literal["json", "regex"]
81
+ path: str | None = None
82
+ pattern: str | None = None
83
+ group: int | str = 1
84
+ source: Literal["stdout", "stderr", "file"] = "stdout"
85
+ file: str | None = None
86
+
87
+ @model_validator(mode="after")
88
+ def validate_parser(self) -> MetricParser:
89
+ if self.type == "json" and not self.path:
90
+ raise ValueError("json metric parsers require path")
91
+ if self.type == "regex" and not self.pattern:
92
+ raise ValueError("regex metric parsers require pattern")
93
+ if self.source == "file" and not self.file:
94
+ raise ValueError("file metric parsers require file")
95
+ return self
96
+
97
+
98
+ class MetricThreshold(StrictModel):
99
+ operator: Literal["gt", "gte", "lt", "lte", "eq"]
100
+ value: float
101
+
102
+
103
+ class MetricConstraint(BaseConstraint):
104
+ kind: Literal["metric"]
105
+ command: list[str] | str
106
+ shell: bool = False
107
+ cwd: str = "."
108
+ success_codes: list[int] = Field(default_factory=lambda: [0])
109
+ pending_codes: list[int] = Field(default_factory=lambda: [75])
110
+ parser: MetricParser
111
+ threshold: MetricThreshold
112
+
113
+ @model_validator(mode="after")
114
+ def validate_command(self) -> MetricConstraint:
115
+ if isinstance(self.command, str) and not self.shell:
116
+ raise ValueError("string commands require shell: true; prefer argv lists")
117
+ if isinstance(self.command, list) and not self.command:
118
+ raise ValueError("command argv cannot be empty")
119
+ if set(self.success_codes) & set(self.pending_codes):
120
+ raise ValueError("success_codes and pending_codes must not overlap")
121
+ return self
122
+
123
+
124
+ class ArtifactConstraint(BaseConstraint):
125
+ kind: Literal["artifact"]
126
+ path: str
127
+ format: Literal["any", "json", "junit"] = "any"
128
+ non_empty: bool = True
129
+
130
+
131
+ class RubricConstraint(BaseConstraint):
132
+ kind: Literal["rubric"]
133
+ evaluator: str
134
+ rubric: str
135
+ include: list[str] = Field(default_factory=lambda: ["**/*"])
136
+ runs: int = Field(default=1, ge=1, le=9)
137
+ pass_quorum: int | None = Field(default=None, ge=1, le=9)
138
+
139
+ @field_validator("include")
140
+ @classmethod
141
+ def validate_include(cls, patterns: list[str]) -> list[str]:
142
+ return _relative_patterns(patterns, "include")
143
+
144
+ @model_validator(mode="after")
145
+ def validate_quorum(self) -> RubricConstraint:
146
+ if self.enforcement == Enforcement.REQUIRED:
147
+ if self.runs < 2:
148
+ raise ValueError("required rubrics need at least two runs")
149
+ if self.pass_quorum is None or self.pass_quorum <= self.runs // 2:
150
+ raise ValueError("required rubrics need an explicit majority pass_quorum")
151
+ if self.pass_quorum > self.runs:
152
+ raise ValueError("pass_quorum cannot exceed runs")
153
+ elif self.pass_quorum is not None and self.pass_quorum > self.runs:
154
+ raise ValueError("pass_quorum cannot exceed runs")
155
+ return self
156
+
157
+
158
+ ConstraintSpec = Annotated[
159
+ CommandConstraint | MetricConstraint | ArtifactConstraint | RubricConstraint,
160
+ Field(discriminator="kind"),
161
+ ]
162
+
163
+
164
+ class OpenAIEvaluatorConfig(StrictModel):
165
+ type: Literal["openai"]
166
+ model: str
167
+ api_key_env: str = "OPENAI_API_KEY"
168
+ timeout_seconds: float = Field(default=60, gt=0, le=600)
169
+ max_attempts: int = Field(default=2, ge=1, le=5)
170
+ max_output_tokens: int = Field(default=2_000, ge=256, le=16_384)
171
+ reasoning_effort: Literal["minimal", "low", "medium", "high"] = "minimal"
172
+
173
+
174
+ class AnthropicEvaluatorConfig(StrictModel):
175
+ type: Literal["anthropic"]
176
+ model: str
177
+ api_key_env: str = "ANTHROPIC_API_KEY"
178
+ timeout_seconds: float = Field(default=60, gt=0, le=600)
179
+ max_attempts: int = Field(default=2, ge=1, le=5)
180
+ max_output_tokens: int = Field(default=2_048, ge=256, le=16_384)
181
+
182
+
183
+ class CommandEvaluatorConfig(StrictModel):
184
+ type: Literal["command"]
185
+ command: list[str] | str
186
+ shell: bool = False
187
+ timeout_seconds: float = Field(default=60, gt=0, le=600)
188
+
189
+ @model_validator(mode="after")
190
+ def validate_command(self) -> CommandEvaluatorConfig:
191
+ if isinstance(self.command, str) and not self.shell:
192
+ raise ValueError("string commands require shell: true")
193
+ if isinstance(self.command, list) and not self.command:
194
+ raise ValueError("command argv cannot be empty")
195
+ return self
196
+
197
+
198
+ EvaluatorConfig = Annotated[
199
+ OpenAIEvaluatorConfig | AnthropicEvaluatorConfig | CommandEvaluatorConfig,
200
+ Field(discriminator="type"),
201
+ ]
202
+
203
+
204
+ class LoopConfig(StrictModel):
205
+ phase: Phase
206
+ interval_seconds: float = Field(gt=0, le=86_400)
207
+ max_repair_attempts: int = Field(ge=1, le=100)
208
+ max_unchanged_repairs: int = Field(ge=1, le=100)
209
+ max_duration_seconds: float = Field(gt=0, le=2_592_000)
210
+ on_pass: Literal["stop"] = "stop"
211
+ on_failure: Literal["repair"] = "repair"
212
+ on_pending: Literal["wait"] = "wait"
213
+ on_budget_exhausted: Literal["human_required"] = "human_required"
214
+
215
+
216
+ class Contract(StrictModel):
217
+ version: Literal[1] = 1
218
+ settings: ContractSettings = Field(default_factory=ContractSettings)
219
+ constraints: dict[str, ConstraintSpec]
220
+ evaluators: dict[str, EvaluatorConfig] = Field(default_factory=dict)
221
+ loops: dict[str, LoopConfig] = Field(default_factory=dict)
222
+
223
+ @model_validator(mode="after")
224
+ def validate_graph_and_evaluators(self) -> Contract:
225
+ for name in (*self.constraints, *self.evaluators, *self.loops):
226
+ if not name or any(
227
+ character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
228
+ for character in name
229
+ ):
230
+ raise ValueError(f"invalid identifier {name!r}")
231
+ stop_loops = [name for name, loop in self.loops.items() if loop.phase == Phase.STOP]
232
+ if len(stop_loops) > 1:
233
+ raise ValueError(
234
+ "at most one stop-phase loop is supported; found " + ", ".join(sorted(stop_loops))
235
+ )
236
+ known = set(self.constraints)
237
+ for constraint_id, spec in self.constraints.items():
238
+ unknown = set(spec.needs) - known
239
+ if unknown:
240
+ raise ValueError(
241
+ f"{constraint_id} depends on unknown constraints: {sorted(unknown)}"
242
+ )
243
+ if isinstance(spec, RubricConstraint) and spec.evaluator not in self.evaluators:
244
+ raise ValueError(f"{constraint_id} references unknown evaluator {spec.evaluator!r}")
245
+
246
+ visiting: set[str] = set()
247
+ visited: set[str] = set()
248
+
249
+ def visit(node: str) -> None:
250
+ if node in visiting:
251
+ raise ValueError(f"constraint dependency cycle includes {node!r}")
252
+ if node in visited:
253
+ return
254
+ visiting.add(node)
255
+ for dependency in self.constraints[node].needs:
256
+ visit(dependency)
257
+ visiting.remove(node)
258
+ visited.add(node)
259
+
260
+ for constraint_id in self.constraints:
261
+ visit(constraint_id)
262
+ for loop_id, loop in self.loops.items():
263
+ if not any(
264
+ spec.enabled and loop.phase in spec.phases for spec in self.constraints.values()
265
+ ):
266
+ raise ValueError(
267
+ f"{loop_id} references phase {loop.phase.value!r} with no enabled constraints"
268
+ )
269
+ return self
270
+
271
+
272
+ def _relative_patterns(patterns: list[str], field: str) -> list[str]:
273
+ if not patterns:
274
+ raise ValueError(f"{field} must contain at least one pattern")
275
+ for pattern in patterns:
276
+ path = PurePosixPath(pattern)
277
+ if not pattern or path.is_absolute() or ".." in path.parts or "\\" in pattern:
278
+ raise ValueError(f"{field} patterns must be project-relative POSIX globs: {pattern!r}")
279
+ return patterns
280
+
281
+
282
+ class Finding(StrictModel):
283
+ message: str
284
+ file_path: str | None = None
285
+ line: int | None = Field(default=None, ge=1)
286
+ suggestion: str | None = None
287
+
288
+
289
+ class EvaluatorCallMetadata(StrictModel):
290
+ provider: str
291
+ model: str
292
+ response_id: str | None = None
293
+ status: str
294
+ attempts: int = Field(ge=1)
295
+ input_tokens: int | None = Field(default=None, ge=0)
296
+ output_tokens: int | None = Field(default=None, ge=0)
297
+ total_tokens: int | None = Field(default=None, ge=0)
298
+ cli_version: str | None = None
299
+ cost_usd: float | None = Field(default=None, ge=0)
300
+ duration_ms: float = Field(ge=0)
301
+
302
+
303
+ class ConstraintResult(StrictModel):
304
+ constraint_id: str
305
+ kind: str
306
+ verdict: Verdict
307
+ enforcement: Enforcement
308
+ input_digest: str
309
+ message: str
310
+ duration_ms: float = Field(default=0, ge=0)
311
+ exit_code: int | None = None
312
+ value: float | None = None
313
+ output_tail: str | None = None
314
+ findings: list[Finding] = Field(default_factory=list)
315
+ evaluator_calls: list[EvaluatorCallMetadata] = Field(default_factory=list)
316
+ cached: bool = False
317
+
318
+ @property
319
+ def blocks(self) -> bool:
320
+ return self.enforcement == Enforcement.REQUIRED and self.verdict not in {
321
+ Verdict.PASS,
322
+ Verdict.SKIPPED,
323
+ Verdict.WAIVED,
324
+ }
325
+
326
+
327
+ class EvidenceRecord(StrictModel):
328
+ schema_version: Literal[1] = 1
329
+ run_id: str
330
+ project_root: str
331
+ contract_digest: str
332
+ phase: Phase
333
+ started_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
334
+ results: list[ConstraintResult]
335
+
336
+ @property
337
+ def passed(self) -> bool:
338
+ return not any(result.blocks for result in self.results)
339
+
340
+
341
+ class LoopState(StrEnum):
342
+ PASSED = "passed"
343
+ REPAIR = "repair"
344
+ WAITING = "waiting"
345
+ HUMAN_REQUIRED = "human_required"
346
+ BUDGET_EXHAUSTED = "budget_exhausted"
347
+ ERROR = "error"
348
+
349
+
350
+ class LoopJournal(StrictModel):
351
+ schema_version: Literal[1] = 1
352
+ loop: str
353
+ contract_digest: str
354
+ started_at: float
355
+ updated_at: float
356
+ observation: int = Field(default=0, ge=0)
357
+ repair_attempt: int = Field(default=0, ge=0)
358
+ unchanged_repairs: int = Field(default=0, ge=0)
359
+ prior_state: LoopState | None = None
360
+ prior_snapshot: str | None = None
361
+ input_snapshot: str | None = None
362
+ last_result: dict[str, Any] | None = None
363
+
364
+
365
+ class CycleResult(StrictModel):
366
+ schema_version: Literal[1] = 1
367
+ loop: str
368
+ state: LoopState
369
+ snapshot: str
370
+ observation: int = Field(ge=1)
371
+ repair_attempt: int = Field(ge=0)
372
+ next_action: str
373
+ wake_after_seconds: float = Field(ge=0)
374
+ blocking_constraints: list[str] = Field(default_factory=list)
375
+
376
+
377
+ class EvaluationBundle(StrictModel):
378
+ schema_version: Literal[1] = 1
379
+ constraint_id: str
380
+ rubric: str
381
+ goal: str | None = None
382
+ diff: str
383
+ deterministic_results: list[dict[str, Any]]
384
+ files: dict[str, str]
385
+ omitted_files: list[str] = Field(default_factory=list)
386
+
387
+
388
+ class EvaluatorVerdict(StrictModel):
389
+ verdict: Literal["pass", "fail", "uncertain"]
390
+ score: float | None = Field(default=None, ge=0, le=1)
391
+ rationale: str
392
+ findings: list[Finding] = Field(default_factory=list)
393
+
394
+
395
+ class Evaluator(Protocol):
396
+ def evaluate(self, bundle: EvaluationBundle) -> EvaluatorVerdict:
397
+ """Return a structured evaluator verdict."""