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.
- flowspec2/__init__.py +105 -0
- flowspec2/authoring/__init__.py +275 -0
- flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
- flowspec2/authoring/authoring-evidence.schema.json +455 -0
- flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
- flowspec2/authoring/benchmark.py +1409 -0
- flowspec2/authoring/corpus/await_correction.case.json +220 -0
- flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
- flowspec2/authoring/corpus/gated_derive.case.json +100 -0
- flowspec2/authoring/corpus/linear.case.json +55 -0
- flowspec2/authoring/corpus/manifest.json +31 -0
- flowspec2/authoring/corpus/subflow.case.json +66 -0
- flowspec2/authoring/corpus/terminal.case.json +94 -0
- flowspec2/authoring/corpus.py +307 -0
- flowspec2/authoring/ctk.py +836 -0
- flowspec2/authoring/detached_signature.py +120 -0
- flowspec2/authoring/evidence.py +311 -0
- flowspec2/authoring/evidence_signature.py +187 -0
- flowspec2/authoring/evidence_verification.py +229 -0
- flowspec2/authoring/gemini.py +215 -0
- flowspec2/authoring/operational-corpus.json +61 -0
- flowspec2/authoring/operational.py +956 -0
- flowspec2/authoring/operational_providers.py +167 -0
- flowspec2/authoring/presentation_review.py +1013 -0
- flowspec2/authoring/presentation_review_signature.py +305 -0
- flowspec2/authoring/projection.py +299 -0
- flowspec2/authoring/provider_prompt.py +83 -0
- flowspec2/backends/__init__.py +82 -0
- flowspec2/backends/http.py +189 -0
- flowspec2/checker.py +238 -0
- flowspec2/cli.py +789 -0
- flowspec2/cli_parser.py +345 -0
- flowspec2/clock.py +23 -0
- flowspec2/compat/__init__.py +47 -0
- flowspec2/compat/models.py +82 -0
- flowspec2/compat/open_workflow.py +616 -0
- flowspec2/compat/rasa.py +19 -0
- flowspec2/compat/rasa_export.py +876 -0
- flowspec2/compat/rasa_import.py +992 -0
- flowspec2/compat/rasa_shared.py +270 -0
- flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
- flowspec2/compat/tool_profiles.py +149 -0
- flowspec2/compat/yaml.py +147 -0
- flowspec2/compiler.py +957 -0
- flowspec2/compiler_contracts.py +17 -0
- flowspec2/compiler_resume_contracts.py +594 -0
- flowspec2/compiler_schema_relations.py +1830 -0
- flowspec2/compiler_tool_contracts.py +245 -0
- flowspec2/compiler_value_contracts.py +447 -0
- flowspec2/derive.py +32 -0
- flowspec2/diagnostics.py +94 -0
- flowspec2/domains.py +489 -0
- flowspec2/experimental/__init__.py +57 -0
- flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
- flowspec2/experimental/v3-preview-loss-policy.json +161 -0
- flowspec2/experimental/v3_lowering.py +928 -0
- flowspec2/experimental/v3_preview.py +2460 -0
- flowspec2/flowspec-2.schema.json +635 -0
- flowspec2/interactive.py +300 -0
- flowspec2/ir.py +1459 -0
- flowspec2/json_codec.py +120 -0
- flowspec2/llm.py +366 -0
- flowspec2/models.py +121 -0
- flowspec2/nodes.py +1537 -0
- flowspec2/observability.py +151 -0
- flowspec2/predicates.py +89 -0
- flowspec2/profiles.py +118 -0
- flowspec2/py.typed +0 -0
- flowspec2/runtime.py +1059 -0
- flowspec2/schema.py +54 -0
- flowspec2/schema_contracts.py +203 -0
- flowspec2/semantic_derive_contracts.py +530 -0
- flowspec2/semantic_path_contracts.py +528 -0
- flowspec2/semantic_predicate_contracts.py +355 -0
- flowspec2/semantic_schema_contracts.py +137 -0
- flowspec2/semantic_source_contracts.py +21 -0
- flowspec2/semantic_state_contracts.py +450 -0
- flowspec2/semantic_support.py +40 -0
- flowspec2/semantics.py +410 -0
- flowspec2/state_migration.py +1034 -0
- flowspec2/subflows/__init__.py +1117 -0
- flowspec2/subflows/address.py +328 -0
- flowspec2/subflows/identification.py +1031 -0
- flowspec2/tools.py +1154 -0
- flowspec2-1.0.0.dist-info/METADATA +482 -0
- flowspec2-1.0.0.dist-info/RECORD +92 -0
- flowspec2-1.0.0.dist-info/WHEEL +4 -0
- flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|