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.
- constraintloop/__init__.py +7 -0
- constraintloop/__main__.py +4 -0
- constraintloop/cli.py +485 -0
- constraintloop/config.py +53 -0
- constraintloop/digest.py +233 -0
- constraintloop/engine.py +466 -0
- constraintloop/environment.py +50 -0
- constraintloop/eval_corpus.py +46 -0
- constraintloop/evaluators.py +334 -0
- constraintloop/hooks.py +335 -0
- constraintloop/loops.py +334 -0
- constraintloop/models.py +397 -0
- constraintloop/native_cli_evaluator.py +464 -0
- constraintloop/py.typed +1 -0
- constraintloop/runners.py +290 -0
- constraintloop/scaffold.py +181 -0
- constraintloop/setup_hooks.py +191 -0
- constraintloop/state.py +225 -0
- constraintloop-0.1.0.dist-info/METADATA +371 -0
- constraintloop-0.1.0.dist-info/RECORD +23 -0
- constraintloop-0.1.0.dist-info/WHEEL +4 -0
- constraintloop-0.1.0.dist-info/entry_points.txt +5 -0
- constraintloop-0.1.0.dist-info/licenses/LICENSE +21 -0
constraintloop/models.py
ADDED
|
@@ -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."""
|