flowspec2 1.0.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.
Files changed (92) hide show
  1. flowspec2/__init__.py +105 -0
  2. flowspec2/authoring/__init__.py +275 -0
  3. flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
  4. flowspec2/authoring/authoring-evidence.schema.json +455 -0
  5. flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
  6. flowspec2/authoring/benchmark.py +1409 -0
  7. flowspec2/authoring/corpus/await_correction.case.json +220 -0
  8. flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
  9. flowspec2/authoring/corpus/gated_derive.case.json +100 -0
  10. flowspec2/authoring/corpus/linear.case.json +55 -0
  11. flowspec2/authoring/corpus/manifest.json +31 -0
  12. flowspec2/authoring/corpus/subflow.case.json +66 -0
  13. flowspec2/authoring/corpus/terminal.case.json +94 -0
  14. flowspec2/authoring/corpus.py +307 -0
  15. flowspec2/authoring/ctk.py +836 -0
  16. flowspec2/authoring/detached_signature.py +120 -0
  17. flowspec2/authoring/evidence.py +311 -0
  18. flowspec2/authoring/evidence_signature.py +187 -0
  19. flowspec2/authoring/evidence_verification.py +229 -0
  20. flowspec2/authoring/gemini.py +215 -0
  21. flowspec2/authoring/operational-corpus.json +61 -0
  22. flowspec2/authoring/operational.py +956 -0
  23. flowspec2/authoring/operational_providers.py +167 -0
  24. flowspec2/authoring/presentation_review.py +1013 -0
  25. flowspec2/authoring/presentation_review_signature.py +305 -0
  26. flowspec2/authoring/projection.py +299 -0
  27. flowspec2/authoring/provider_prompt.py +83 -0
  28. flowspec2/backends/__init__.py +82 -0
  29. flowspec2/backends/http.py +189 -0
  30. flowspec2/checker.py +238 -0
  31. flowspec2/cli.py +789 -0
  32. flowspec2/cli_parser.py +345 -0
  33. flowspec2/clock.py +23 -0
  34. flowspec2/compat/__init__.py +47 -0
  35. flowspec2/compat/models.py +82 -0
  36. flowspec2/compat/open_workflow.py +616 -0
  37. flowspec2/compat/rasa.py +19 -0
  38. flowspec2/compat/rasa_export.py +876 -0
  39. flowspec2/compat/rasa_import.py +992 -0
  40. flowspec2/compat/rasa_shared.py +270 -0
  41. flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
  42. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
  43. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
  44. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
  45. flowspec2/compat/tool_profiles.py +149 -0
  46. flowspec2/compat/yaml.py +147 -0
  47. flowspec2/compiler.py +957 -0
  48. flowspec2/compiler_contracts.py +17 -0
  49. flowspec2/compiler_resume_contracts.py +594 -0
  50. flowspec2/compiler_schema_relations.py +1830 -0
  51. flowspec2/compiler_tool_contracts.py +245 -0
  52. flowspec2/compiler_value_contracts.py +447 -0
  53. flowspec2/derive.py +32 -0
  54. flowspec2/diagnostics.py +94 -0
  55. flowspec2/domains.py +489 -0
  56. flowspec2/experimental/__init__.py +57 -0
  57. flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
  58. flowspec2/experimental/v3-preview-loss-policy.json +161 -0
  59. flowspec2/experimental/v3_lowering.py +928 -0
  60. flowspec2/experimental/v3_preview.py +2460 -0
  61. flowspec2/flowspec-2.schema.json +635 -0
  62. flowspec2/interactive.py +300 -0
  63. flowspec2/ir.py +1459 -0
  64. flowspec2/json_codec.py +120 -0
  65. flowspec2/llm.py +366 -0
  66. flowspec2/models.py +121 -0
  67. flowspec2/nodes.py +1537 -0
  68. flowspec2/observability.py +151 -0
  69. flowspec2/predicates.py +89 -0
  70. flowspec2/profiles.py +118 -0
  71. flowspec2/py.typed +0 -0
  72. flowspec2/runtime.py +1059 -0
  73. flowspec2/schema.py +54 -0
  74. flowspec2/schema_contracts.py +203 -0
  75. flowspec2/semantic_derive_contracts.py +530 -0
  76. flowspec2/semantic_path_contracts.py +528 -0
  77. flowspec2/semantic_predicate_contracts.py +355 -0
  78. flowspec2/semantic_schema_contracts.py +137 -0
  79. flowspec2/semantic_source_contracts.py +21 -0
  80. flowspec2/semantic_state_contracts.py +450 -0
  81. flowspec2/semantic_support.py +40 -0
  82. flowspec2/semantics.py +410 -0
  83. flowspec2/state_migration.py +1034 -0
  84. flowspec2/subflows/__init__.py +1117 -0
  85. flowspec2/subflows/address.py +328 -0
  86. flowspec2/subflows/identification.py +1031 -0
  87. flowspec2/tools.py +1154 -0
  88. flowspec2-1.0.0.dist-info/METADATA +482 -0
  89. flowspec2-1.0.0.dist-info/RECORD +92 -0
  90. flowspec2-1.0.0.dist-info/WHEEL +4 -0
  91. flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
  92. flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1409 @@
