formwork 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.
formwork/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """formwork — declare who owns which field, then guarantee the domain rules.
2
+
3
+ from typing import Annotated
4
+ from formwork import Spec, computed, chosen, generated, rule, generate
5
+
6
+ class Plan(Spec):
7
+ "A weekly training plan."
8
+ split: Annotated[str, computed(rules.split_for)]
9
+ exercises: Annotated[list[Exercise], chosen(source="library", key="id")]
10
+ rationale: Annotated[str, generated(describe="Two sentences, plain language.")]
11
+
12
+ @rule("Weekly sets per muscle must stay inside the prescribed range.",
13
+ fields=["exercises"], repair=repair.truncate("exercises", 10))
14
+ def volume(self, ctx): ...
15
+
16
+ plan, report = generate(Plan, ctx, model)
17
+ """
18
+
19
+ from formwork.engine import Session, agenerate, generate
20
+ from formwork.errors import ConstraintError, FormworkError, StructuralError
21
+ from formwork.fields import FieldSpec, Role, chosen, computed, generated
22
+ from formwork.prompt import PromptBuilder
23
+ from formwork.providers.base import AsyncModel, Model, ModelRequest
24
+ from formwork.report import Attempt, Report, Usage
25
+ from formwork.rules import Objective, Rule, Violation, rule, soft
26
+ from formwork.spec import Spec
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ __all__ = [
31
+ # spec
32
+ "Spec",
33
+ "computed",
34
+ "chosen",
35
+ "generated",
36
+ "FieldSpec",
37
+ "Role",
38
+ # rules
39
+ "rule",
40
+ "soft",
41
+ "Rule",
42
+ "Objective",
43
+ "Violation",
44
+ # running
45
+ "generate",
46
+ "agenerate",
47
+ "Session",
48
+ "PromptBuilder",
49
+ # providers
50
+ "Model",
51
+ "AsyncModel",
52
+ "ModelRequest",
53
+ # results
54
+ "Report",
55
+ "Attempt",
56
+ "Usage",
57
+ # errors
58
+ "FormworkError",
59
+ "ConstraintError",
60
+ "StructuralError",
61
+ "__version__",
62
+ ]
formwork/engine.py ADDED
@@ -0,0 +1,367 @@
1
+ """The generation loop, written sans-IO.
2
+
3
+ ``Session`` is a state machine: it hands you a request, you get it answered
4
+ however you like, you feed the answer back. The synchronous and asynchronous
5
+ drivers below are each about six lines on top of it, the test doubles drive it
6
+ without a network, and anyone with an unusual setup — a queue, a batch API, a
7
+ human in the loop — can drive it themselves. One copy of the logic.
8
+
9
+ The loop itself:
10
+
11
+ computed fields → prompt → model → schema check → rule check
12
+ ↓ fails
13
+ deterministic repair, if a rule declared one
14
+ ↓ still fails
15
+ targeted repair: re-ask for the implicated
16
+ fields only, freeze the rest
17
+ ↓ out of attempts
18
+ ConstraintError
19
+
20
+ The step worth defending is the targeted one. The obvious implementation is to
21
+ regenerate everything with the errors appended to the prompt, which is what
22
+ most hand-rolled versions do; it throws away correct work, costs a full
23
+ completion, and gives the model fresh opportunities to break a rule it had
24
+ satisfied. Re-asking for two fields out of nine is cheaper and converges more
25
+ often, and the price is that rules have to say which fields they implicate.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from typing import Any, Generic, TypeVar
32
+
33
+ from pydantic import ValidationError
34
+
35
+ from formwork import repair as repair_module
36
+ from formwork.errors import ConstraintError, FormworkError, StructuralError
37
+ from formwork.prompt import PromptBuilder
38
+ from formwork.providers.base import AsyncModel, Model, ModelRequest
39
+ from formwork.report import Attempt, Report, Usage
40
+ from formwork.rules import Violation
41
+ from formwork.spec import Spec
42
+
43
+ __all__ = ["Session", "generate", "agenerate"]
44
+
45
+ S = TypeVar("S", bound=Spec)
46
+
47
+ DEFAULT_MAX_ATTEMPTS = 3
48
+
49
+
50
+ class Session(Generic[S]):
51
+ """One candidate's worth of generation, driven by the caller."""
52
+
53
+ def __init__(
54
+ self,
55
+ spec: type[S],
56
+ ctx: Any,
57
+ *,
58
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS,
59
+ prompt_builder: PromptBuilder | None = None,
60
+ system: str | None = None,
61
+ targeted_repair: bool = True,
62
+ use_declared_repairs: bool = True,
63
+ ) -> None:
64
+ if max_attempts < 1:
65
+ raise ValueError("max_attempts must be at least 1")
66
+
67
+ self.spec = spec
68
+ self.ctx = ctx
69
+ self.max_attempts = max_attempts
70
+ # Both default on. They exist to be switched off by the benchmark, so
71
+ # that a measured gain can be attributed to one mechanism rather than
72
+ # to "the library"; turning them off in production only makes the loop
73
+ # more expensive.
74
+ self.targeted_repair = targeted_repair
75
+ self.use_declared_repairs = use_declared_repairs
76
+ self.prompts = prompt_builder or PromptBuilder()
77
+ self.system = system if system is not None else self.prompts.system(spec)
78
+
79
+ self.computed_values = spec.resolve_computed(ctx)
80
+ self.report = Report()
81
+
82
+ self._instance: S | None = None
83
+ self._violations: list[Violation] = []
84
+ self._structural: list[str] = []
85
+ self._pending: ModelRequest | None = None
86
+ self._result: S | None = None
87
+
88
+ # ── driving ──────────────────────────────────────────────────────────
89
+
90
+ def next_request(self) -> ModelRequest | None:
91
+ """The next call to make, or None when there is nothing left to try."""
92
+ if self._result is not None:
93
+ return None
94
+ if len(self.report.attempts) >= self.max_attempts:
95
+ return None
96
+ if self._pending is not None:
97
+ return self._pending
98
+
99
+ self._pending = self._build_request()
100
+ return self._pending
101
+
102
+ def feed(self, raw: dict[str, Any], usage: Usage | None = None) -> None:
103
+ """Hand back what the model returned for the outstanding request."""
104
+ request = self._pending
105
+ if request is None:
106
+ raise RuntimeError("feed() called with no outstanding request")
107
+ self._pending = None
108
+
109
+ attempt = Attempt(
110
+ index=len(self.report.attempts),
111
+ kind=request.kind,
112
+ usage=usage or Usage(),
113
+ targeted_fields=request.fields,
114
+ )
115
+ self.report.attempts.append(attempt)
116
+
117
+ candidate = self._parse(request, raw, attempt)
118
+ if candidate is None:
119
+ return
120
+
121
+ self._structural = []
122
+ self._evaluate(candidate, attempt)
123
+
124
+ def finish(self) -> tuple[S, Report]:
125
+ """The object, or the reason there isn't one."""
126
+ if self._result is not None:
127
+ total, breakdown = self._result.score(self.ctx)
128
+ self.report.soft_scores = breakdown
129
+ return self._result, self.report
130
+ if self._violations:
131
+ raise ConstraintError(self._violations, self.report)
132
+ raise StructuralError(self._structural or ["model was never called"], self.report)
133
+
134
+ @property
135
+ def done(self) -> bool:
136
+ return self._result is not None
137
+
138
+ # ── internals ────────────────────────────────────────────────────────
139
+
140
+ def _build_request(self) -> ModelRequest:
141
+ if self._instance is None:
142
+ schema = self.spec.model_facing_schema()
143
+ if self._structural:
144
+ prompt = self.prompts.structural_retry(
145
+ self.spec, self.ctx, self.computed_values, self._structural
146
+ )
147
+ else:
148
+ prompt = self.prompts.initial(self.spec, self.ctx, self.computed_values)
149
+ return ModelRequest(
150
+ prompt=prompt,
151
+ schema=schema,
152
+ system=self.system,
153
+ kind="initial",
154
+ fields=self.spec.model_owned_fields(),
155
+ )
156
+
157
+ targeted = self._targeted_fields()
158
+ return ModelRequest(
159
+ prompt=self.prompts.repair(
160
+ self.spec, self.ctx, self._instance, self._violations, targeted
161
+ ),
162
+ schema=self.spec.model_facing_schema(only=targeted),
163
+ system=self.system,
164
+ kind="repair",
165
+ fields=targeted,
166
+ )
167
+
168
+ def _targeted_fields(self) -> tuple[str, ...]:
169
+ """Fields the violated rules pointed at, falling back to everything.
170
+
171
+ The fallback is deliberate rather than an error: a rule that does not
172
+ declare its fields still works, it just costs a full regeneration. The
173
+ library should not refuse to run because someone was in a hurry.
174
+ """
175
+ if not self.targeted_repair:
176
+ return self.spec.model_owned_fields()
177
+
178
+ owned = set(self.spec.model_owned_fields())
179
+ implicated = tuple(
180
+ dict.fromkeys(
181
+ name
182
+ for violation in self._violations
183
+ for name in violation.fields
184
+ if name in owned
185
+ )
186
+ )
187
+ return implicated or self.spec.model_owned_fields()
188
+
189
+ def _parse(
190
+ self, request: ModelRequest, raw: dict[str, Any], attempt: Attempt
191
+ ) -> S | None:
192
+ try:
193
+ parsed = request.schema.model_validate(raw)
194
+ except ValidationError as error:
195
+ attempt.structural_errors = tuple(_format(error))
196
+ self._structural = list(attempt.structural_errors)
197
+ return None
198
+
199
+ try:
200
+ if request.is_repair:
201
+ assert self._instance is not None
202
+ return self._instance.patched(**parsed.model_dump())
203
+ return self.spec.assemble(self.computed_values, parsed)
204
+ except ValidationError as error:
205
+ # The partial matched its own schema but the whole object does not
206
+ # hold together — a cross-field constraint expressed in Pydantic.
207
+ attempt.structural_errors = tuple(_format(error))
208
+ self._structural = list(attempt.structural_errors)
209
+ return None
210
+
211
+ def _evaluate(self, candidate: S, attempt: Attempt) -> None:
212
+ result = candidate.check(self.ctx)
213
+ if result.ok:
214
+ self._settle(candidate)
215
+ return
216
+
217
+ strategies = (
218
+ {
219
+ rule.name: rule.repair
220
+ for rule in type(candidate).__formwork_rules__
221
+ if rule.repair is not None
222
+ }
223
+ if self.use_declared_repairs
224
+ else {}
225
+ )
226
+ patched, fired = repair_module.apply(candidate, result.violations, self.ctx, strategies)
227
+
228
+ if fired:
229
+ attempt.repaired_by = tuple(fired)
230
+ candidate = patched
231
+ result = candidate.check(self.ctx)
232
+ if result.ok:
233
+ self._settle(candidate)
234
+ return
235
+
236
+ attempt.violations = tuple(result.violations)
237
+ self._instance = candidate
238
+ self._violations = list(result.violations)
239
+
240
+ def _settle(self, candidate: S) -> None:
241
+ self._instance = candidate
242
+ self._result = candidate
243
+ self._violations = []
244
+
245
+
246
+ def _format(error: ValidationError) -> list[str]:
247
+ """Pydantic errors as one readable line each, for the retry prompt."""
248
+ lines = []
249
+ for item in error.errors():
250
+ location = ".".join(str(part) for part in item["loc"]) or "<root>"
251
+ lines.append(f"{location}: {item['msg']}")
252
+ return lines
253
+
254
+
255
+ # ── drivers ──────────────────────────────────────────────────────────────
256
+
257
+
258
+ def generate(
259
+ spec: type[S],
260
+ ctx: Any,
261
+ model: Model,
262
+ *,
263
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS,
264
+ candidates: int = 1,
265
+ prompt_builder: PromptBuilder | None = None,
266
+ system: str | None = None,
267
+ targeted_repair: bool = True,
268
+ use_declared_repairs: bool = True,
269
+ ) -> tuple[S, Report]:
270
+ """Produce an object satisfying every rule, or raise.
271
+
272
+ ``candidates`` above 1 runs the loop that many times and keeps the
273
+ rule-valid result with the lowest soft score. It multiplies cost, so it is
274
+ off by default and only earns its keep when the spec declares objectives.
275
+ """
276
+ picker: _Picker[S] = _Picker(ctx, candidates)
277
+
278
+ for _ in range(candidates):
279
+ session = Session(
280
+ spec,
281
+ ctx,
282
+ max_attempts=max_attempts,
283
+ prompt_builder=prompt_builder,
284
+ system=system,
285
+ targeted_repair=targeted_repair,
286
+ use_declared_repairs=use_declared_repairs,
287
+ )
288
+ while (request := session.next_request()) is not None:
289
+ raw, usage = model.generate_structured(request)
290
+ session.feed(raw, usage)
291
+ picker.offer(session)
292
+
293
+ return picker.best()
294
+
295
+
296
+ async def agenerate(
297
+ spec: type[S],
298
+ ctx: Any,
299
+ model: AsyncModel,
300
+ *,
301
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS,
302
+ candidates: int = 1,
303
+ prompt_builder: PromptBuilder | None = None,
304
+ system: str | None = None,
305
+ targeted_repair: bool = True,
306
+ use_declared_repairs: bool = True,
307
+ ) -> tuple[S, Report]:
308
+ """``generate``, awaited. Candidates run sequentially, not concurrently:
309
+ a later candidate is only worth paying for if the earlier ones were poor,
310
+ and firing them all at once would remove that option."""
311
+ picker: _Picker[S] = _Picker(ctx, candidates)
312
+
313
+ for _ in range(candidates):
314
+ session = Session(
315
+ spec,
316
+ ctx,
317
+ max_attempts=max_attempts,
318
+ prompt_builder=prompt_builder,
319
+ system=system,
320
+ targeted_repair=targeted_repair,
321
+ use_declared_repairs=use_declared_repairs,
322
+ )
323
+ while (request := session.next_request()) is not None:
324
+ raw, usage = await model.generate_structured(request)
325
+ session.feed(raw, usage)
326
+ picker.offer(session)
327
+
328
+ return picker.best()
329
+
330
+
331
+ class _Picker(Generic[S]):
332
+ """Keeps the best candidate and the full cost of finding it."""
333
+
334
+ def __init__(self, ctx: Any, candidates: int) -> None:
335
+ self.ctx = ctx
336
+ self.candidates = candidates
337
+ self.attempts: list[Attempt] = []
338
+ self.best_result: tuple[S, Report] | None = None
339
+ self.best_score = math.inf
340
+ self.last_error: FormworkError | None = None
341
+
342
+ def offer(self, session: Session[S]) -> None:
343
+ try:
344
+ instance, report = session.finish()
345
+ except FormworkError as error:
346
+ self.last_error = error
347
+ self.attempts.extend(session.report.attempts)
348
+ return
349
+
350
+ score, _ = instance.score(self.ctx)
351
+ self.attempts.extend(report.attempts)
352
+ if score < self.best_score:
353
+ self.best_score = score
354
+ self.best_result = (instance, report)
355
+
356
+ def best(self) -> tuple[S, Report]:
357
+ if self.best_result is None:
358
+ assert self.last_error is not None
359
+ # Re-raise with the cost of every candidate, not just the last.
360
+ self.last_error.report.attempts = self.attempts
361
+ self.last_error.report.candidates_considered = self.candidates
362
+ raise self.last_error
363
+
364
+ instance, report = self.best_result
365
+ report.attempts = self.attempts
366
+ report.candidates_considered = self.candidates
367
+ return instance, report
formwork/errors.py ADDED
@@ -0,0 +1,52 @@
1
+ """Failures the caller is expected to handle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from formwork.rules import Violation
8
+
9
+ if TYPE_CHECKING:
10
+ from formwork.report import Report
11
+
12
+ __all__ = ["FormworkError", "ConstraintError", "StructuralError"]
13
+
14
+
15
+ class FormworkError(Exception):
16
+ """Base class.
17
+
18
+ Every failure carries the report, so a caller that only catches the base
19
+ class can still bill the tokens the attempt cost.
20
+ """
21
+
22
+ report: Report
23
+
24
+
25
+ class ConstraintError(FormworkError):
26
+ """The engine could not produce an object satisfying every rule.
27
+
28
+ This is the load-bearing guarantee: the alternative to raising here would
29
+ be returning something that breaks a rule you declared, and a library that
30
+ does that is worse than no library, because you would have stopped
31
+ checking.
32
+ """
33
+
34
+ def __init__(self, violations: list[Violation], report: Report) -> None:
35
+ self.violations = violations
36
+ self.report = report
37
+ detail = "; ".join(str(v) for v in violations) or "unknown"
38
+ super().__init__(
39
+ f"gave up after {report.model_calls} model call(s): {detail}"
40
+ )
41
+
42
+
43
+ class StructuralError(FormworkError):
44
+ """The model never produced output matching the schema."""
45
+
46
+ def __init__(self, errors: list[str], report: Report) -> None:
47
+ self.errors = errors
48
+ self.report = report
49
+ detail = "; ".join(errors) or "unknown"
50
+ super().__init__(
51
+ f"schema never satisfied after {report.model_calls} model call(s): {detail}"
52
+ )
formwork/fields.py ADDED
@@ -0,0 +1,128 @@
1
+ """Field roles — who owns which part of the output.
2
+
3
+ This is the idea the rest of the library is built around. In a generation task
4
+ with real domain logic, some fields are *decisions your code already knows how
5
+ to make* and some are *the reason you reached for a model in the first place*.
6
+ Mixing them into one prompt and hoping the model respects the first kind is the
7
+ default approach, and it is why these features are flaky.
8
+
9
+ So each field declares a role:
10
+
11
+ ``computed`` your code fills it, before the model is called; the model never
12
+ sees it as a choice, only as a stated fact.
13
+ ``chosen`` the model picks, but from a closed set you supply at runtime.
14
+ ``generated`` the model is free.
15
+
16
+ The practical consequence is that the schema sent to the model contains only
17
+ the ``chosen`` and ``generated`` fields. A field the model cannot see is a
18
+ field it cannot get wrong.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass
25
+ from enum import StrEnum
26
+ from typing import Any
27
+
28
+ __all__ = ["Role", "FieldSpec", "computed", "chosen", "generated"]
29
+
30
+
31
+ class Role(StrEnum):
32
+ COMPUTED = "computed"
33
+ CHOSEN = "chosen"
34
+ GENERATED = "generated"
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class FieldSpec:
39
+ """Metadata attached to a field through ``Annotated``."""
40
+
41
+ role: Role
42
+ resolver: Callable[[Any], Any] | None = None
43
+ source: str | None = None
44
+ key: str | None = None
45
+ describe: str | None = None
46
+ max_items: int | None = None
47
+
48
+ @property
49
+ def model_facing(self) -> bool:
50
+ """Does this field appear in the schema handed to the model?"""
51
+ return self.role is not Role.COMPUTED
52
+
53
+ def allowed_values(self, ctx: Any) -> tuple[Any, ...] | None:
54
+ """The closed set for a ``chosen`` field, read off the context.
55
+
56
+ Returned as a tuple so callers can put it in a prompt, hand it to a
57
+ grammar backend, or check membership — the three things anyone wants to
58
+ do with a closed set.
59
+ """
60
+ if self.role is not Role.CHOSEN or self.source is None:
61
+ return None
62
+ pool = _read_source(ctx, self.source)
63
+ if self.key is None:
64
+ return tuple(pool)
65
+ return tuple(_read_source(item, self.key) for item in pool)
66
+
67
+ def resolve(self, ctx: Any) -> Any:
68
+ if self.resolver is None:
69
+ raise ValueError("computed field has no resolver")
70
+ return self.resolver(ctx)
71
+
72
+
73
+ def _read_source(obj: Any, path: str) -> Any:
74
+ """Read ``path`` off a context object, tolerating dicts and attributes.
75
+
76
+ Contexts in the wild are ORM rows, dataclasses, Pydantic models and plain
77
+ dicts in roughly equal measure; refusing three of those would be a silly
78
+ reason for a library to be unusable.
79
+ """
80
+ current = obj
81
+ for part in path.split("."):
82
+ if isinstance(current, dict):
83
+ if part not in current:
84
+ raise KeyError(f"context has no key {path!r}")
85
+ current = current[part]
86
+ else:
87
+ if not hasattr(current, part):
88
+ raise AttributeError(f"context has no attribute {path!r}")
89
+ current = getattr(current, part)
90
+ return current
91
+
92
+
93
+ def computed(resolver: Callable[[Any], Any], *, describe: str | None = None) -> FieldSpec:
94
+ """Your code owns this field.
95
+
96
+ ``resolver`` is called with the context before the model runs. The value is
97
+ stated to the model as a fact so it can write around it, but the model is
98
+ never asked to produce it.
99
+ """
100
+ return FieldSpec(role=Role.COMPUTED, resolver=resolver, describe=describe)
101
+
102
+
103
+ def chosen(
104
+ *,
105
+ source: str,
106
+ key: str | None = None,
107
+ describe: str | None = None,
108
+ max_items: int | None = None,
109
+ ) -> FieldSpec:
110
+ """The model picks from a closed set.
111
+
112
+ ``source`` is a dotted path into the context holding the allowed pool.
113
+ ``key`` is for the common case where the field is a list of objects and it
114
+ is one attribute of each — an id — that must come from the pool, while the
115
+ rest of the object is generated.
116
+ """
117
+ return FieldSpec(
118
+ role=Role.CHOSEN,
119
+ source=source,
120
+ key=key,
121
+ describe=describe,
122
+ max_items=max_items,
123
+ )
124
+
125
+
126
+ def generated(*, describe: str | None = None, max_items: int | None = None) -> FieldSpec:
127
+ """The model is free within the type."""
128
+ return FieldSpec(role=Role.GENERATED, describe=describe, max_items=max_items)