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,530 @@
|
|
|
1
|
+
"""Derived-value semantic contracts and execution-order checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from itertools import product
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from .derive import decode_derive_key, encode_derive_component, encode_derive_key
|
|
10
|
+
from .diagnostics import DiagnosticLocation, FlowDiagnostic
|
|
11
|
+
from .domains import make_slot_model
|
|
12
|
+
from .semantic_path_contracts import step_identifier
|
|
13
|
+
from .semantic_schema_contracts import slot_model_accepts
|
|
14
|
+
from .semantic_support import json_pointer, semantic_diagnostic
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def derive_contracts(
|
|
18
|
+
document: dict[str, Any],
|
|
19
|
+
identifiers: dict[str, str],
|
|
20
|
+
*,
|
|
21
|
+
external_slots: frozenset[str] | None,
|
|
22
|
+
external_steps: frozenset[str],
|
|
23
|
+
) -> list[FlowDiagnostic]:
|
|
24
|
+
diagnostics: list[FlowDiagnostic] = []
|
|
25
|
+
declared_slots = set(document.get("slots", {}))
|
|
26
|
+
native_collected_slots = {
|
|
27
|
+
cast(str, path_step.get("slot", path_step.get("confirm")))
|
|
28
|
+
for path_step in document["path"]
|
|
29
|
+
if "slot" in path_step or "confirm" in path_step
|
|
30
|
+
}
|
|
31
|
+
slots = set(declared_slots)
|
|
32
|
+
if external_slots is not None:
|
|
33
|
+
slots.update(external_slots)
|
|
34
|
+
definitions: dict[str, int] = {}
|
|
35
|
+
for derive_index, derive_definition in enumerate(document.get("derive", [])):
|
|
36
|
+
target = derive_definition["writes"]
|
|
37
|
+
if (first_index := definitions.get(target)) is not None:
|
|
38
|
+
diagnostics.append(
|
|
39
|
+
semantic_diagnostic(
|
|
40
|
+
"FLOWSPEC_SEMANTIC_DUPLICATE_DERIVE_TARGET",
|
|
41
|
+
json_pointer("derive", derive_index, "writes"),
|
|
42
|
+
f"derive target {target!r} has more than one definition",
|
|
43
|
+
related=(
|
|
44
|
+
DiagnosticLocation(path=json_pointer("derive", first_index, "writes")),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
definitions[target] = derive_index
|
|
50
|
+
if target in native_collected_slots:
|
|
51
|
+
diagnostics.append(
|
|
52
|
+
semantic_diagnostic(
|
|
53
|
+
"FLOWSPEC_SEMANTIC_DERIVE_OVERWRITES_DECLARED_SLOT",
|
|
54
|
+
json_pointer("derive", derive_index, "writes"),
|
|
55
|
+
f"derive target {target!r} collides with a collected slot",
|
|
56
|
+
suggested_fix="Use a distinct derived state key.",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
if external_slots is not None and target in external_slots:
|
|
60
|
+
diagnostics.append(
|
|
61
|
+
semantic_diagnostic(
|
|
62
|
+
"FLOWSPEC_SEMANTIC_DERIVE_OVERWRITES_PROFILE_SLOT",
|
|
63
|
+
json_pointer("derive", derive_index, "writes"),
|
|
64
|
+
f"derive target {target!r} is owned by a profile subflow",
|
|
65
|
+
suggested_fix="Use a distinct derived state key.",
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
derive_targets = set(definitions)
|
|
70
|
+
explicit_derive_nodes = {
|
|
71
|
+
cast(str, path_step["derive"]): cast(
|
|
72
|
+
str,
|
|
73
|
+
path_step.get("step", f"derive_{path_step['derive']}"),
|
|
74
|
+
)
|
|
75
|
+
for path_step in document["path"]
|
|
76
|
+
if "derive" in path_step
|
|
77
|
+
}
|
|
78
|
+
derive_node_identifiers = {
|
|
79
|
+
target: explicit_derive_nodes.get(target, f"derive_{target}") for target in derive_targets
|
|
80
|
+
}
|
|
81
|
+
slot_definitions = cast(dict[str, dict[str, Any]], document.get("slots", {}))
|
|
82
|
+
domain_definitions = cast(dict[str, dict[str, Any]], document.get("domains", {}))
|
|
83
|
+
gatedstep_identifiers = set(
|
|
84
|
+
cast(dict[str, Any], cast(dict[str, Any], document.get("overrides", {})).get("gates", {}))
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def source_can_be_absent(source_slot: str, slot_definition: dict[str, Any]) -> bool:
|
|
88
|
+
source_path_step: dict[str, Any] = next(
|
|
89
|
+
(
|
|
90
|
+
path_step
|
|
91
|
+
for path_step in document["path"]
|
|
92
|
+
if path_step.get("slot", path_step.get("confirm")) == source_slot
|
|
93
|
+
),
|
|
94
|
+
{},
|
|
95
|
+
)
|
|
96
|
+
return bool(
|
|
97
|
+
not slot_definition.get("required", True)
|
|
98
|
+
or slot_definition.get("nullable", False)
|
|
99
|
+
or slot_definition.get("on_exhaust") == "skip"
|
|
100
|
+
or "ask_when" in source_path_step
|
|
101
|
+
or "skip_when" in source_path_step
|
|
102
|
+
or source_path_step.get("step") in gatedstep_identifiers
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def closed_source_values(source_slot: str) -> tuple[Any, ...] | None:
|
|
106
|
+
slot_definition = slot_definitions.get(source_slot)
|
|
107
|
+
if slot_definition is None:
|
|
108
|
+
return None
|
|
109
|
+
domain_definition = domain_definitions.get(cast(str, slot_definition["domain"]))
|
|
110
|
+
if domain_definition is None:
|
|
111
|
+
return None
|
|
112
|
+
domain_kind = domain_definition.get("type", "categorical")
|
|
113
|
+
source_values: list[Any]
|
|
114
|
+
if domain_kind == "categorical":
|
|
115
|
+
source_values = list(domain_definition.get("values", []))
|
|
116
|
+
elif domain_kind == "bool":
|
|
117
|
+
source_values = [True, False]
|
|
118
|
+
else:
|
|
119
|
+
return None
|
|
120
|
+
if source_can_be_absent(source_slot, slot_definition) and None not in source_values:
|
|
121
|
+
source_values.append(None)
|
|
122
|
+
return tuple(source_values)
|
|
123
|
+
|
|
124
|
+
def closed_source_tokens(source_slot: str) -> tuple[str, ...] | None:
|
|
125
|
+
source_values = closed_source_values(source_slot)
|
|
126
|
+
return (
|
|
127
|
+
tuple(encode_derive_component(source_value) for source_value in source_values)
|
|
128
|
+
if source_values is not None
|
|
129
|
+
else None
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def open_source_is_compatible_with_target(
|
|
133
|
+
source_slot: str,
|
|
134
|
+
target_slot: str,
|
|
135
|
+
) -> bool | None:
|
|
136
|
+
source_slot_definition = slot_definitions.get(source_slot)
|
|
137
|
+
target_slot_definition = slot_definitions.get(target_slot)
|
|
138
|
+
if source_slot_definition is None or target_slot_definition is None:
|
|
139
|
+
return None
|
|
140
|
+
if source_can_be_absent(source_slot, source_slot_definition) and not bool(
|
|
141
|
+
target_slot_definition.get("nullable", False)
|
|
142
|
+
):
|
|
143
|
+
return False
|
|
144
|
+
source_domain = domain_definitions.get(cast(str, source_slot_definition["domain"]))
|
|
145
|
+
target_domain = domain_definitions.get(cast(str, target_slot_definition["domain"]))
|
|
146
|
+
if source_domain is None or target_domain is None:
|
|
147
|
+
return None
|
|
148
|
+
source_kind = cast(str, source_domain.get("type", "categorical"))
|
|
149
|
+
target_kind = cast(str, target_domain.get("type", "categorical"))
|
|
150
|
+
if source_kind in {"integer", "number"}:
|
|
151
|
+
if target_kind not in {"integer", "number"}:
|
|
152
|
+
return False
|
|
153
|
+
if source_kind == "number" and target_kind == "integer":
|
|
154
|
+
return False
|
|
155
|
+
source_minimum = source_domain.get("minimum")
|
|
156
|
+
target_minimum = target_domain.get("minimum")
|
|
157
|
+
source_maximum = source_domain.get("maximum")
|
|
158
|
+
target_maximum = target_domain.get("maximum")
|
|
159
|
+
if target_minimum is not None and (
|
|
160
|
+
source_minimum is None or source_minimum < target_minimum
|
|
161
|
+
):
|
|
162
|
+
return False
|
|
163
|
+
if target_maximum is not None and (
|
|
164
|
+
source_maximum is None or source_maximum > target_maximum
|
|
165
|
+
):
|
|
166
|
+
return False
|
|
167
|
+
return True
|
|
168
|
+
string_kinds = {"brazilian_tax_id", "email", "name", "free_text"}
|
|
169
|
+
if source_kind not in string_kinds or target_kind not in string_kinds:
|
|
170
|
+
return False if source_kind != target_kind else None
|
|
171
|
+
if target_kind == "free_text":
|
|
172
|
+
if target_domain.get("optional"):
|
|
173
|
+
return True
|
|
174
|
+
return source_kind != "free_text" or not source_domain.get("optional")
|
|
175
|
+
if source_kind == target_kind:
|
|
176
|
+
return True
|
|
177
|
+
if source_kind == "brazilian_tax_id" and target_kind == "name":
|
|
178
|
+
return True
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
for derive_index, derive_definition in enumerate(document.get("derive", [])):
|
|
182
|
+
derive_target = cast(str, derive_definition["writes"])
|
|
183
|
+
if derive_target not in explicit_derive_nodes and "after" not in derive_definition:
|
|
184
|
+
diagnostics.append(
|
|
185
|
+
semantic_diagnostic(
|
|
186
|
+
"FLOWSPEC_SEMANTIC_UNPLACED_DERIVE",
|
|
187
|
+
json_pointer("derive", derive_index),
|
|
188
|
+
"derive must be placed in path or declare an after anchor",
|
|
189
|
+
suggested_fix="Add a derive path step or an explicit after step id.",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
for source_index, source_slot in enumerate(derive_definition["from"]):
|
|
193
|
+
if (
|
|
194
|
+
external_slots is not None
|
|
195
|
+
and source_slot not in slots
|
|
196
|
+
and source_slot not in derive_targets
|
|
197
|
+
):
|
|
198
|
+
diagnostics.append(
|
|
199
|
+
semantic_diagnostic(
|
|
200
|
+
"FLOWSPEC_SEMANTIC_UNKNOWN_DERIVE_SOURCE",
|
|
201
|
+
json_pointer("derive", derive_index, "from", source_index),
|
|
202
|
+
f"derive source {source_slot!r} is not declared or exposed",
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
if len(derive_definition["from"]) > 1:
|
|
206
|
+
slot_definition = slot_definitions.get(source_slot)
|
|
207
|
+
domain_definition = (
|
|
208
|
+
domain_definitions.get(slot_definition["domain"])
|
|
209
|
+
if slot_definition is not None
|
|
210
|
+
else None
|
|
211
|
+
)
|
|
212
|
+
domain_kind = (
|
|
213
|
+
domain_definition.get("type", "categorical")
|
|
214
|
+
if domain_definition is not None
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
217
|
+
categorical_values = (
|
|
218
|
+
domain_definition.get("values", [])
|
|
219
|
+
if domain_kind == "categorical" and domain_definition is not None
|
|
220
|
+
else []
|
|
221
|
+
)
|
|
222
|
+
if domain_kind not in {"categorical", "bool"} or any(
|
|
223
|
+
isinstance(domain_value, str) and "|" in domain_value
|
|
224
|
+
for domain_value in categorical_values
|
|
225
|
+
):
|
|
226
|
+
diagnostics.append(
|
|
227
|
+
semantic_diagnostic(
|
|
228
|
+
"FLOWSPEC_SEMANTIC_NON_INJECTIVE_DERIVE_KEY",
|
|
229
|
+
json_pointer("derive", derive_index, "from", source_index),
|
|
230
|
+
"multi-source lookup keys require closed categorical or bool "
|
|
231
|
+
"sources whose values do not contain '|'",
|
|
232
|
+
suggested_fix=(
|
|
233
|
+
"Use closed sources or compose single-source derives so the "
|
|
234
|
+
"lookup key is unambiguous."
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
if (
|
|
239
|
+
(anchor := derive_definition.get("after")) is not None
|
|
240
|
+
and anchor not in identifiers
|
|
241
|
+
and anchor not in external_steps
|
|
242
|
+
and anchor not in derive_node_identifiers.values()
|
|
243
|
+
):
|
|
244
|
+
diagnostics.append(
|
|
245
|
+
semantic_diagnostic(
|
|
246
|
+
"FLOWSPEC_SEMANTIC_UNKNOWN_DERIVE_ANCHOR",
|
|
247
|
+
json_pointer("derive", derive_index, "after"),
|
|
248
|
+
f"derive anchor {anchor!r} is not a path or profile step",
|
|
249
|
+
suggested_fix="Use a stable executable step id.",
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
if isinstance(
|
|
253
|
+
default_value := derive_definition.get("default"), str
|
|
254
|
+
) and default_value.startswith("$from["):
|
|
255
|
+
default_match = re.fullmatch(r"\$from\[(\d+)\]", default_value)
|
|
256
|
+
if default_match is None or int(default_match.group(1)) >= len(
|
|
257
|
+
derive_definition["from"]
|
|
258
|
+
):
|
|
259
|
+
diagnostics.append(
|
|
260
|
+
semantic_diagnostic(
|
|
261
|
+
"FLOWSPEC_SEMANTIC_INVALID_DERIVE_DEFAULT_REFERENCE",
|
|
262
|
+
json_pointer("derive", derive_index, "default"),
|
|
263
|
+
f"derive default {default_value!r} does not reference an existing source",
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
expected_arity = len(derive_definition["from"])
|
|
267
|
+
for lookup_key in derive_definition["lookup"]:
|
|
268
|
+
lookup_components = decode_derive_key(lookup_key, expected_arity)
|
|
269
|
+
if len(lookup_components) != expected_arity:
|
|
270
|
+
diagnostics.append(
|
|
271
|
+
semantic_diagnostic(
|
|
272
|
+
"FLOWSPEC_SEMANTIC_DERIVE_LOOKUP_ARITY",
|
|
273
|
+
json_pointer("derive", derive_index, "lookup", lookup_key),
|
|
274
|
+
f"lookup key has a different arity than the {expected_arity} derive sources",
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
continue
|
|
278
|
+
for source_index, (source_slot, lookup_component) in enumerate(
|
|
279
|
+
zip(derive_definition["from"], lookup_components, strict=True)
|
|
280
|
+
):
|
|
281
|
+
accepted_tokens = closed_source_tokens(source_slot)
|
|
282
|
+
if accepted_tokens is not None and lookup_component not in accepted_tokens:
|
|
283
|
+
diagnostics.append(
|
|
284
|
+
semantic_diagnostic(
|
|
285
|
+
"FLOWSPEC_SEMANTIC_DERIVE_LOOKUP_VALUE_OUT_OF_DOMAIN",
|
|
286
|
+
json_pointer("derive", derive_index, "lookup", lookup_key),
|
|
287
|
+
f"lookup component {lookup_component!r} is not reachable from "
|
|
288
|
+
f"source {source_slot!r}",
|
|
289
|
+
related=(
|
|
290
|
+
DiagnosticLocation(
|
|
291
|
+
path=json_pointer("derive", derive_index, "from", source_index),
|
|
292
|
+
message="Source slot for this lookup component.",
|
|
293
|
+
),
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
target_slot_definition = slot_definitions.get(derive_target)
|
|
299
|
+
if target_slot_definition is not None:
|
|
300
|
+
target_domain_name = cast(str, target_slot_definition["domain"])
|
|
301
|
+
if target_domain_name in domain_definitions:
|
|
302
|
+
target_model = make_slot_model(
|
|
303
|
+
derive_target,
|
|
304
|
+
target_domain_name,
|
|
305
|
+
domain_definitions,
|
|
306
|
+
nullable=bool(target_slot_definition.get("nullable", False)),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
authored_outputs = [
|
|
310
|
+
*(
|
|
311
|
+
(
|
|
312
|
+
json_pointer("derive", derive_index, "lookup", lookup_key),
|
|
313
|
+
lookup_value,
|
|
314
|
+
)
|
|
315
|
+
for lookup_key, lookup_value in derive_definition["lookup"].items()
|
|
316
|
+
),
|
|
317
|
+
*(
|
|
318
|
+
[
|
|
319
|
+
(
|
|
320
|
+
json_pointer("derive", derive_index, "default"),
|
|
321
|
+
derive_definition["default"],
|
|
322
|
+
)
|
|
323
|
+
]
|
|
324
|
+
if "default" in derive_definition
|
|
325
|
+
and not str(derive_definition["default"]).startswith("$from[")
|
|
326
|
+
else []
|
|
327
|
+
),
|
|
328
|
+
]
|
|
329
|
+
for output_path, output_value in authored_outputs:
|
|
330
|
+
try:
|
|
331
|
+
target_model.model_validate({derive_target: output_value})
|
|
332
|
+
except Exception:
|
|
333
|
+
diagnostics.append(
|
|
334
|
+
semantic_diagnostic(
|
|
335
|
+
"FLOWSPEC_SEMANTIC_DERIVE_OUTPUT_OUT_OF_DOMAIN",
|
|
336
|
+
output_path,
|
|
337
|
+
f"derive output {output_value!r} is not accepted by target "
|
|
338
|
+
f"slot {derive_target!r}",
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
if isinstance(default_value, str) and (
|
|
343
|
+
default_match := re.fullmatch(r"\$from\[(\d+)\]", default_value)
|
|
344
|
+
):
|
|
345
|
+
source_index = int(default_match.group(1))
|
|
346
|
+
if source_index < len(derive_definition["from"]):
|
|
347
|
+
source_slot = cast(str, derive_definition["from"][source_index])
|
|
348
|
+
source_value_sets = [
|
|
349
|
+
closed_source_values(candidate_source)
|
|
350
|
+
for candidate_source in derive_definition["from"]
|
|
351
|
+
]
|
|
352
|
+
fallback_values: tuple[Any, ...] | None = None
|
|
353
|
+
if all(source_values is not None for source_values in source_value_sets):
|
|
354
|
+
fallback_values = tuple(
|
|
355
|
+
source_values[source_index]
|
|
356
|
+
for source_values in product(
|
|
357
|
+
*cast(list[tuple[Any, ...]], source_value_sets)
|
|
358
|
+
)
|
|
359
|
+
if encode_derive_key(source_values)
|
|
360
|
+
not in derive_definition["lookup"]
|
|
361
|
+
)
|
|
362
|
+
fallback_is_compatible = (
|
|
363
|
+
all(
|
|
364
|
+
slot_model_accepts(
|
|
365
|
+
target_model,
|
|
366
|
+
derive_target,
|
|
367
|
+
fallback_value,
|
|
368
|
+
)
|
|
369
|
+
for fallback_value in fallback_values
|
|
370
|
+
)
|
|
371
|
+
if fallback_values is not None
|
|
372
|
+
else open_source_is_compatible_with_target(
|
|
373
|
+
source_slot,
|
|
374
|
+
derive_target,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
if fallback_is_compatible is False:
|
|
378
|
+
diagnostics.append(
|
|
379
|
+
semantic_diagnostic(
|
|
380
|
+
"FLOWSPEC_SEMANTIC_DERIVE_DEFAULT_SOURCE_INCOMPATIBLE",
|
|
381
|
+
json_pointer("derive", derive_index, "default"),
|
|
382
|
+
f"derive fallback source {source_slot!r} is not accepted by "
|
|
383
|
+
f"target slot {derive_target!r}",
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if target_slot_definition.get("required", True) and "default" not in derive_definition:
|
|
388
|
+
source_token_sets = [
|
|
389
|
+
closed_source_tokens(source_slot) for source_slot in derive_definition["from"]
|
|
390
|
+
]
|
|
391
|
+
lookup_keys = set(derive_definition["lookup"])
|
|
392
|
+
is_total = all(token_set is not None for token_set in source_token_sets) and all(
|
|
393
|
+
encode_derive_key(source_tokens) in lookup_keys
|
|
394
|
+
for source_tokens in product(*cast(list[tuple[str, ...]], source_token_sets))
|
|
395
|
+
)
|
|
396
|
+
if not is_total:
|
|
397
|
+
diagnostics.append(
|
|
398
|
+
semantic_diagnostic(
|
|
399
|
+
"FLOWSPEC_SEMANTIC_REQUIRED_DERIVE_NOT_TOTAL",
|
|
400
|
+
json_pointer("derive", derive_index),
|
|
401
|
+
f"derive for required slot {derive_target!r} does not cover every "
|
|
402
|
+
"reachable source value and has no default",
|
|
403
|
+
suggested_fix="Add a domain-valid default or complete the lookup.",
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
dependency_graph = {
|
|
408
|
+
target: [
|
|
409
|
+
source
|
|
410
|
+
for source in document["derive"][derive_index]["from"]
|
|
411
|
+
if source in derive_targets
|
|
412
|
+
]
|
|
413
|
+
for target, derive_index in definitions.items()
|
|
414
|
+
}
|
|
415
|
+
active_targets: list[str] = []
|
|
416
|
+
completed_targets: set[str] = set()
|
|
417
|
+
reported_cycles: set[frozenset[str]] = set()
|
|
418
|
+
|
|
419
|
+
def visit(target: str) -> None:
|
|
420
|
+
if target in completed_targets:
|
|
421
|
+
return
|
|
422
|
+
if target in active_targets:
|
|
423
|
+
cycle = active_targets[active_targets.index(target) :] + [target]
|
|
424
|
+
cycle_members = frozenset(cycle)
|
|
425
|
+
if cycle_members not in reported_cycles:
|
|
426
|
+
reported_cycles.add(cycle_members)
|
|
427
|
+
diagnostics.append(
|
|
428
|
+
semantic_diagnostic(
|
|
429
|
+
"FLOWSPEC_SEMANTIC_DERIVE_DEPENDENCY_CYCLE",
|
|
430
|
+
json_pointer("derive", definitions[target], "from"),
|
|
431
|
+
f"derive dependency cycle: {' -> '.join(cycle)}",
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
return
|
|
435
|
+
active_targets.append(target)
|
|
436
|
+
for source in dependency_graph[target]:
|
|
437
|
+
visit(source)
|
|
438
|
+
active_targets.pop()
|
|
439
|
+
completed_targets.add(target)
|
|
440
|
+
|
|
441
|
+
for target in sorted(derive_targets):
|
|
442
|
+
visit(target)
|
|
443
|
+
|
|
444
|
+
terminal_identifier = cast(
|
|
445
|
+
str,
|
|
446
|
+
cast(dict[str, Any], document.get("terminal", {})).get("step", "terminal"),
|
|
447
|
+
)
|
|
448
|
+
ordered_node_identifiers = ["__init__"]
|
|
449
|
+
native_slot_nodes: dict[str, str] = {}
|
|
450
|
+
for path_step in document["path"]:
|
|
451
|
+
node_identifier = step_identifier(path_step, terminal_identifier)
|
|
452
|
+
if node_identifier is not None:
|
|
453
|
+
ordered_node_identifiers.append(node_identifier)
|
|
454
|
+
if "slot" in path_step or "confirm" in path_step:
|
|
455
|
+
native_slot_nodes[cast(str, path_step.get("slot", path_step.get("confirm")))] = cast(
|
|
456
|
+
str, node_identifier
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
last_inserted_for_anchor: dict[str, str] = {}
|
|
460
|
+
unresolved_placement_targets: set[str] = set()
|
|
461
|
+
for derive_index, derive_definition in enumerate(document.get("derive", [])):
|
|
462
|
+
target = cast(str, derive_definition["writes"])
|
|
463
|
+
if target in explicit_derive_nodes:
|
|
464
|
+
continue
|
|
465
|
+
derive_node_identifier = derive_node_identifiers[target]
|
|
466
|
+
anchor = derive_definition.get("after")
|
|
467
|
+
if anchor is None:
|
|
468
|
+
terminal_position = (
|
|
469
|
+
ordered_node_identifiers.index(terminal_identifier)
|
|
470
|
+
if terminal_identifier in ordered_node_identifiers
|
|
471
|
+
else len(ordered_node_identifiers)
|
|
472
|
+
)
|
|
473
|
+
ordered_node_identifiers.insert(terminal_position, derive_node_identifier)
|
|
474
|
+
continue
|
|
475
|
+
effective_anchor = last_inserted_for_anchor.get(anchor, anchor)
|
|
476
|
+
if effective_anchor not in ordered_node_identifiers:
|
|
477
|
+
if anchor not in external_steps:
|
|
478
|
+
diagnostics.append(
|
|
479
|
+
semantic_diagnostic(
|
|
480
|
+
"FLOWSPEC_SEMANTIC_DERIVE_ANCHOR_ORDER",
|
|
481
|
+
json_pointer("derive", derive_index, "after"),
|
|
482
|
+
f"derive anchor {anchor!r} is not executable before this declaration",
|
|
483
|
+
suggested_fix="Declare an anchored producer before derives that target it.",
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
unresolved_placement_targets.add(target)
|
|
487
|
+
continue
|
|
488
|
+
anchor_index = ordered_node_identifiers.index(effective_anchor)
|
|
489
|
+
ordered_node_identifiers.insert(anchor_index + 1, derive_node_identifier)
|
|
490
|
+
last_inserted_for_anchor[cast(str, anchor)] = derive_node_identifier
|
|
491
|
+
|
|
492
|
+
node_positions = {
|
|
493
|
+
node_identifier: node_index
|
|
494
|
+
for node_index, node_identifier in enumerate(ordered_node_identifiers)
|
|
495
|
+
}
|
|
496
|
+
for derive_index, derive_definition in enumerate(document.get("derive", [])):
|
|
497
|
+
target = cast(str, derive_definition["writes"])
|
|
498
|
+
if target in unresolved_placement_targets:
|
|
499
|
+
continue
|
|
500
|
+
target_node = derive_node_identifiers[target]
|
|
501
|
+
target_position = node_positions.get(target_node)
|
|
502
|
+
if target_position is None:
|
|
503
|
+
continue
|
|
504
|
+
for source_index, source_slot in enumerate(derive_definition["from"]):
|
|
505
|
+
producer_node = derive_node_identifiers.get(source_slot) or native_slot_nodes.get(
|
|
506
|
+
source_slot
|
|
507
|
+
)
|
|
508
|
+
if producer_node is None:
|
|
509
|
+
continue
|
|
510
|
+
producer_position = node_positions.get(producer_node)
|
|
511
|
+
if producer_position is None or producer_position >= target_position:
|
|
512
|
+
diagnostics.append(
|
|
513
|
+
semantic_diagnostic(
|
|
514
|
+
"FLOWSPEC_SEMANTIC_DERIVE_EXECUTION_ORDER",
|
|
515
|
+
json_pointer("derive", derive_index, "from", source_index),
|
|
516
|
+
f"derive source {source_slot!r} is produced after {target!r} would execute",
|
|
517
|
+
suggested_fix="Move the derive after every source producer.",
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
for path_index, path_step in enumerate(document["path"]):
|
|
522
|
+
if "derive" in path_step and path_step["derive"] not in definitions:
|
|
523
|
+
diagnostics.append(
|
|
524
|
+
semantic_diagnostic(
|
|
525
|
+
"FLOWSPEC_SEMANTIC_UNKNOWN_DERIVE_TARGET",
|
|
526
|
+
json_pointer("path", path_index, "derive"),
|
|
527
|
+
f"derive step {path_step['derive']!r} has no derive definition",
|
|
528
|
+
)
|
|
529
|
+
)
|
|
530
|
+
return diagnostics
|