1
+ """Deterministic, provider-neutral benchmarks for AI-authored flow sources.
2
+
3
+ The benchmark deliberately does not call a model or estimate model quality. A
4
+ caller injects an author and a source adapter. Every adapted document is judged
5
+ by the same :func:`flowspec2.checker.check_flow` structural, semantic, and
6
+ compilation contract.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import re
14
+ from collections.abc import Callable, Iterable, Mapping, Sequence
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Final, Protocol, cast
17
+
18
+ from flowspec2.checker import check_flow
19
+ from flowspec2.diagnostics import CompilationStatus, FlowDiagnostic
20
+ from flowspec2.ir import normalize_flow
21
+ from flowspec2.json_codec import StrictJsonError, strict_json_loads
22
+ from flowspec2.profiles import FlowProfile, reference_profile
23
+
24
+ _IDENTIFIER_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
25
+ TOKEN_PROXY_METHOD: Final[str] = "utf8_byte_quartets"
26
+ TOKEN_PROXY_BYTES_PER_UNIT: Final[int] = 4
27
+ AUTHORING_ACCEPTANCE_FORMAT: Final[str] = "flowspec2/authoring-acceptance@2"
28
+ DEFAULT_VARIABLE_POINTER_PATTERNS: Final[tuple[str, ...]] = (
29
+ "/flow",
30
+ "/route/description",
31
+ "/route/trigger_phrases",
32
+ "/version",
33
+ )
34
+ _VARIABLE_PATH_PROMPT_PATTERN: Final[re.Pattern[str]] = re.compile(
35
+ r"^/path/[0-9]+/prompt/(?:extract_hint|text)$"
36
+ )
37
+
38
+
39
+ def _require_identifier(identifier: str, contract_name: str) -> None:
40
+ if not _IDENTIFIER_PATTERN.fullmatch(identifier):
41
+ raise ValueError(
42
+ f"{contract_name} must contain only lowercase letters, digits, hyphens, "
43
+ f"and underscores: {identifier!r}"
44
+ )
45
+
46
+
47
+ def _compact_json(json_document: object) -> str:
48
+ return json.dumps(
49
+ json_document,
50
+ ensure_ascii=False,
51
+ allow_nan=False,
52
+ separators=(",", ":"),
53
+ sort_keys=True,
54
+ )
55
+
56
+
57
+ def _validate_variable_pointer_patterns(pointer_patterns: tuple[str, ...]) -> None:
58
+ if len(set(pointer_patterns)) != len(pointer_patterns):
59
+ raise ValueError("authoring variable pointer patterns must be unique")
60
+ if any(not pointer_pattern.startswith("/") for pointer_pattern in pointer_patterns):
61
+ raise ValueError("authoring variable pointer patterns must start with '/'")
62
+ if any(
63
+ pointer_pattern not in DEFAULT_VARIABLE_POINTER_PATTERNS
64
+ and not _VARIABLE_PATH_PROMPT_PATTERN.fullmatch(pointer_pattern)
65
+ for pointer_pattern in pointer_patterns
66
+ ):
67
+ raise ValueError(
68
+ "authoring variable pointers are restricted to versioned presentation paths"
69
+ )
70
+
71
+
72
+ def _normalized_flow_json(flow_document: object) -> str:
73
+ if not isinstance(flow_document, dict):
74
+ raise ValueError("an authoring flow must be a JSON object")
75
+ return _compact_json(normalize_flow(cast(dict[str, Any], flow_document)))
76
+
77
+
78
+ def _json_pointer(path_segments: Iterable[str | int]) -> str:
79
+ encoded_segments = (
80
+ str(path_segment).replace("~", "~0").replace("/", "~1") for path_segment in path_segments
81
+ )
82
+ return "".join(f"/{encoded_segment}" for encoded_segment in encoded_segments)
83
+
84
+
85
+ def _diagnostic_sort_key(flow_diagnostic: FlowDiagnostic) -> tuple[str, str, str, str, str]:
86
+ return (
87
+ flow_diagnostic.path,
88
+ flow_diagnostic.code,
89
+ flow_diagnostic.severity,
90
+ flow_diagnostic.message,
91
+ _compact_json(flow_diagnostic.to_dict()),
92
+ )
93
+
94
+
95
+ def _deterministic_diagnostics(
96
+ flow_diagnostics: Iterable[FlowDiagnostic],
97
+ ) -> tuple[FlowDiagnostic, ...]:
98
+ return tuple(sorted(set(flow_diagnostics), key=_diagnostic_sort_key))
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class SourceAdaptation:
103
+ """Immutable source and executable documents emitted by one adapter."""
104
+
105
+ format_identifier: str
106
+ authored_document_json: str | None
107
+ executable_flow_json: str | None
108
+ canonical_authored_source: str | None = None
109
+ diagnostics: tuple[FlowDiagnostic, ...] = ()
110
+
111
+ def __post_init__(self) -> None:
112
+ if not self.format_identifier:
113
+ raise ValueError("source adaptation format identifier must be non-empty")
114
+ for field_name, serialized_document in (
115
+ ("authored_document_json", self.authored_document_json),
116
+ ("executable_flow_json", self.executable_flow_json),
117
+ ):
118
+ if serialized_document is None:
119
+ continue
120
+ parsed_document = strict_json_loads(serialized_document)
121
+ if _compact_json(parsed_document) != serialized_document:
122
+ raise ValueError(f"source adaptation {field_name} must be canonical compact JSON")
123
+ if self.executable_flow_json is not None and self.authored_document_json is None:
124
+ raise ValueError("an executable flow requires an authored source document")
125
+ if (self.authored_document_json is None) != (self.canonical_authored_source is None):
126
+ raise ValueError(
127
+ "an authored document and its canonical source must be present together"
128
+ )
129
+ if self.canonical_authored_source is not None and not isinstance(
130
+ self.canonical_authored_source, str
131
+ ):
132
+ raise TypeError("source adaptation canonical authored source must be a string")
133
+ if self.canonical_authored_source == "":
134
+ raise ValueError("source adaptation canonical authored source must be non-empty")
135
+ object.__setattr__(
136
+ self,
137
+ "diagnostics",
138
+ _deterministic_diagnostics(self.diagnostics),
139
+ )
140
+
141
+ @classmethod
142
+ def from_documents(
143
+ cls,
144
+ *,
145
+ format_identifier: str,
146
+ authored_document: object,
147
+ executable_flow: object,
148
+ canonical_authored_source: str | None = None,
149
+ diagnostics: tuple[FlowDiagnostic, ...] = (),
150
+ ) -> SourceAdaptation:
151
+ """Create an adaptation with canonical documents and authored syntax."""
152
+
153
+ authored_document_json = _compact_json(authored_document)
154
+
155
+ return cls(
156
+ format_identifier=format_identifier,
157
+ authored_document_json=authored_document_json,
158
+ executable_flow_json=_compact_json(executable_flow),
159
+ canonical_authored_source=(
160
+ authored_document_json
161
+ if canonical_authored_source is None
162
+ else canonical_authored_source
163
+ ),
164
+ diagnostics=diagnostics,
165
+ )
166
+
167
+
168
+ class AuthoringSourceAdapter(Protocol):
169
+ """Convert an authored source string into the executable flowspec contract."""
170
+
171
+ @property
172
+ def format_identifier(self) -> str: ...
173
+
174
+ def adapt(self, authored_source: str) -> SourceAdaptation: ...
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class FlowSpec2JsonAdapter:
179
+ """Identity adapter for JSON-authored ``flowspec/2`` documents."""
180
+
181
+ format_identifier: str = "flowspec/2"
182
+
183
+ def adapt(self, authored_source: str) -> SourceAdaptation:
184
+ try:
185
+ authored_document: object = strict_json_loads(authored_source)
186
+ except (json.JSONDecodeError, StrictJsonError) as decoding_error:
187
+ decoding_message = (
188
+ f"Invalid JSON at line {decoding_error.lineno}, "
189
+ f"column {decoding_error.colno}: {decoding_error.msg}"
190
+ if isinstance(decoding_error, json.JSONDecodeError)
191
+ else f"Invalid JSON: {decoding_error}"
192
+ )
193
+ return SourceAdaptation(
194
+ format_identifier=self.format_identifier,
195
+ authored_document_json=None,
196
+ executable_flow_json=None,
197
+ diagnostics=(
198
+ FlowDiagnostic(
199
+ code="AUTHORING_SOURCE_JSON_INVALID",
200
+ severity="error",
201
+ path="",
202
+ message=decoding_message,
203
+ ),
204
+ ),
205
+ )
206
+ return SourceAdaptation.from_documents(
207
+ format_identifier=self.format_identifier,
208
+ authored_document=authored_document,
209
+ executable_flow=authored_document,
210
+ )
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class ForbiddenConstruct:
215
+ """A JSON Pointer pattern for one intentionally unsupported construct."""
216
+
217
+ identifier: str
218
+ pointer_pattern: str
219
+ message: str
220
+
221
+ def __post_init__(self) -> None:
222
+ _require_identifier(self.identifier, "forbidden construct identifier")
223
+ if not self.pointer_pattern.startswith("/"):
224
+ raise ValueError("forbidden construct pointer pattern must start with '/'")
225
+ if not self.message:
226
+ raise ValueError("forbidden construct message must be non-empty")
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class RequiredFlowConstruct:
231
+ """A required executable-flow location and optional exact JSON value."""
232
+
233
+ identifier: str
234
+ pointer_pattern: str
235
+ expected_json: str | None = None
236
+
237
+ def __post_init__(self) -> None:
238
+ _require_identifier(self.identifier, "required flow construct identifier")
239
+ if not self.pointer_pattern.startswith("/"):
240
+ raise ValueError("required flow construct pointer pattern must start with '/'")
241
+ if self.expected_json is not None:
242
+ expected_value = strict_json_loads(self.expected_json)
243
+ if _compact_json(expected_value) != self.expected_json:
244
+ raise ValueError("required flow construct expected value must be canonical JSON")
245
+
246
+ @classmethod
247
+ def expecting(
248
+ cls,
249
+ identifier: str,
250
+ pointer_pattern: str,
251
+ expected_value: object,
252
+ ) -> RequiredFlowConstruct:
253
+ """Create a required construct with an exact JSON-compatible value."""
254
+
255
+ return cls(
256
+ identifier=identifier,
257
+ pointer_pattern=pointer_pattern,
258
+ expected_json=_compact_json(expected_value),
259
+ )
260
+
261
+ def to_dict(self) -> dict[str, object]:
262
+ """Return the public presence or exact-value requirement."""
263
+
264
+ required_construct: dict[str, object] = {
265
+ "identifier": self.identifier,
266
+ "pointer_pattern": self.pointer_pattern,
267
+ }
268
+ if self.expected_json is not None:
269
+ required_construct["expected"] = strict_json_loads(self.expected_json)
270
+ return required_construct
271
+
272
+
273
+ @dataclass(frozen=True)
274
+ class AcceptanceExpectation:
275
+ """One public semantic observation required from an executable flow."""
276
+
277
+ path: str
278
+ expected_json: str
279
+
280
+ def __post_init__(self) -> None:
281
+ if not self.path.startswith("/"):
282
+ raise ValueError("acceptance expectation path must start with '/'")
283
+ expected_value = strict_json_loads(self.expected_json)
284
+ if _compact_json(expected_value) != self.expected_json:
285
+ raise ValueError("acceptance expectation value must be canonical JSON")
286
+
287
+ @classmethod
288
+ def expecting(cls, path: str, expected_value: object) -> AcceptanceExpectation:
289
+ """Create an expectation from a JSON-compatible value."""
290
+
291
+ return cls(path=path, expected_json=_compact_json(expected_value))
292
+
293
+ def to_dict(self) -> dict[str, object]:
294
+ """Return the public expectation contract."""
295
+
296
+ return {
297
+ "path": self.path,
298
+ "expected": strict_json_loads(self.expected_json),
299
+ }
300
+
301
+
302
+ @dataclass(frozen=True)
303
+ class AuthoringAcceptanceContract:
304
+ """Complete public grading contract without a reference source document."""
305
+
306
+ expectations: tuple[AcceptanceExpectation, ...]
307
+ required_constructs: tuple[RequiredFlowConstruct, ...] = ()
308
+ forbidden_constructs: tuple[ForbiddenConstruct, ...] = field(
309
+ default_factory=lambda: DEFAULT_FORBIDDEN_CONSTRUCTS
310
+ )
311
+ variable_pointer_patterns: tuple[str, ...] = DEFAULT_VARIABLE_POINTER_PATTERNS
312
+ format_identifier: str = AUTHORING_ACCEPTANCE_FORMAT
313
+
314
+ def __post_init__(self) -> None:
315
+ if self.format_identifier != AUTHORING_ACCEPTANCE_FORMAT:
316
+ raise ValueError("unknown authoring acceptance format")
317
+ expectations = tuple(sorted(self.expectations, key=lambda expectation: expectation.path))
318
+ if not expectations:
319
+ raise ValueError("authoring acceptance contract requires expectations")
320
+ expectation_paths = tuple(expectation.path for expectation in expectations)
321
+ if len(set(expectation_paths)) != len(expectation_paths):
322
+ raise ValueError("authoring acceptance expectation paths must be unique")
323
+ required_constructs = tuple(
324
+ sorted(self.required_constructs, key=lambda construct: construct.identifier)
325
+ )
326
+ forbidden_constructs = tuple(
327
+ sorted(self.forbidden_constructs, key=lambda construct: construct.identifier)
328
+ )
329
+ variable_pointer_patterns = tuple(sorted(self.variable_pointer_patterns))
330
+ _validate_variable_pointer_patterns(variable_pointer_patterns)
331
+ object.__setattr__(self, "expectations", expectations)
332
+ object.__setattr__(self, "required_constructs", required_constructs)
333
+ object.__setattr__(self, "forbidden_constructs", forbidden_constructs)
334
+ object.__setattr__(self, "variable_pointer_patterns", variable_pointer_patterns)
335
+
336
+ def to_dict(self) -> dict[str, object]:
337
+ """Return the closed author-visible acceptance contract."""
338
+
339
+ return {
340
+ "format": self.format_identifier,
341
+ "expectations": [expectation.to_dict() for expectation in self.expectations],
342
+ "required_constructs": [
343
+ required_construct.to_dict() for required_construct in self.required_constructs
344
+ ],
345
+ "forbidden_constructs": [
346
+ {
347
+ "identifier": forbidden_construct.identifier,
348
+ "pointer_pattern": forbidden_construct.pointer_pattern,
349
+ "message": forbidden_construct.message,
350
+ }
351
+ for forbidden_construct in self.forbidden_constructs
352
+ ],
353
+ "variable_pointer_patterns": list(self.variable_pointer_patterns),
354
+ }
355
+
356
+
357
+ def _forbidden_construct(
358
+ identifier: str,
359
+ pointer_pattern: str,
360
+ construct_name: str,
361
+ ) -> ForbiddenConstruct:
362
+ return ForbiddenConstruct(
363
+ identifier=identifier,
364
+ pointer_pattern=pointer_pattern,
365
+ message=(
366
+ f"The authored source uses unsupported {construct_name}; "
367
+ "keep the flow on the closed ordered conversational rail."
368
+ ),
369
+ )
370
+
371
+
372
+ DEFAULT_FORBIDDEN_CONSTRUCTS: Final[tuple[ForbiddenConstruct, ...]] = (
373
+ _forbidden_construct("top-code", "/code", "arbitrary code"),
374
+ _forbidden_construct("top-cron", "/cron", "cron scheduling"),
375
+ _forbidden_construct("top-expressions", "/expressions", "arbitrary expressions"),
376
+ _forbidden_construct("top-loop", "/loop", "loops"),
377
+ _forbidden_construct("top-loops", "/loops", "loops"),
378
+ _forbidden_construct("top-parallel", "/parallel", "parallel execution"),
379
+ _forbidden_construct("top-schedule", "/schedule", "scheduling"),
380
+ _forbidden_construct("top-script", "/script", "arbitrary scripts"),
381
+ _forbidden_construct("top-transitions", "/transitions", "manual transition tables"),
382
+ _forbidden_construct("path-code", "/path/*/code", "arbitrary step code"),
383
+ _forbidden_construct("path-expression", "/path/*/expression", "arbitrary expressions"),
384
+ _forbidden_construct("path-for-each", "/path/*/for_each", "iteration"),
385
+ _forbidden_construct("path-goto", "/path/*/goto", "manual path transitions"),
386
+ _forbidden_construct("path-loop", "/path/*/loop", "loops"),
387
+ _forbidden_construct("path-next", "/path/*/next", "manual path transitions"),
388
+ _forbidden_construct("path-parallel", "/path/*/parallel", "parallel execution"),
389
+ _forbidden_construct("path-script", "/path/*/script", "arbitrary scripts"),
390
+ _forbidden_construct("path-while", "/path/*/while", "loops"),
391
+ )
392
+
393
+
394
+ @dataclass(frozen=True)
395
+ class AuthoringTask:
396
+ """The source-answer-free task and complete public grading contract."""
397
+
398
+ identifier: str
399
+ prompt: str
400
+ acceptance: AuthoringAcceptanceContract
401
+
402
+ def __post_init__(self) -> None:
403
+ _require_identifier(self.identifier, "authoring task identifier")
404
+ if not self.prompt.strip():
405
+ raise ValueError(f"authoring task {self.identifier!r} prompt must be non-empty")
406
+ object.__setattr__(self, "prompt", self.prompt.strip())
407
+
408
+
409
+ @dataclass(frozen=True)
410
+ class AuthoringBenchmarkCase:
411
+ """One private fixture projected into a public semantic task contract."""
412
+
413
+ identifier: str
414
+ prompt: str
415
+ expected_flow_json: str
416
+ feature_tags: tuple[str, ...] = ()
417
+ required_constructs: tuple[RequiredFlowConstruct, ...] = ()
418
+ forbidden_constructs: tuple[ForbiddenConstruct, ...] = DEFAULT_FORBIDDEN_CONSTRUCTS
419
+
420
+ def __post_init__(self) -> None:
421
+ _require_identifier(self.identifier, "benchmark case identifier")
422
+ if not self.prompt.strip():
423
+ raise ValueError(f"benchmark case {self.identifier!r} prompt must be non-empty")
424
+ expected_flow = strict_json_loads(self.expected_flow_json)
425
+ feature_tags = tuple(self.feature_tags)
426
+ required_constructs = tuple(self.required_constructs)
427
+ forbidden_constructs = tuple(self.forbidden_constructs)
428
+ if len(set(feature_tags)) != len(feature_tags):
429
+ raise ValueError(f"benchmark case {self.identifier!r} feature tags must be unique")
430
+ if len({rule.identifier for rule in forbidden_constructs}) != len(forbidden_constructs):
431
+ raise ValueError(
432
+ f"benchmark case {self.identifier!r} forbidden construct identifiers must be unique"
433
+ )
434
+ if len({rule.identifier for rule in required_constructs}) != len(required_constructs):
435
+ raise ValueError(
436
+ f"benchmark case {self.identifier!r} required construct identifiers must be unique"
437
+ )
438
+ object.__setattr__(self, "prompt", self.prompt.strip())
439
+ object.__setattr__(self, "expected_flow_json", _normalized_flow_json(expected_flow))
440
+ object.__setattr__(self, "feature_tags", tuple(sorted(feature_tags)))
441
+ object.__setattr__(
442
+ self,
443
+ "required_constructs",
444
+ tuple(sorted(required_constructs, key=lambda rule: rule.identifier)),
445
+ )
446
+ object.__setattr__(
447
+ self,
448
+ "forbidden_constructs",
449
+ tuple(sorted(forbidden_constructs, key=lambda rule: rule.identifier)),
450
+ )
451
+
452
+ @classmethod
453
+ def expecting_flow(
454
+ cls,
455
+ *,
456
+ identifier: str,
457
+ prompt: str,
458
+ expected_flow: object,
459
+ feature_tags: tuple[str, ...] = (),
460
+ required_constructs: tuple[RequiredFlowConstruct, ...] = (),
461
+ forbidden_constructs: tuple[ForbiddenConstruct, ...] = DEFAULT_FORBIDDEN_CONSTRUCTS,
462
+ ) -> AuthoringBenchmarkCase:
463
+ """Create a closed case from a reviewed private fixture flow."""
464
+
465
+ return cls(
466
+ identifier=identifier,
467
+ prompt=prompt,
468
+ expected_flow_json=_compact_json(expected_flow),
469
+ feature_tags=feature_tags,
470
+ required_constructs=required_constructs,
471
+ forbidden_constructs=forbidden_constructs,
472
+ )
473
+
474
+ def authoring_task(self) -> AuthoringTask:
475
+ """Project the evaluator-private case into its author-facing contract."""
476
+
477
+ return AuthoringTask(
478
+ identifier=self.identifier,
479
+ prompt=self.prompt,
480
+ acceptance=authoring_acceptance_for_flow(
481
+ strict_json_loads(self.expected_flow_json),
482
+ required_constructs=self.required_constructs,
483
+ forbidden_constructs=self.forbidden_constructs,
484
+ ),
485
+ )
486
+
487
+
488
+ @dataclass(frozen=True)
489
+ class AuthoringBenchmarkLimits:
490
+ """Bound the correction interaction for every benchmark case."""
491
+
492
+ max_correction_rounds: int = 2
493
+
494
+ def __post_init__(self) -> None:
495
+ if self.max_correction_rounds < 0:
496
+ raise ValueError("max_correction_rounds must not be negative")
497
+
498
+
499
+ @dataclass(frozen=True)
500
+ class AuthoringRequest:
501
+ """One initial or diagnostic-guided request delivered to an injected author."""
502
+
503
+ task: AuthoringTask
504
+ format_identifier: str
505
+ profile_identifier: str
506
+ profile_contract_json: str
507
+ correction_round: int
508
+ previous_source: str | None
509
+ previous_diagnostics: tuple[FlowDiagnostic, ...]
510
+
511
+ def __post_init__(self) -> None:
512
+ if not self.format_identifier:
513
+ raise ValueError("authoring request format identifier must be non-empty")
514
+ if not self.profile_identifier:
515
+ raise ValueError("authoring request profile identifier must be non-empty")
516
+ profile_contract = strict_json_loads(self.profile_contract_json)
517
+ if not isinstance(profile_contract, dict):
518
+ raise ValueError("authoring request profile contract must be a JSON object")
519
+ if _compact_json(profile_contract) != self.profile_contract_json:
520
+ raise ValueError("authoring request profile contract must be canonical JSON")
521
+ if profile_contract.get("identifier") != self.profile_identifier:
522
+ raise ValueError("authoring request profile contract identifier does not match")
523
+ if self.correction_round < 0:
524
+ raise ValueError("authoring request correction round must not be negative")
525
+ if self.correction_round == 0 and (
526
+ self.previous_source is not None or self.previous_diagnostics
527
+ ):
528
+ raise ValueError("the initial authoring request cannot contain a previous attempt")
529
+ if self.correction_round > 0 and self.previous_source is None:
530
+ raise ValueError("a correction request requires the previous source")
531
+ object.__setattr__(
532
+ self,
533
+ "previous_diagnostics",
534
+ _deterministic_diagnostics(self.previous_diagnostics),
535
+ )
536
+
537
+
538
+ @dataclass(frozen=True)
539
+ class AuthoredSource:
540
+ """One exact authored source and optional effective provider model version."""
541
+
542
+ source: str
543
+ effective_model_version: str | None = None
544
+
545
+ def __post_init__(self) -> None:
546
+ if not isinstance(self.source, str):
547
+ raise TypeError("authored source must be a string")
548
+ if self.effective_model_version is not None:
549
+ normalized_model_version = self.effective_model_version.strip()
550
+ if not normalized_model_version:
551
+ raise ValueError("effective model version must be non-empty when present")
552
+ object.__setattr__(self, "effective_model_version", normalized_model_version)
553
+
554
+
555
+ AuthoringAuthor = Callable[[AuthoringRequest], AuthoredSource]
556
+
557
+
558
+ @dataclass(frozen=True)
559
+ class AuthoringAttempt:
560
+ """Machine-readable outcome for one emitted source document."""
561
+
562
+ correction_round: int
563
+ source_sha256: str
564
+ raw_source_bytes: int
565
+ canonical_source_bytes: int | None
566
+ token_proxy_units: int | None
567
+ compilation: CompilationStatus
568
+ diagnostics: tuple[FlowDiagnostic, ...]
569
+ succeeded: bool
570
+
571
+ def __post_init__(self) -> None:
572
+ if self.correction_round < 0:
573
+ raise ValueError("authoring attempt correction round must not be negative")
574
+ if not re.fullmatch(r"[0-9a-f]{64}", self.source_sha256):
575
+ raise ValueError("authoring attempt source digest must be lowercase SHA-256")
576
+ if self.raw_source_bytes < 0:
577
+ raise ValueError("authoring attempt raw source size must not be negative")
578
+ if (self.canonical_source_bytes is None) != (self.token_proxy_units is None):
579
+ raise ValueError("canonical source size and token proxy must be present together")
580
+ if self.canonical_source_bytes is not None:
581
+ expected_proxy = (
582
+ self.canonical_source_bytes + TOKEN_PROXY_BYTES_PER_UNIT - 1
583
+ ) // TOKEN_PROXY_BYTES_PER_UNIT
584
+ if self.canonical_source_bytes < 0 or self.token_proxy_units != expected_proxy:
585
+ raise ValueError(
586
+ "authoring attempt token proxy does not match canonical source size"
587
+ )
588
+ deterministic_diagnostics = _deterministic_diagnostics(self.diagnostics)
589
+ object.__setattr__(self, "diagnostics", deterministic_diagnostics)
590
+ if self.succeeded and (
591
+ self.compilation != "succeeded"
592
+ or any(
593
+ flow_diagnostic.severity == "error" for flow_diagnostic in deterministic_diagnostics
594
+ )
595
+ ):
596
+ raise ValueError("a successful authoring attempt must compile without errors")
597
+
598
+ def to_dict(self) -> dict[str, object]:
599
+ """Return a deterministic JSON-compatible representation."""
600
+
601
+ return {
602
+ "correction_round": self.correction_round,
603
+ "source_sha256": self.source_sha256,
604
+ "raw_source_bytes": self.raw_source_bytes,
605
+ "canonical_source_bytes": self.canonical_source_bytes,
606
+ "token_proxy_units": self.token_proxy_units,
607
+ "compilation": self.compilation,
608
+ "succeeded": self.succeeded,
609
+ "diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
610
+ }
611
+
612
+
613
+ @dataclass(frozen=True)
614
+ class AuthoringBenchmarkResult:
615
+ """All bounded attempts for one benchmark case."""
616
+
617
+ case_identifier: str
618
+ feature_tags: tuple[str, ...]
619
+ format_identifier: str
620
+ attempts: tuple[AuthoringAttempt, ...]
621
+
622
+ def __post_init__(self) -> None:
623
+ _require_identifier(self.case_identifier, "benchmark result case identifier")
624
+ if not self.format_identifier:
625
+ raise ValueError("benchmark result format identifier must be non-empty")
626
+ feature_tags = tuple(self.feature_tags)
627
+ if len(set(feature_tags)) != len(feature_tags):
628
+ raise ValueError(
629
+ f"benchmark result {self.case_identifier!r} feature tags must be unique"
630
+ )
631
+ authoring_attempts = tuple(self.attempts)
632
+ object.__setattr__(self, "feature_tags", tuple(sorted(feature_tags)))
633
+ object.__setattr__(self, "attempts", authoring_attempts)
634
+ if not authoring_attempts:
635
+ raise ValueError(f"benchmark result {self.case_identifier!r} requires an attempt")
636
+ expected_rounds = tuple(range(len(authoring_attempts)))
637
+ actual_rounds = tuple(attempt.correction_round for attempt in authoring_attempts)
638
+ if actual_rounds != expected_rounds:
639
+ raise ValueError(
640
+ f"benchmark result {self.case_identifier!r} attempts must use contiguous rounds"
641
+ )
642
+ if any(attempt.succeeded for attempt in authoring_attempts[:-1]):
643
+ raise ValueError(
644
+ f"benchmark result {self.case_identifier!r} cannot continue after success"
645
+ )
646
+
647
+ @property
648
+ def succeeded(self) -> bool:
649
+ return self.attempts[-1].succeeded
650
+
651
+ @property
652
+ def correction_rounds(self) -> int:
653
+ return len(self.attempts) - 1
654
+
655
+ def to_dict(self) -> dict[str, object]:
656
+ """Return a deterministic JSON-compatible representation."""
657
+
658
+ return {
659
+ "case_identifier": self.case_identifier,
660
+ "feature_tags": list(self.feature_tags),
661
+ "format_identifier": self.format_identifier,
662
+ "succeeded": self.succeeded,
663
+ "correction_rounds": self.correction_rounds,
664
+ "attempts": [attempt.to_dict() for attempt in self.attempts],
665
+ }
666
+
667
+
668
+ @dataclass(frozen=True)
669
+ class AuthoringBenchmarkReport:
670
+ """Deterministic aggregate suitable for cross-adapter comparison."""
671
+
672
+ benchmark_identifier: str
673
+ format_identifier: str
674
+ profile_identifier: str
675
+ case_results: tuple[AuthoringBenchmarkResult, ...]
676
+
677
+ def __post_init__(self) -> None:
678
+ _require_identifier(self.benchmark_identifier, "benchmark identifier")
679
+ if not self.format_identifier:
680
+ raise ValueError("benchmark report format identifier must be non-empty")
681
+ if not self.profile_identifier:
682
+ raise ValueError("benchmark report profile identifier must be non-empty")
683
+ case_results = tuple(self.case_results)
684
+ object.__setattr__(self, "case_results", case_results)
685
+ if not case_results:
686
+ raise ValueError("benchmark report requires at least one case")
687
+ case_identifiers = tuple(case_result.case_identifier for case_result in case_results)
688
+ if case_identifiers != tuple(sorted(case_identifiers)):
689
+ raise ValueError("benchmark report cases must be sorted by identifier")
690
+ if len(set(case_identifiers)) != len(case_identifiers):
691
+ raise ValueError("benchmark report case identifiers must be unique")
692
+ if any(
693
+ case_result.format_identifier != self.format_identifier for case_result in case_results
694
+ ):
695
+ raise ValueError("benchmark report case formats must match the report format")
696
+
697
+ @property
698
+ def successful_cases(self) -> int:
699
+ return sum(case_result.succeeded for case_result in self.case_results)
700
+
701
+ @property
702
+ def total_cases(self) -> int:
703
+ return len(self.case_results)
704
+
705
+ @property
706
+ def total_attempts(self) -> int:
707
+ return sum(len(case_result.attempts) for case_result in self.case_results)
708
+
709
+ @property
710
+ def total_correction_rounds(self) -> int:
711
+ return sum(case_result.correction_rounds for case_result in self.case_results)
712
+
713
+ def to_dict(self) -> dict[str, object]:
714
+ """Return a deterministic JSON-compatible representation."""
715
+
716
+ return {
717
+ "benchmark_identifier": self.benchmark_identifier,
718
+ "format_identifier": self.format_identifier,
719
+ "profile_identifier": self.profile_identifier,
720
+ "successful_cases": self.successful_cases,
721
+ "total_cases": self.total_cases,
722
+ "total_attempts": self.total_attempts,
723
+ "total_correction_rounds": self.total_correction_rounds,
724
+ "token_proxy": {
725
+ "method": TOKEN_PROXY_METHOD,
726
+ "bytes_per_unit": TOKEN_PROXY_BYTES_PER_UNIT,
727
+ },
728
+ "case_results": [case_result.to_dict() for case_result in self.case_results],
729
+ }
730
+
731
+ def to_json(self) -> str:
732
+ """Serialize the report as canonical compact JSON."""
733
+
734
+ return _compact_json(self.to_dict())
735
+
736
+
737
+ def _document_locations(
738
+ json_document: object,
739
+ path_segments: tuple[str | int, ...] = (),
740
+ ) -> Iterable[tuple[tuple[str | int, ...], object]]:
741
+ if isinstance(json_document, Mapping):
742
+ for property_name in sorted(json_document, key=str):
743
+ property_path = (*path_segments, str(property_name))
744
+ property_value = json_document[property_name]
745
+ yield property_path, property_value
746
+ yield from _document_locations(property_value, property_path)
747
+ elif isinstance(json_document, list):
748
+ for element_index, element_value in enumerate(json_document):
749
+ element_path = (*path_segments, element_index)
750
+ yield element_path, element_value
751
+ yield from _document_locations(element_value, element_path)
752
+
753
+
754
+ def _pattern_segments(pointer_pattern: str) -> tuple[str, ...]:
755
+ return tuple(
756
+ encoded_segment.replace("~1", "/").replace("~0", "~")
757
+ for encoded_segment in pointer_pattern[1:].split("/")
758
+ )
759
+
760
+
761
+ def _matches_pointer_pattern(
762
+ path_segments: tuple[str | int, ...],
763
+ pointer_pattern: str,
764
+ ) -> bool:
765
+ expected_segments = _pattern_segments(pointer_pattern)
766
+ return len(path_segments) == len(expected_segments) and all(
767
+ expected_segment == "*" or expected_segment == str(path_segment)
768
+ for path_segment, expected_segment in zip(path_segments, expected_segments, strict=True)
769
+ )
770
+
771
+
772
+ def _semantic_observation_items(
773
+ json_document: object,
774
+ path_segments: tuple[str | int, ...] = (),
775
+ ) -> Iterable[tuple[str, object]]:
776
+ if isinstance(json_document, Mapping):
777
+ if not json_document and path_segments:
778
+ yield _json_pointer(path_segments), {}
779
+ for property_name in sorted(json_document, key=str):
780
+ property_path = (*path_segments, str(property_name))
781
+ yield from _semantic_observation_items(
782
+ json_document[property_name],
783
+ property_path,
784
+ )
785
+ return
786
+ if isinstance(json_document, list):
787
+ if not json_document and path_segments:
788
+ yield _json_pointer(path_segments), []
789
+ for element_index, element_value in enumerate(json_document):
790
+ yield from _semantic_observation_items(
791
+ element_value,
792
+ (*path_segments, element_index),
793
+ )
794
+ return
795
+ if not path_segments:
796
+ raise ValueError("an authoring semantic observation requires a JSON object")
797
+ yield _json_pointer(path_segments), json_document
798
+
799
+
800
+ def _referenced_domain_names(json_document: object) -> frozenset[str]:
801
+ referenced_names: set[str] = set()
802
+ if isinstance(json_document, Mapping):
803
+ for property_name, property_value in json_document.items():
804
+ if property_name in {"domain", "from_domain"} and isinstance(property_value, str):
805
+ referenced_names.add(property_value)
806
+ referenced_names.update(_referenced_domain_names(property_value))
807
+ elif isinstance(json_document, list):
808
+ for element_value in json_document:
809
+ referenced_names.update(_referenced_domain_names(element_value))
810
+ return frozenset(referenced_names)
811
+
812
+
813
+ def _step_references(normalized_flow: Mapping[str, object]) -> dict[str, str]:
814
+ step_references: dict[str, str] = {}
815
+ normalized_path = normalized_flow.get("path")
816
+ if isinstance(normalized_path, list):
817
+ for path_index, path_step in enumerate(normalized_path):
818
+ if isinstance(path_step, Mapping) and isinstance(path_step.get("step"), str):
819
+ step_references[cast(str, path_step["step"])] = f"path[{path_index}]"
820
+ for contract_name in ("confirm", "terminal"):
821
+ contract = normalized_flow.get(contract_name)
822
+ if isinstance(contract, Mapping) and isinstance(contract.get("step"), str):
823
+ step_references[cast(str, contract["step"])] = contract_name
824
+ return step_references
825
+
826
+
827
+ def _semantic_prompt(
828
+ prompt_contract: Mapping[str, object],
829
+ domains: Mapping[str, object],
830
+ step_references: Mapping[str, str],
831
+ path_segments: tuple[str | int, ...],
832
+ ) -> dict[str, object]:
833
+ verbatim = prompt_contract.get("verbatim") is True
834
+ semantic_prompt: dict[str, object] = {
835
+ "present": True,
836
+ "verbatim": verbatim,
837
+ }
838
+ if verbatim:
839
+ semantic_prompt["text"] = prompt_contract.get("text")
840
+ for property_name, property_value in prompt_contract.items():
841
+ if property_name in {"extract_hint", "text", "verbatim"}:
842
+ continue
843
+ semantic_prompt[property_name] = _semantic_flow_value(
844
+ property_value,
845
+ domains,
846
+ step_references,
847
+ (*path_segments, property_name),
848
+ )
849
+ return semantic_prompt
850
+
851
+
852
+ def _semantic_flow_value(
853
+ json_document: object,
854
+ domains: Mapping[str, object],
855
+ step_references: Mapping[str, str],
856
+ path_segments: tuple[str | int, ...] = (),
857
+ ) -> object:
858
+ if isinstance(json_document, Mapping):
859
+ semantic_mapping: dict[str, object] = {}
860
+ for property_name, property_value in json_document.items():
861
+ property_path = (*path_segments, property_name)
862
+ if not path_segments and property_name in {"domains", "flow", "version"}:
863
+ continue
864
+ if path_segments == ("route",) and property_name in {
865
+ "description",
866
+ "trigger_phrases",
867
+ }:
868
+ continue
869
+ if property_name == "prompt" and isinstance(property_value, Mapping):
870
+ semantic_mapping[property_name] = _semantic_prompt(
871
+ property_value,
872
+ domains,
873
+ step_references,
874
+ property_path,
875
+ )
876
+ continue
877
+ if property_name in {"domain", "from_domain"} and isinstance(property_value, str):
878
+ semantic_mapping[f"{property_name}_contract"] = _semantic_flow_value(
879
+ domains[property_value],
880
+ domains,
881
+ step_references,
882
+ property_path,
883
+ )
884
+ continue
885
+ is_step_declaration = property_name == "step" and (
886
+ (len(path_segments) == 2 and path_segments[0] == "path")
887
+ or path_segments in {("confirm",), ("terminal",)}
888
+ )
889
+ if is_step_declaration:
890
+ continue
891
+ if property_name in {"goto", "next_step", "on_confirm", "step"} and isinstance(
892
+ property_value, str
893
+ ):
894
+ semantic_mapping[property_name] = step_references.get(
895
+ property_value,
896
+ property_value,
897
+ )
898
+ continue
899
+ semantic_mapping[property_name] = _semantic_flow_value(
900
+ property_value,
901
+ domains,
902
+ step_references,
903
+ property_path,
904
+ )
905
+ return semantic_mapping
906
+ if isinstance(json_document, list):
907
+ return [
908
+ _semantic_flow_value(
909
+ element_value,
910
+ domains,
911
+ step_references,
912
+ (*path_segments, element_index),
913
+ )
914
+ for element_index, element_value in enumerate(json_document)
915
+ ]
916
+ return json_document
917
+
918
+
919
+ def _semantic_flow_document(normalized_flow: Mapping[str, object]) -> dict[str, object]:
920
+ raw_domains = normalized_flow.get("domains")
921
+ if not isinstance(raw_domains, Mapping):
922
+ raise ValueError("a normalized authoring flow requires domain contracts")
923
+ domains = cast(Mapping[str, object], raw_domains)
924
+ semantic_flow = cast(
925
+ dict[str, object],
926
+ _semantic_flow_value(
927
+ normalized_flow,
928
+ domains,
929
+ _step_references(normalized_flow),
930
+ ),
931
+ )
932
+ referenced_domains = _referenced_domain_names(normalized_flow)
933
+ semantic_flow["unused_domain_contracts"] = sorted(
934
+ (
935
+ _semantic_flow_value(domain_contract, domains, {}, ("unused_domain_contracts",))
936
+ for domain_name, domain_contract in domains.items()
937
+ if domain_name not in referenced_domains
938
+ ),
939
+ key=_compact_json,
940
+ )
941
+ return semantic_flow
942
+
943
+
944
+ def observe_authoring_flow(
945
+ executable_flow: object,
946
+ *,
947
+ variable_pointer_patterns: tuple[str, ...] = DEFAULT_VARIABLE_POINTER_PATTERNS,
948
+ ) -> dict[str, object]:
949
+ """Project one flow into complete, source-syntax-free semantic observations."""
950
+
951
+ normalized_flow = strict_json_loads(_normalized_flow_json(executable_flow))
952
+ if not isinstance(normalized_flow, dict):
953
+ raise AssertionError("normalized authoring flows must be JSON objects")
954
+ _validate_variable_pointer_patterns(tuple(sorted(variable_pointer_patterns)))
955
+ return dict(_semantic_observation_items(_semantic_flow_document(normalized_flow)))
956
+
957
+
958
+ def authoring_acceptance_for_flow(
959
+ executable_flow: object,
960
+ *,
961
+ required_constructs: tuple[RequiredFlowConstruct, ...] = (),
962
+ forbidden_constructs: tuple[ForbiddenConstruct, ...] = DEFAULT_FORBIDDEN_CONSTRUCTS,
963
+ variable_pointer_patterns: tuple[str, ...] | None = None,
964
+ ) -> AuthoringAcceptanceContract:
965
+ """Build the public semantic contract for one private fixture source."""
966
+
967
+ normalized_flow = strict_json_loads(_normalized_flow_json(executable_flow))
968
+ if not isinstance(normalized_flow, dict):
969
+ raise AssertionError("normalized authoring flows must be JSON objects")
970
+ resolved_variable_pointer_patterns = list(
971
+ DEFAULT_VARIABLE_POINTER_PATTERNS
972
+ if variable_pointer_patterns is None
973
+ else variable_pointer_patterns
974
+ )
975
+ normalized_path = normalized_flow.get("path")
976
+ if variable_pointer_patterns is None and isinstance(normalized_path, list):
977
+ for path_index, path_step in enumerate(normalized_path):
978
+ if not isinstance(path_step, dict):
979
+ continue
980
+ prompt_contract = path_step.get("prompt")
981
+ if isinstance(prompt_contract, dict) and prompt_contract.get("verbatim") is not True:
982
+ resolved_variable_pointer_patterns.append(f"/path/{path_index}/prompt/text")
983
+ if isinstance(prompt_contract, dict):
984
+ resolved_variable_pointer_patterns.append(f"/path/{path_index}/prompt/extract_hint")
985
+ resolved_patterns = tuple(sorted(resolved_variable_pointer_patterns))
986
+ observations = observe_authoring_flow(
987
+ normalized_flow,
988
+ variable_pointer_patterns=resolved_patterns,
989
+ )
990
+ return AuthoringAcceptanceContract(
991
+ expectations=tuple(
992
+ AcceptanceExpectation.expecting(path, expected_value)
993
+ for path, expected_value in observations.items()
994
+ ),
995
+ required_constructs=required_constructs,
996
+ forbidden_constructs=forbidden_constructs,
997
+ variable_pointer_patterns=resolved_patterns,
998
+ )
999
+
1000
+
1001
+ def _forbidden_diagnostics(
1002
+ authored_document: object,
1003
+ forbidden_constructs: tuple[ForbiddenConstruct, ...],
1004
+ ) -> tuple[FlowDiagnostic, ...]:
1005
+ flow_diagnostics = (
1006
+ FlowDiagnostic(
1007
+ code=f"AUTHORING_FORBIDDEN_{forbidden_construct.identifier.upper().replace('-', '_')}",
1008
+ severity="error",
1009
+ path=_json_pointer(path_segments),
1010
+ message=forbidden_construct.message,
1011
+ )
1012
+ for path_segments, _property_value in _document_locations(authored_document)
1013
+ for forbidden_construct in forbidden_constructs
1014
+ if _matches_pointer_pattern(path_segments, forbidden_construct.pointer_pattern)
1015
+ )
1016
+ return _deterministic_diagnostics(flow_diagnostics)
1017
+
1018
+
1019
+ def _matching_locations(
1020
+ json_document: object,
1021
+ pointer_pattern: str,
1022
+ ) -> tuple[tuple[tuple[str | int, ...], object], ...]:
1023
+ return tuple(
1024
+ (path_segments, property_value)
1025
+ for path_segments, property_value in _document_locations(json_document)
1026
+ if _matches_pointer_pattern(path_segments, pointer_pattern)
1027
+ )
1028
+
1029
+
1030
+ def _missing_construct_path(pointer_pattern: str) -> str:
1031
+ fixed_segments = []
1032
+ for pattern_segment in _pattern_segments(pointer_pattern):
1033
+ if pattern_segment == "*":
1034
+ break
1035
+ fixed_segments.append(pattern_segment)
1036
+ return _json_pointer(fixed_segments)
1037
+
1038
+
1039
+ def _required_construct_diagnostics(
1040
+ executable_flow: object,
1041
+ required_constructs: tuple[RequiredFlowConstruct, ...],
1042
+ ) -> tuple[FlowDiagnostic, ...]:
1043
+ flow_diagnostics: list[FlowDiagnostic] = []
1044
+ for required_construct in required_constructs:
1045
+ matching_locations = _matching_locations(
1046
+ executable_flow,
1047
+ required_construct.pointer_pattern,
1048
+ )
1049
+ diagnostic_code = required_construct.identifier.upper().replace("-", "_")
1050
+ if not matching_locations:
1051
+ expected_contract = (
1052
+ ""
1053
+ if required_construct.expected_json is None
1054
+ else f" with the exact JSON value {required_construct.expected_json}"
1055
+ )
1056
+ flow_diagnostics.append(
1057
+ FlowDiagnostic(
1058
+ code=f"AUTHORING_REQUIRED_{diagnostic_code}_MISSING",
1059
+ severity="error",
1060
+ path=_missing_construct_path(required_construct.pointer_pattern),
1061
+ message=(
1062
+ "The executable flow is missing required construct "
1063
+ f"{required_construct.pointer_pattern!r}{expected_contract}."
1064
+ ),
1065
+ suggested_fix=(
1066
+ f"Add a location matching {required_construct.pointer_pattern!r}"
1067
+ f"{expected_contract}."
1068
+ ),
1069
+ )
1070
+ )
1071
+ continue
1072
+ if required_construct.expected_json is None or any(
1073
+ _compact_json(property_value) == required_construct.expected_json
1074
+ for _path_segments, property_value in matching_locations
1075
+ ):
1076
+ continue
1077
+ first_path, first_property_value = matching_locations[0]
1078
+ actual_json_value = _compact_json(first_property_value)
1079
+ flow_diagnostics.append(
1080
+ FlowDiagnostic(
1081
+ code=f"AUTHORING_REQUIRED_{diagnostic_code}_MISMATCH",
1082
+ severity="error",
1083
+ path=_json_pointer(first_path),
1084
+ message=(
1085
+ f"Required construct {required_construct.pointer_pattern!r} matched "
1086
+ f"{actual_json_value}, but must have the exact JSON value "
1087
+ f"{required_construct.expected_json}."
1088
+ ),
1089
+ suggested_fix=(
1090
+ f"Set a location matching {required_construct.pointer_pattern!r} to "
1091
+ f"{required_construct.expected_json}."
1092
+ ),
1093
+ )
1094
+ )
1095
+ return _deterministic_diagnostics(flow_diagnostics)
1096
+
1097
+
1098
+ def acceptance_diagnostics(
1099
+ executable_flow: object,
1100
+ acceptance_contract: AuthoringAcceptanceContract,
1101
+ *,
1102
+ covered_paths: frozenset[str] = frozenset(),
1103
+ ) -> tuple[FlowDiagnostic, ...]:
1104
+ """Return every deterministic mismatch against the public task contract."""
1105
+
1106
+ expected_observations = {
1107
+ expectation.path: strict_json_loads(expectation.expected_json)
1108
+ for expectation in acceptance_contract.expectations
1109
+ }
1110
+ observed_observations = observe_authoring_flow(
1111
+ executable_flow,
1112
+ variable_pointer_patterns=acceptance_contract.variable_pointer_patterns,
1113
+ )
1114
+ diagnostic_paths = sorted(set(expected_observations) | set(observed_observations))
1115
+ return _deterministic_diagnostics(
1116
+ FlowDiagnostic(
1117
+ code=(
1118
+ "AUTHORING_ACCEPTANCE_UNEXPECTED"
1119
+ if diagnostic_path not in expected_observations
1120
+ else (
1121
+ "AUTHORING_ACCEPTANCE_MISSING"
1122
+ if diagnostic_path not in observed_observations
1123
+ else "AUTHORING_ACCEPTANCE_MISMATCH"
1124
+ )
1125
+ ),
1126
+ severity="error",
1127
+ path=diagnostic_path,
1128
+ message=(
1129
+ f"Unexpected semantic observation {_compact_json(observed_observations[diagnostic_path])}."
1130
+ if diagnostic_path not in expected_observations
1131
+ else (
1132
+ f"Missing required semantic observation {_compact_json(expected_observations[diagnostic_path])}."
1133
+ if diagnostic_path not in observed_observations
1134
+ else (
1135
+ "Semantic observation must equal "
1136
+ f"{_compact_json(expected_observations[diagnostic_path])}, but observed "
1137
+ f"{_compact_json(observed_observations[diagnostic_path])}."
1138
+ )
1139
+ )
1140
+ ),
1141
+ suggested_fix=(
1142
+ f"Remove the behavior at {diagnostic_path!r}."
1143
+ if diagnostic_path not in expected_observations
1144
+ else f"Set the semantic observation at {diagnostic_path!r} to the public expected value."
1145
+ ),
1146
+ )
1147
+ for diagnostic_path in diagnostic_paths
1148
+ if (
1149
+ diagnostic_path not in expected_observations
1150
+ or diagnostic_path not in observed_observations
1151
+ or expected_observations[diagnostic_path] != observed_observations[diagnostic_path]
1152
+ )
1153
+ if diagnostic_path not in covered_paths
1154
+ )
1155
+
1156
+
1157
+ def _evaluate_source(
1158
+ authored_source: str,
1159
+ source_adapter: AuthoringSourceAdapter,
1160
+ acceptance_contract: AuthoringAcceptanceContract,
1161
+ correction_round: int,
1162
+ flow_profile: FlowProfile,
1163
+ ) -> AuthoringAttempt:
1164
+ encoded_source = authored_source.encode("utf-8")
1165
+ try:
1166
+ source_adaptation = source_adapter.adapt(authored_source)
1167
+ if source_adaptation.format_identifier != source_adapter.format_identifier:
1168
+ raise ValueError(
1169
+ "source adapter returned a format identifier that differs from its declaration"
1170
+ )
1171
+ if source_adaptation.canonical_authored_source is not None:
1172
+ canonical_adaptation = source_adapter.adapt(source_adaptation.canonical_authored_source)
1173
+ source_projection = (
1174
+ source_adaptation.format_identifier,
1175
+ source_adaptation.authored_document_json,
1176
+ source_adaptation.executable_flow_json,
1177
+ source_adaptation.canonical_authored_source,
1178
+ )
1179
+ canonical_projection = (
1180
+ canonical_adaptation.format_identifier,
1181
+ canonical_adaptation.authored_document_json,
1182
+ canonical_adaptation.executable_flow_json,
1183
+ canonical_adaptation.canonical_authored_source,
1184
+ )
1185
+ if canonical_projection != source_projection:
1186
+ raise ValueError(
1187
+ "source adapter canonical syntax must round-trip to the same authored "
1188
+ "and executable projections"
1189
+ )
1190
+ except Exception as adaptation_error:
1191
+ adaptation_details = str(adaptation_error).strip() or "no details"
1192
+ return AuthoringAttempt(
1193
+ correction_round=correction_round,
1194
+ source_sha256=hashlib.sha256(encoded_source).hexdigest(),
1195
+ raw_source_bytes=len(encoded_source),
1196
+ canonical_source_bytes=None,
1197
+ token_proxy_units=None,
1198
+ compilation="skipped",
1199
+ diagnostics=(
1200
+ FlowDiagnostic(
1201
+ code="AUTHORING_SOURCE_ADAPTATION_FAILED",
1202
+ severity="error",
1203
+ path="",
1204
+ message=(
1205
+ f"The {source_adapter.format_identifier!r} source adapter failed with "
1206
+ f"{type(adaptation_error).__name__}: {adaptation_details}."
1207
+ ),
1208
+ ),
1209
+ ),
1210
+ succeeded=False,
1211
+ )
1212
+
1213
+ flow_diagnostics = list(source_adaptation.diagnostics)
1214
+ canonical_source_bytes: int | None = None
1215
+ token_proxy_units: int | None = None
1216
+ if source_adaptation.authored_document_json is not None:
1217
+ authored_document = strict_json_loads(source_adaptation.authored_document_json)
1218
+ canonical_source_bytes = len(
1219
+ cast(str, source_adaptation.canonical_authored_source).encode("utf-8")
1220
+ )
1221
+ token_proxy_units = (
1222
+ canonical_source_bytes + TOKEN_PROXY_BYTES_PER_UNIT - 1
1223
+ ) // TOKEN_PROXY_BYTES_PER_UNIT
1224
+ flow_diagnostics.extend(
1225
+ _forbidden_diagnostics(
1226
+ authored_document,
1227
+ acceptance_contract.forbidden_constructs,
1228
+ )
1229
+ )
1230
+
1231
+ compilation: CompilationStatus = "skipped"
1232
+ executable_flow_available = source_adaptation.executable_flow_json is not None
1233
+ if executable_flow_available:
1234
+ executable_flow = strict_json_loads(cast(str, source_adaptation.executable_flow_json))
1235
+ required_diagnostics = _required_construct_diagnostics(
1236
+ executable_flow,
1237
+ acceptance_contract.required_constructs,
1238
+ )
1239
+ flow_diagnostics.extend(required_diagnostics)
1240
+ flow_check_report = check_flow(executable_flow, profile=flow_profile)
1241
+ compilation = flow_check_report.compilation
1242
+ flow_diagnostics.extend(flow_check_report.diagnostics)
1243
+ if compilation == "succeeded":
1244
+ flow_diagnostics.extend(
1245
+ acceptance_diagnostics(
1246
+ executable_flow,
1247
+ acceptance_contract,
1248
+ covered_paths=frozenset(diagnostic.path for diagnostic in required_diagnostics),
1249
+ )
1250
+ )
1251
+
1252
+ deterministic_diagnostics = _deterministic_diagnostics(flow_diagnostics)
1253
+ succeeded = (
1254
+ executable_flow_available
1255
+ and compilation == "succeeded"
1256
+ and not any(
1257
+ flow_diagnostic.severity == "error" for flow_diagnostic in deterministic_diagnostics
1258
+ )
1259
+ )
1260
+ return AuthoringAttempt(
1261
+ correction_round=correction_round,
1262
+ source_sha256=hashlib.sha256(encoded_source).hexdigest(),
1263
+ raw_source_bytes=len(encoded_source),
1264
+ canonical_source_bytes=canonical_source_bytes,
1265
+ token_proxy_units=token_proxy_units,
1266
+ compilation=compilation,
1267
+ diagnostics=deterministic_diagnostics,
1268
+ succeeded=succeeded,
1269
+ )
1270
+
1271
+
1272
+ def _run_benchmark_case(
1273
+ benchmark_case: AuthoringBenchmarkCase,
1274
+ author: AuthoringAuthor,
1275
+ source_adapter: AuthoringSourceAdapter,
1276
+ benchmark_limits: AuthoringBenchmarkLimits,
1277
+ flow_profile: FlowProfile,
1278
+ ) -> AuthoringBenchmarkResult:
1279
+ authoring_attempts: list[AuthoringAttempt] = []
1280
+ previous_source: str | None = None
1281
+ previous_diagnostics: tuple[FlowDiagnostic, ...] = ()
1282
+ authoring_task = benchmark_case.authoring_task()
1283
+ for correction_round in range(benchmark_limits.max_correction_rounds + 1):
1284
+ authoring_request = AuthoringRequest(
1285
+ task=authoring_task,
1286
+ format_identifier=source_adapter.format_identifier,
1287
+ profile_identifier=flow_profile.identifier,
1288
+ profile_contract_json=flow_profile.canonical_json(),
1289
+ correction_round=correction_round,
1290
+ previous_source=previous_source,
1291
+ previous_diagnostics=previous_diagnostics,
1292
+ )
1293
+ authored_response = author(authoring_request)
1294
+ if not isinstance(authored_response, AuthoredSource):
1295
+ raise TypeError("the injected author must return AuthoredSource")
1296
+ authoring_attempt = _evaluate_source(
1297
+ authored_response.source,
1298
+ source_adapter,
1299
+ authoring_task.acceptance,
1300
+ correction_round,
1301
+ flow_profile,
1302
+ )
1303
+ authoring_attempts.append(authoring_attempt)
1304
+ if authoring_attempt.succeeded:
1305
+ break
1306
+ previous_source = authored_response.source
1307
+ previous_diagnostics = authoring_attempt.diagnostics
1308
+
1309
+ return AuthoringBenchmarkResult(
1310
+ case_identifier=benchmark_case.identifier,
1311
+ feature_tags=benchmark_case.feature_tags,
1312
+ format_identifier=source_adapter.format_identifier,
1313
+ attempts=tuple(authoring_attempts),
1314
+ )
1315
+
1316
+
1317
+ def run_authoring_benchmark(
1318
+ benchmark_identifier: str,
1319
+ benchmark_cases: Sequence[AuthoringBenchmarkCase],
1320
+ author: AuthoringAuthor,
1321
+ *,
1322
+ source_adapter: AuthoringSourceAdapter | None = None,
1323
+ limits: AuthoringBenchmarkLimits | None = None,
1324
+ profile: FlowProfile | None = None,
1325
+ ) -> AuthoringBenchmarkReport:
1326
+ """Run all cases through the injected author, adapter, and exact checker."""
1327
+
1328
+ _require_identifier(benchmark_identifier, "benchmark identifier")
1329
+ resolved_source_adapter = source_adapter or FlowSpec2JsonAdapter()
1330
+ resolved_limits = limits or AuthoringBenchmarkLimits()
1331
+ resolved_profile = profile or reference_profile()
1332
+ sorted_cases = tuple(
1333
+ sorted(benchmark_cases, key=lambda benchmark_case: benchmark_case.identifier)
1334
+ )
1335
+ case_identifiers = tuple(benchmark_case.identifier for benchmark_case in sorted_cases)
1336
+ if len(set(case_identifiers)) != len(case_identifiers):
1337
+ raise ValueError("benchmark case identifiers must be unique")
1338
+
1339
+ return AuthoringBenchmarkReport(
1340
+ benchmark_identifier=benchmark_identifier,
1341
+ format_identifier=resolved_source_adapter.format_identifier,
1342
+ profile_identifier=resolved_profile.identifier,
1343
+ case_results=tuple(
1344
+ _run_benchmark_case(
1345
+ benchmark_case,
1346
+ author,
1347
+ resolved_source_adapter,
1348
+ resolved_limits,
1349
+ resolved_profile,
1350
+ )
1351
+ for benchmark_case in sorted_cases
1352
+ ),
1353
+ )
1354
+
1355
+
1356
+ def replay_authoring_benchmark(
1357
+ benchmark_identifier: str,
1358
+ benchmark_cases: Sequence[AuthoringBenchmarkCase],
1359
+ captured_sources: Mapping[str, Sequence[str]],
1360
+ *,
1361
+ source_adapter: AuthoringSourceAdapter | None = None,
1362
+ profile: FlowProfile | None = None,
1363
+ ) -> AuthoringBenchmarkReport:
1364
+ """Re-evaluate an exact closed capture set without invoking an author."""
1365
+
1366
+ _require_identifier(benchmark_identifier, "benchmark identifier")
1367
+ resolved_source_adapter = source_adapter or FlowSpec2JsonAdapter()
1368
+ resolved_profile = profile or reference_profile()
1369
+ sorted_cases = tuple(
1370
+ sorted(benchmark_cases, key=lambda benchmark_case: benchmark_case.identifier)
1371
+ )
1372
+ case_identifiers = tuple(benchmark_case.identifier for benchmark_case in sorted_cases)
1373
+ if len(set(case_identifiers)) != len(case_identifiers):
1374
+ raise ValueError("benchmark case identifiers must be unique")
1375
+ if set(captured_sources) != set(case_identifiers):
1376
+ raise ValueError("captured source cases do not match the benchmark corpus")
1377
+
1378
+ case_results: list[AuthoringBenchmarkResult] = []
1379
+ for benchmark_case in sorted_cases:
1380
+ case_sources = tuple(captured_sources[benchmark_case.identifier])
1381
+ if not case_sources:
1382
+ raise ValueError(
1383
+ f"captured source case {benchmark_case.identifier!r} requires an attempt"
1384
+ )
1385
+ acceptance_contract = benchmark_case.authoring_task().acceptance
1386
+ case_results.append(
1387
+ AuthoringBenchmarkResult(
1388
+ case_identifier=benchmark_case.identifier,
1389
+ feature_tags=benchmark_case.feature_tags,
1390
+ format_identifier=resolved_source_adapter.format_identifier,
1391
+ attempts=tuple(
1392
+ _evaluate_source(
1393
+ authored_source,
1394
+ resolved_source_adapter,
1395
+ acceptance_contract,
1396
+ correction_round,
1397
+ resolved_profile,
1398
+ )
1399
+ for correction_round, authored_source in enumerate(case_sources)
1400
+ ),
1401
+ )
1402
+ )
1403
+
1404
+ return AuthoringBenchmarkReport(
1405
+ benchmark_identifier=benchmark_identifier,
1406
+ format_identifier=resolved_source_adapter.format_identifier,
1407
+ profile_identifier=resolved_profile.identifier,
1408
+ case_results=tuple(case_results),
1409
+ )