millforge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- millforge/__init__.py +1174 -0
- millforge/_forge/LICENSE +21 -0
- millforge/_forge/PROVENANCE.json +295 -0
- millforge/_forge/UPDATE_POLICY.md +24 -0
- millforge/_forge/__init__.py +14 -0
- millforge/_forge/adapter.py +2232 -0
- millforge/_forge/base_runner.py +121 -0
- millforge/_forge/clients/__init__.py +10 -0
- millforge/_forge/clients/base.py +200 -0
- millforge/_forge/context/__init__.py +23 -0
- millforge/_forge/context/manager.py +178 -0
- millforge/_forge/context/strategies.py +335 -0
- millforge/_forge/core/__init__.py +16 -0
- millforge/_forge/core/inference.py +433 -0
- millforge/_forge/core/messages.py +119 -0
- millforge/_forge/core/runner.py +479 -0
- millforge/_forge/core/steps.py +108 -0
- millforge/_forge/core/workflow.py +400 -0
- millforge/_forge/errors.py +222 -0
- millforge/_forge/guardrails/__init__.py +21 -0
- millforge/_forge/guardrails/error_tracker.py +71 -0
- millforge/_forge/guardrails/guardrails.py +194 -0
- millforge/_forge/guardrails/nudge.py +47 -0
- millforge/_forge/guardrails/response_validator.py +119 -0
- millforge/_forge/guardrails/step_enforcer.py +183 -0
- millforge/_forge/prompts/__init__.py +16 -0
- millforge/_forge/prompts/nudges.py +95 -0
- millforge/_forge/prompts/templates.py +285 -0
- millforge/_version.py +3 -0
- millforge/artifacts.py +570 -0
- millforge/base/__init__.py +97 -0
- millforge/base/composition.py +402 -0
- millforge/base/context.py +285 -0
- millforge/base/harness.py +138 -0
- millforge/base/identity.py +465 -0
- millforge/base/options.py +34 -0
- millforge/base/platform.py +17 -0
- millforge/base/prompt.py +317 -0
- millforge/base/runner.py +546 -0
- millforge/compiled_plan.py +970 -0
- millforge/compiler/__init__.py +231 -0
- millforge/compiler/artifact_validation.py +257 -0
- millforge/compiler/canonicalization.py +169 -0
- millforge/compiler/capabilities.py +66 -0
- millforge/compiler/catalogs.py +500 -0
- millforge/compiler/diagnostics.py +491 -0
- millforge/compiler/graph.py +678 -0
- millforge/compiler/lowering.py +198 -0
- millforge/compiler/output.py +692 -0
- millforge/compiler/parsing.py +1424 -0
- millforge/compiler/requests.py +1180 -0
- millforge/compiler/schema_validation.py +272 -0
- millforge/compiler/semantic.py +490 -0
- millforge/compiler/service.py +448 -0
- millforge/compiler/source.py +375 -0
- millforge/compiler/validators.py +184 -0
- millforge/connectors/__init__.py +95 -0
- millforge/connectors/admission.py +801 -0
- millforge/connectors/broker.py +202 -0
- millforge/connectors/contracts.py +1159 -0
- millforge/connectors/diagnostics.py +189 -0
- millforge/connectors/fake.py +66 -0
- millforge/connectors/runtime.py +236 -0
- millforge/contracts.py +2860 -0
- millforge/custom_tools/__init__.py +67 -0
- millforge/custom_tools/compiler.py +724 -0
- millforge/custom_tools/contracts.py +1093 -0
- millforge/custom_tools/diagnostics.py +205 -0
- millforge/eval_artifacts.py +952 -0
- millforge/eval_boundary.py +2435 -0
- millforge/eval_fixtures/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
- millforge/eval_fixtures/default_pack/manifest.json +12 -0
- millforge/eval_modes.py +1282 -0
- millforge/eval_presets.py +1398 -0
- millforge/eval_reports.py +2517 -0
- millforge/eval_suite.py +2429 -0
- millforge/eval_trials.py +2632 -0
- millforge/eval_workflow.py +794 -0
- millforge/exceptions.py +122 -0
- millforge/model_backend.py +2098 -0
- millforge/protocols.py +340 -0
- millforge/py.typed +0 -0
- millforge/runtime.py +1791 -0
- millforge/testing/__init__.py +1089 -0
- millforge/tools/__init__.py +83 -0
- millforge/tools/builtin_runtime.py +1339 -0
- millforge/tools/builtins.py +773 -0
- millforge/tools/execution.py +1545 -0
- millforge/tools/path_policy.py +155 -0
- millforge/tools/pi_compat/PI_LICENSE +21 -0
- millforge/tools/pi_compat/PROVENANCE.json +55 -0
- millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
- millforge/tools/pi_compat/__init__.py +34 -0
- millforge/tools/pi_compat/contracts.py +49 -0
- millforge/tools/pi_compat/editing.py +390 -0
- millforge/tools/pi_compat/mutations.py +57 -0
- millforge/tools/pi_compat/operations.py +401 -0
- millforge/tools/pi_compat/paths.py +155 -0
- millforge/tools/pi_compat/process.py +1375 -0
- millforge/tools/pi_compat/search.py +738 -0
- millforge/tools/pi_compat/truncation.py +267 -0
- millforge/tools/pi_compat_catalog.py +396 -0
- millforge/tools/pi_compat_runtime.py +460 -0
- millforge/tools/registry.py +553 -0
- millforge/tools/results.py +533 -0
- millforge-0.1.0.dist-info/METADATA +844 -0
- millforge-0.1.0.dist-info/RECORD +116 -0
- millforge-0.1.0.dist-info/WHEEL +4 -0
- millforge-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
"""Deterministic semantic graph and argument validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import (
|
|
10
|
+
BaseModel,
|
|
11
|
+
ConfigDict,
|
|
12
|
+
Field,
|
|
13
|
+
StrictStr,
|
|
14
|
+
field_serializer,
|
|
15
|
+
field_validator,
|
|
16
|
+
model_validator,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from millforge.compiler.catalogs import ToolCatalogEntry
|
|
20
|
+
from millforge.compiler.diagnostics import (
|
|
21
|
+
CompilerDiagnostic,
|
|
22
|
+
CompilerPhase,
|
|
23
|
+
DiagnosticField,
|
|
24
|
+
DiagnosticSeverity,
|
|
25
|
+
sort_diagnostics,
|
|
26
|
+
)
|
|
27
|
+
from millforge.compiler.schema_validation import property_schema_compatibility_bytes
|
|
28
|
+
from millforge.compiler.source import HarnessNodeSource, HarnessSource
|
|
29
|
+
from millforge.compiler.validators import validate_node_id
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ResolvedNodeDescriptor(BaseModel):
|
|
33
|
+
"""Source node paired with its admitted catalog descriptor."""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(extra="forbid", frozen=True, arbitrary_types_allowed=True)
|
|
36
|
+
|
|
37
|
+
node_id: StrictStr
|
|
38
|
+
source: HarnessNodeSource
|
|
39
|
+
descriptor: ToolCatalogEntry
|
|
40
|
+
|
|
41
|
+
@field_validator("node_id")
|
|
42
|
+
@classmethod
|
|
43
|
+
def _node_id_valid(cls, value: str) -> str:
|
|
44
|
+
return validate_node_id(value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GraphValidationResult(BaseModel):
|
|
48
|
+
"""Deterministic graph validation output."""
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
51
|
+
|
|
52
|
+
diagnostics: tuple[CompilerDiagnostic, ...] = Field(default_factory=tuple)
|
|
53
|
+
terminal_node_ids: tuple[StrictStr, ...] = Field(default_factory=tuple)
|
|
54
|
+
terminal_result_map: Mapping[StrictStr, StrictStr] = Field(default_factory=dict)
|
|
55
|
+
required_node_ids: tuple[StrictStr, ...] = Field(default_factory=tuple)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def ok(self) -> bool:
|
|
59
|
+
return not self.diagnostics
|
|
60
|
+
|
|
61
|
+
@model_validator(mode="after")
|
|
62
|
+
def _freeze_terminal_result_map(self) -> GraphValidationResult:
|
|
63
|
+
object.__setattr__(
|
|
64
|
+
self,
|
|
65
|
+
"terminal_result_map",
|
|
66
|
+
MappingProxyType(dict(sorted(self.terminal_result_map.items()))),
|
|
67
|
+
)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
@field_serializer("terminal_result_map")
|
|
71
|
+
def _serialize_terminal_result_map(
|
|
72
|
+
self, value: Mapping[StrictStr, StrictStr]
|
|
73
|
+
) -> dict[str, str]:
|
|
74
|
+
return dict(value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_harness_graph(
|
|
78
|
+
source: HarnessSource,
|
|
79
|
+
resolved_nodes: Mapping[str, ToolCatalogEntry | ResolvedNodeDescriptor],
|
|
80
|
+
*,
|
|
81
|
+
allowed_terminal_results: Iterable[str] | None = None,
|
|
82
|
+
) -> GraphValidationResult:
|
|
83
|
+
"""Validate source graph topology and top-level argument matches."""
|
|
84
|
+
nodes_by_id = {node.node_id: node for node in source.graph.nodes}
|
|
85
|
+
resolved_by_id = _resolved_entries(resolved_nodes)
|
|
86
|
+
allowed_results = (
|
|
87
|
+
None
|
|
88
|
+
if allowed_terminal_results is None
|
|
89
|
+
else frozenset(allowed_terminal_results)
|
|
90
|
+
)
|
|
91
|
+
diagnostics: list[CompilerDiagnostic] = []
|
|
92
|
+
|
|
93
|
+
duplicate_edges = _duplicate_prerequisite_pairs(source.graph.nodes)
|
|
94
|
+
unknown_edges = _unknown_prerequisite_pairs(source.graph.nodes, nodes_by_id)
|
|
95
|
+
self_edges = _self_prerequisite_pairs(source.graph.nodes)
|
|
96
|
+
terminal_as_prereq_edges = _terminal_as_prerequisite_pairs(
|
|
97
|
+
source.graph.nodes, nodes_by_id
|
|
98
|
+
)
|
|
99
|
+
invalid_edges = (
|
|
100
|
+
duplicate_edges | unknown_edges | self_edges | terminal_as_prereq_edges
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
diagnostics.extend(
|
|
104
|
+
_diagnostic(
|
|
105
|
+
"MF-G001",
|
|
106
|
+
f"Node {dependent!r} references unknown prerequisite {prereq!r}.",
|
|
107
|
+
node_id=dependent,
|
|
108
|
+
fields={"prerequisite": prereq},
|
|
109
|
+
)
|
|
110
|
+
for dependent, prereq in sorted(unknown_edges)
|
|
111
|
+
)
|
|
112
|
+
diagnostics.extend(
|
|
113
|
+
_diagnostic(
|
|
114
|
+
"MF-G002",
|
|
115
|
+
f"Node {node_id!r} cannot require itself.",
|
|
116
|
+
node_id=node_id,
|
|
117
|
+
)
|
|
118
|
+
for node_id, _ in sorted(self_edges)
|
|
119
|
+
)
|
|
120
|
+
diagnostics.extend(
|
|
121
|
+
_diagnostic(
|
|
122
|
+
"MF-G003",
|
|
123
|
+
f"Node {dependent!r} declares duplicate prerequisite {prereq!r}.",
|
|
124
|
+
node_id=dependent,
|
|
125
|
+
fields={"prerequisite": prereq},
|
|
126
|
+
)
|
|
127
|
+
for dependent, prereq in sorted(duplicate_edges)
|
|
128
|
+
)
|
|
129
|
+
diagnostics.extend(
|
|
130
|
+
_diagnostic(
|
|
131
|
+
"MF-G013",
|
|
132
|
+
f"Terminal node {prereq!r} cannot be a prerequisite.",
|
|
133
|
+
node_id=dependent,
|
|
134
|
+
related_ids=(prereq,),
|
|
135
|
+
)
|
|
136
|
+
for dependent, prereq in sorted(terminal_as_prereq_edges)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
adjacency = _valid_adjacency(source.graph.nodes, invalid_edges)
|
|
140
|
+
cycle_nodes = _cycle_nodes(adjacency)
|
|
141
|
+
for component in _cycle_components(adjacency):
|
|
142
|
+
diagnostics.append(
|
|
143
|
+
_diagnostic(
|
|
144
|
+
"MF-G004",
|
|
145
|
+
f"Prerequisite cycle includes {', '.join(component)}.",
|
|
146
|
+
node_id=component[0],
|
|
147
|
+
related_ids=component[1:],
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
terminal_nodes = sorted(
|
|
152
|
+
(node for node in source.graph.nodes if node.terminal_result is not None),
|
|
153
|
+
key=lambda item: item.node_id,
|
|
154
|
+
)
|
|
155
|
+
terminal_node_ids = tuple(node.node_id for node in terminal_nodes)
|
|
156
|
+
terminal_result_map = {
|
|
157
|
+
node.node_id: node.terminal_result
|
|
158
|
+
for node in terminal_nodes
|
|
159
|
+
if node.terminal_result is not None
|
|
160
|
+
}
|
|
161
|
+
required_node_ids = tuple(
|
|
162
|
+
sorted(node.node_id for node in source.graph.nodes if node.required)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if not terminal_nodes:
|
|
166
|
+
diagnostics.append(
|
|
167
|
+
_diagnostic("MF-G011", "Graph must contain at least one terminal node.")
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
terminal_results: dict[str, str] = {}
|
|
171
|
+
duplicate_terminal_results: set[str] = set()
|
|
172
|
+
seen_terminal_results: set[str] = set()
|
|
173
|
+
for node in terminal_nodes:
|
|
174
|
+
assert node.terminal_result is not None
|
|
175
|
+
seen_terminal_results.add(node.terminal_result)
|
|
176
|
+
if allowed_results is not None and node.terminal_result not in allowed_results:
|
|
177
|
+
diagnostics.append(
|
|
178
|
+
_diagnostic(
|
|
179
|
+
"MF-G009",
|
|
180
|
+
f"Terminal result {node.terminal_result!r} is not allowed.",
|
|
181
|
+
node_id=node.node_id,
|
|
182
|
+
fields={"terminal_result": node.terminal_result},
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
previous = terminal_results.setdefault(node.terminal_result, node.node_id)
|
|
186
|
+
if previous != node.node_id:
|
|
187
|
+
duplicate_terminal_results.add(node.terminal_result)
|
|
188
|
+
for terminal_result in sorted(duplicate_terminal_results):
|
|
189
|
+
related_ids = tuple(
|
|
190
|
+
node.node_id
|
|
191
|
+
for node in terminal_nodes
|
|
192
|
+
if node.terminal_result == terminal_result
|
|
193
|
+
)
|
|
194
|
+
diagnostics.append(
|
|
195
|
+
_diagnostic(
|
|
196
|
+
"MF-G010",
|
|
197
|
+
f"Terminal result {terminal_result!r} is used by multiple nodes.",
|
|
198
|
+
node_id=related_ids[0],
|
|
199
|
+
related_ids=related_ids[1:],
|
|
200
|
+
fields={"terminal_result": terminal_result},
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
if allowed_results is not None:
|
|
204
|
+
for terminal_result in sorted(allowed_results - seen_terminal_results):
|
|
205
|
+
diagnostics.append(
|
|
206
|
+
_diagnostic(
|
|
207
|
+
"MF-G011",
|
|
208
|
+
f"Terminal result {terminal_result!r} has no terminal node.",
|
|
209
|
+
fields={"terminal_result": terminal_result},
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
for node in sorted(source.graph.nodes, key=lambda item: item.node_id):
|
|
214
|
+
if node.required and node.terminal_result is not None:
|
|
215
|
+
diagnostics.append(
|
|
216
|
+
_diagnostic(
|
|
217
|
+
"MF-G008",
|
|
218
|
+
f"Terminal node {node.node_id!r} cannot be required.",
|
|
219
|
+
node_id=node.node_id,
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
satisfiable = _satisfiable_nodes(nodes_by_id, adjacency, cycle_nodes, invalid_edges)
|
|
224
|
+
unresolved_blocked = _nodes_blocked_by_unresolved(
|
|
225
|
+
nodes_by_id, adjacency, resolved_by_id
|
|
226
|
+
)
|
|
227
|
+
required_ancestors_by_terminal = _required_ancestor_gaps(
|
|
228
|
+
nodes_by_id,
|
|
229
|
+
adjacency,
|
|
230
|
+
terminal_node_ids=terminal_node_ids,
|
|
231
|
+
required_node_ids=required_node_ids,
|
|
232
|
+
)
|
|
233
|
+
for node in sorted(source.graph.nodes, key=lambda item: item.node_id):
|
|
234
|
+
if node.node_id in cycle_nodes:
|
|
235
|
+
continue
|
|
236
|
+
if node.node_id in unresolved_blocked:
|
|
237
|
+
continue
|
|
238
|
+
if any(
|
|
239
|
+
(node.node_id, prereq.node_id) in invalid_edges
|
|
240
|
+
for prereq in node.prerequisites
|
|
241
|
+
):
|
|
242
|
+
continue
|
|
243
|
+
if node.node_id not in satisfiable:
|
|
244
|
+
if node.terminal_result is not None:
|
|
245
|
+
code = "MF-G006"
|
|
246
|
+
elif node.required:
|
|
247
|
+
code = "MF-G007"
|
|
248
|
+
else:
|
|
249
|
+
code = "MF-G005"
|
|
250
|
+
diagnostics.append(
|
|
251
|
+
_diagnostic(
|
|
252
|
+
code,
|
|
253
|
+
f"Node {node.node_id!r} is not reachable from graph roots.",
|
|
254
|
+
node_id=node.node_id,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
continue
|
|
258
|
+
if node.terminal_result is not None:
|
|
259
|
+
missing_required = required_ancestors_by_terminal.get(node.node_id, ())
|
|
260
|
+
if (
|
|
261
|
+
node.node_id not in required_node_ids
|
|
262
|
+
and missing_required
|
|
263
|
+
and all(required_id in satisfiable for required_id in missing_required)
|
|
264
|
+
and not any(
|
|
265
|
+
(node.node_id, prereq.node_id) in invalid_edges
|
|
266
|
+
for prereq in node.prerequisites
|
|
267
|
+
)
|
|
268
|
+
):
|
|
269
|
+
diagnostics.append(
|
|
270
|
+
_diagnostic(
|
|
271
|
+
"MF-G006",
|
|
272
|
+
f"Terminal node {node.node_id!r} is not gated by required nodes.",
|
|
273
|
+
node_id=node.node_id,
|
|
274
|
+
related_ids=missing_required,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
diagnostics.extend(
|
|
279
|
+
_argument_diagnostics(
|
|
280
|
+
source.graph.nodes,
|
|
281
|
+
nodes_by_id,
|
|
282
|
+
resolved_by_id,
|
|
283
|
+
invalid_edges=invalid_edges,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return GraphValidationResult(
|
|
288
|
+
diagnostics=sort_diagnostics(diagnostics),
|
|
289
|
+
terminal_node_ids=terminal_node_ids,
|
|
290
|
+
terminal_result_map=terminal_result_map,
|
|
291
|
+
required_node_ids=required_node_ids,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _resolved_entries(
|
|
296
|
+
resolved_nodes: Mapping[str, ToolCatalogEntry | ResolvedNodeDescriptor],
|
|
297
|
+
) -> dict[str, ToolCatalogEntry]:
|
|
298
|
+
entries: dict[str, ToolCatalogEntry] = {}
|
|
299
|
+
for node_id, value in resolved_nodes.items():
|
|
300
|
+
entries[node_id] = (
|
|
301
|
+
value.descriptor if isinstance(value, ResolvedNodeDescriptor) else value
|
|
302
|
+
)
|
|
303
|
+
return entries
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _duplicate_prerequisite_pairs(
|
|
307
|
+
nodes: Sequence[HarnessNodeSource],
|
|
308
|
+
) -> set[tuple[str, str]]:
|
|
309
|
+
duplicates: set[tuple[str, str]] = set()
|
|
310
|
+
for node in nodes:
|
|
311
|
+
seen: set[str] = set()
|
|
312
|
+
for prerequisite in node.prerequisites:
|
|
313
|
+
if prerequisite.node_id in seen:
|
|
314
|
+
duplicates.add((node.node_id, prerequisite.node_id))
|
|
315
|
+
seen.add(prerequisite.node_id)
|
|
316
|
+
return duplicates
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _unknown_prerequisite_pairs(
|
|
320
|
+
nodes: Sequence[HarnessNodeSource],
|
|
321
|
+
nodes_by_id: Mapping[str, HarnessNodeSource],
|
|
322
|
+
) -> set[tuple[str, str]]:
|
|
323
|
+
return {
|
|
324
|
+
(node.node_id, prerequisite.node_id)
|
|
325
|
+
for node in nodes
|
|
326
|
+
for prerequisite in node.prerequisites
|
|
327
|
+
if prerequisite.node_id not in nodes_by_id
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _self_prerequisite_pairs(
|
|
332
|
+
nodes: Sequence[HarnessNodeSource],
|
|
333
|
+
) -> set[tuple[str, str]]:
|
|
334
|
+
return {
|
|
335
|
+
(node.node_id, prerequisite.node_id)
|
|
336
|
+
for node in nodes
|
|
337
|
+
for prerequisite in node.prerequisites
|
|
338
|
+
if prerequisite.node_id == node.node_id
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _terminal_as_prerequisite_pairs(
|
|
343
|
+
nodes: Sequence[HarnessNodeSource],
|
|
344
|
+
nodes_by_id: Mapping[str, HarnessNodeSource],
|
|
345
|
+
) -> set[tuple[str, str]]:
|
|
346
|
+
return {
|
|
347
|
+
(node.node_id, prerequisite.node_id)
|
|
348
|
+
for node in nodes
|
|
349
|
+
for prerequisite in node.prerequisites
|
|
350
|
+
if (
|
|
351
|
+
prerequisite.node_id in nodes_by_id
|
|
352
|
+
and nodes_by_id[prerequisite.node_id].terminal_result is not None
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _valid_adjacency(
|
|
358
|
+
nodes: Sequence[HarnessNodeSource],
|
|
359
|
+
invalid_edges: set[tuple[str, str]],
|
|
360
|
+
) -> dict[str, tuple[str, ...]]:
|
|
361
|
+
adjacency: dict[str, list[str]] = {node.node_id: [] for node in nodes}
|
|
362
|
+
for node in nodes:
|
|
363
|
+
for prerequisite in node.prerequisites:
|
|
364
|
+
if (node.node_id, prerequisite.node_id) not in invalid_edges:
|
|
365
|
+
adjacency[prerequisite.node_id].append(node.node_id)
|
|
366
|
+
return {key: tuple(sorted(set(value))) for key, value in adjacency.items()}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _reverse_adjacency(
|
|
370
|
+
nodes_by_id: Mapping[str, HarnessNodeSource],
|
|
371
|
+
adjacency: Mapping[str, tuple[str, ...]],
|
|
372
|
+
) -> dict[str, tuple[str, ...]]:
|
|
373
|
+
reverse: dict[str, list[str]] = {node_id: [] for node_id in nodes_by_id}
|
|
374
|
+
for prerequisite, dependents in adjacency.items():
|
|
375
|
+
for dependent in dependents:
|
|
376
|
+
reverse[dependent].append(prerequisite)
|
|
377
|
+
return {key: tuple(sorted(value)) for key, value in reverse.items()}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _cycle_nodes(adjacency: Mapping[str, tuple[str, ...]]) -> set[str]:
|
|
381
|
+
return {
|
|
382
|
+
node_id for component in _cycle_components(adjacency) for node_id in component
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _cycle_components(
|
|
387
|
+
adjacency: Mapping[str, tuple[str, ...]],
|
|
388
|
+
) -> list[tuple[str, ...]]:
|
|
389
|
+
index = 0
|
|
390
|
+
stack: list[str] = []
|
|
391
|
+
indices: dict[str, int] = {}
|
|
392
|
+
lowlinks: dict[str, int] = {}
|
|
393
|
+
on_stack: set[str] = set()
|
|
394
|
+
components: list[tuple[str, ...]] = []
|
|
395
|
+
|
|
396
|
+
def visit(node_id: str) -> None:
|
|
397
|
+
nonlocal index
|
|
398
|
+
indices[node_id] = index
|
|
399
|
+
lowlinks[node_id] = index
|
|
400
|
+
index += 1
|
|
401
|
+
stack.append(node_id)
|
|
402
|
+
on_stack.add(node_id)
|
|
403
|
+
for dependent in adjacency.get(node_id, ()):
|
|
404
|
+
if dependent not in indices:
|
|
405
|
+
visit(dependent)
|
|
406
|
+
lowlinks[node_id] = min(lowlinks[node_id], lowlinks[dependent])
|
|
407
|
+
elif dependent in on_stack:
|
|
408
|
+
lowlinks[node_id] = min(lowlinks[node_id], indices[dependent])
|
|
409
|
+
if lowlinks[node_id] != indices[node_id]:
|
|
410
|
+
return
|
|
411
|
+
component: list[str] = []
|
|
412
|
+
while True:
|
|
413
|
+
member = stack.pop()
|
|
414
|
+
on_stack.remove(member)
|
|
415
|
+
component.append(member)
|
|
416
|
+
if member == node_id:
|
|
417
|
+
break
|
|
418
|
+
if len(component) > 1:
|
|
419
|
+
components.append(tuple(sorted(component)))
|
|
420
|
+
|
|
421
|
+
for node_id in sorted(adjacency):
|
|
422
|
+
if node_id not in indices:
|
|
423
|
+
visit(node_id)
|
|
424
|
+
return sorted(components)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _satisfiable_nodes(
|
|
428
|
+
nodes_by_id: Mapping[str, HarnessNodeSource],
|
|
429
|
+
adjacency: Mapping[str, tuple[str, ...]],
|
|
430
|
+
cycle_nodes: set[str],
|
|
431
|
+
invalid_edges: set[tuple[str, str]],
|
|
432
|
+
) -> set[str]:
|
|
433
|
+
reverse = _reverse_adjacency(nodes_by_id, adjacency)
|
|
434
|
+
memo: dict[str, bool] = {}
|
|
435
|
+
|
|
436
|
+
def satisfiable(node_id: str) -> bool:
|
|
437
|
+
if node_id in memo:
|
|
438
|
+
return memo[node_id]
|
|
439
|
+
if node_id in cycle_nodes:
|
|
440
|
+
memo[node_id] = False
|
|
441
|
+
return False
|
|
442
|
+
node = nodes_by_id[node_id]
|
|
443
|
+
if any(
|
|
444
|
+
(node.node_id, prereq.node_id) in invalid_edges
|
|
445
|
+
for prereq in node.prerequisites
|
|
446
|
+
):
|
|
447
|
+
memo[node_id] = False
|
|
448
|
+
return False
|
|
449
|
+
memo[node_id] = all(satisfiable(prereq_id) for prereq_id in reverse[node_id])
|
|
450
|
+
return memo[node_id]
|
|
451
|
+
|
|
452
|
+
return {node_id for node_id in sorted(nodes_by_id) if satisfiable(node_id)}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _nodes_blocked_by_unresolved(
|
|
456
|
+
nodes_by_id: Mapping[str, HarnessNodeSource],
|
|
457
|
+
adjacency: Mapping[str, tuple[str, ...]],
|
|
458
|
+
resolved_by_id: Mapping[str, ToolCatalogEntry],
|
|
459
|
+
) -> set[str]:
|
|
460
|
+
unresolved = set(nodes_by_id) - set(resolved_by_id)
|
|
461
|
+
if not unresolved:
|
|
462
|
+
return set()
|
|
463
|
+
blocked = set(unresolved)
|
|
464
|
+
stack = sorted(unresolved)
|
|
465
|
+
while stack:
|
|
466
|
+
current = stack.pop()
|
|
467
|
+
for dependent in adjacency.get(current, ()):
|
|
468
|
+
if dependent in blocked:
|
|
469
|
+
continue
|
|
470
|
+
blocked.add(dependent)
|
|
471
|
+
stack.append(dependent)
|
|
472
|
+
return blocked
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _required_ancestor_gaps(
|
|
476
|
+
nodes_by_id: Mapping[str, HarnessNodeSource],
|
|
477
|
+
adjacency: Mapping[str, tuple[str, ...]],
|
|
478
|
+
*,
|
|
479
|
+
terminal_node_ids: Sequence[str],
|
|
480
|
+
required_node_ids: Sequence[str],
|
|
481
|
+
) -> dict[str, tuple[str, ...]]:
|
|
482
|
+
reverse = _reverse_adjacency(nodes_by_id, adjacency)
|
|
483
|
+
required = set(required_node_ids)
|
|
484
|
+
gaps: dict[str, tuple[str, ...]] = {}
|
|
485
|
+
for terminal_node_id in sorted(terminal_node_ids):
|
|
486
|
+
ancestors: set[str] = set()
|
|
487
|
+
stack = list(reverse[terminal_node_id])
|
|
488
|
+
while stack:
|
|
489
|
+
current = stack.pop()
|
|
490
|
+
if current in ancestors:
|
|
491
|
+
continue
|
|
492
|
+
ancestors.add(current)
|
|
493
|
+
stack.extend(reverse[current])
|
|
494
|
+
missing = tuple(sorted(required - ancestors - {terminal_node_id}))
|
|
495
|
+
if missing:
|
|
496
|
+
gaps[terminal_node_id] = missing
|
|
497
|
+
return gaps
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _argument_diagnostics(
|
|
501
|
+
nodes: Sequence[HarnessNodeSource],
|
|
502
|
+
nodes_by_id: Mapping[str, HarnessNodeSource],
|
|
503
|
+
resolved_by_id: Mapping[str, ToolCatalogEntry],
|
|
504
|
+
*,
|
|
505
|
+
invalid_edges: set[tuple[str, str]],
|
|
506
|
+
) -> list[CompilerDiagnostic]:
|
|
507
|
+
diagnostics: list[CompilerDiagnostic] = []
|
|
508
|
+
for node in sorted(nodes, key=lambda item: item.node_id):
|
|
509
|
+
current_descriptor = resolved_by_id.get(node.node_id)
|
|
510
|
+
if current_descriptor is None:
|
|
511
|
+
continue
|
|
512
|
+
current_required = _required_properties(current_descriptor.input_schema)
|
|
513
|
+
current_properties = _schema_properties(current_descriptor.input_schema)
|
|
514
|
+
for prerequisite in sorted(node.prerequisites, key=lambda item: item.node_id):
|
|
515
|
+
if (node.node_id, prerequisite.node_id) in invalid_edges:
|
|
516
|
+
continue
|
|
517
|
+
prior_descriptor = resolved_by_id.get(prerequisite.node_id)
|
|
518
|
+
if prior_descriptor is None or prerequisite.node_id not in nodes_by_id:
|
|
519
|
+
continue
|
|
520
|
+
prior_required = _required_properties(prior_descriptor.input_schema)
|
|
521
|
+
prior_properties = _schema_properties(prior_descriptor.input_schema)
|
|
522
|
+
diagnostics.extend(
|
|
523
|
+
_argument_edge_diagnostics(
|
|
524
|
+
node_id=node.node_id,
|
|
525
|
+
prerequisite_id=prerequisite.node_id,
|
|
526
|
+
matches=prerequisite.argument_matches,
|
|
527
|
+
prior_required=prior_required,
|
|
528
|
+
prior_properties=prior_properties,
|
|
529
|
+
current_required=current_required,
|
|
530
|
+
current_properties=current_properties,
|
|
531
|
+
)
|
|
532
|
+
)
|
|
533
|
+
return diagnostics
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _argument_edge_diagnostics(
|
|
537
|
+
*,
|
|
538
|
+
node_id: str,
|
|
539
|
+
prerequisite_id: str,
|
|
540
|
+
matches: Sequence[Any],
|
|
541
|
+
prior_required: set[str],
|
|
542
|
+
prior_properties: Mapping[str, Any],
|
|
543
|
+
current_required: set[str],
|
|
544
|
+
current_properties: Mapping[str, Any],
|
|
545
|
+
) -> list[CompilerDiagnostic]:
|
|
546
|
+
diagnostics: list[CompilerDiagnostic] = []
|
|
547
|
+
prior_seen: dict[str, str] = {}
|
|
548
|
+
current_seen: dict[str, str] = {}
|
|
549
|
+
for match in sorted(
|
|
550
|
+
matches, key=lambda item: (item.prior_argument, item.current_argument)
|
|
551
|
+
):
|
|
552
|
+
fields = {
|
|
553
|
+
"prerequisite": prerequisite_id,
|
|
554
|
+
"prior_argument": match.prior_argument,
|
|
555
|
+
"current_argument": match.current_argument,
|
|
556
|
+
}
|
|
557
|
+
if (
|
|
558
|
+
match.prior_argument in prior_seen
|
|
559
|
+
and prior_seen[match.prior_argument] != match.current_argument
|
|
560
|
+
):
|
|
561
|
+
diagnostics.append(
|
|
562
|
+
_diagnostic(
|
|
563
|
+
"MF-G012",
|
|
564
|
+
"Argument match fans out from one prerequisite argument.",
|
|
565
|
+
node_id=node_id,
|
|
566
|
+
related_ids=(prerequisite_id,),
|
|
567
|
+
fields=fields,
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
if (
|
|
571
|
+
match.current_argument in current_seen
|
|
572
|
+
and current_seen[match.current_argument] != match.prior_argument
|
|
573
|
+
):
|
|
574
|
+
diagnostics.append(
|
|
575
|
+
_diagnostic(
|
|
576
|
+
"MF-G012",
|
|
577
|
+
"Argument match fans in to one current argument.",
|
|
578
|
+
node_id=node_id,
|
|
579
|
+
related_ids=(prerequisite_id,),
|
|
580
|
+
fields=fields,
|
|
581
|
+
)
|
|
582
|
+
)
|
|
583
|
+
prior_seen.setdefault(match.prior_argument, match.current_argument)
|
|
584
|
+
current_seen.setdefault(match.current_argument, match.prior_argument)
|
|
585
|
+
|
|
586
|
+
if match.prior_argument not in prior_properties:
|
|
587
|
+
diagnostics.append(
|
|
588
|
+
_diagnostic(
|
|
589
|
+
"MF-G012",
|
|
590
|
+
"Argument match references unknown prerequisite argument.",
|
|
591
|
+
node_id=node_id,
|
|
592
|
+
related_ids=(prerequisite_id,),
|
|
593
|
+
fields=fields,
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
continue
|
|
597
|
+
if match.current_argument not in current_properties:
|
|
598
|
+
diagnostics.append(
|
|
599
|
+
_diagnostic(
|
|
600
|
+
"MF-G012",
|
|
601
|
+
"Argument match references unknown current argument.",
|
|
602
|
+
node_id=node_id,
|
|
603
|
+
related_ids=(prerequisite_id,),
|
|
604
|
+
fields=fields,
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
continue
|
|
608
|
+
if match.prior_argument not in prior_required:
|
|
609
|
+
diagnostics.append(
|
|
610
|
+
_diagnostic(
|
|
611
|
+
"MF-G012",
|
|
612
|
+
"Prerequisite argument match source must be required.",
|
|
613
|
+
node_id=node_id,
|
|
614
|
+
related_ids=(prerequisite_id,),
|
|
615
|
+
fields=fields,
|
|
616
|
+
)
|
|
617
|
+
)
|
|
618
|
+
continue
|
|
619
|
+
if match.current_argument not in current_required:
|
|
620
|
+
diagnostics.append(
|
|
621
|
+
_diagnostic(
|
|
622
|
+
"MF-G012",
|
|
623
|
+
"Current argument match target must be required.",
|
|
624
|
+
node_id=node_id,
|
|
625
|
+
related_ids=(prerequisite_id,),
|
|
626
|
+
fields=fields,
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
continue
|
|
630
|
+
if property_schema_compatibility_bytes(
|
|
631
|
+
prior_properties[match.prior_argument]
|
|
632
|
+
) != (
|
|
633
|
+
property_schema_compatibility_bytes(
|
|
634
|
+
current_properties[match.current_argument]
|
|
635
|
+
)
|
|
636
|
+
):
|
|
637
|
+
diagnostics.append(
|
|
638
|
+
_diagnostic(
|
|
639
|
+
"MF-G012",
|
|
640
|
+
"Argument match schemas are not compatible.",
|
|
641
|
+
node_id=node_id,
|
|
642
|
+
related_ids=(prerequisite_id,),
|
|
643
|
+
fields=fields,
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
return diagnostics
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _schema_properties(schema: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
650
|
+
properties = schema.get("properties")
|
|
651
|
+
return properties if isinstance(properties, Mapping) else {}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _required_properties(schema: Mapping[str, Any]) -> set[str]:
|
|
655
|
+
required = schema.get("required")
|
|
656
|
+
return set(required) if isinstance(required, tuple | list) else set()
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _diagnostic(
|
|
660
|
+
code: str,
|
|
661
|
+
message: str,
|
|
662
|
+
*,
|
|
663
|
+
node_id: str | None = None,
|
|
664
|
+
related_ids: tuple[str, ...] = (),
|
|
665
|
+
fields: Mapping[str, str] | None = None,
|
|
666
|
+
) -> CompilerDiagnostic:
|
|
667
|
+
return CompilerDiagnostic(
|
|
668
|
+
code=code,
|
|
669
|
+
phase=CompilerPhase.GRAPH,
|
|
670
|
+
severity=DiagnosticSeverity.ERROR,
|
|
671
|
+
message=message,
|
|
672
|
+
node_id=node_id,
|
|
673
|
+
related_ids=related_ids,
|
|
674
|
+
fields=tuple(
|
|
675
|
+
DiagnosticField(key=key, value=value)
|
|
676
|
+
for key, value in sorted((fields or {}).items())
|
|
677
|
+
),
|
|
678
|
+
)
|