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.
Files changed (116) hide show
  1. millforge/__init__.py +1174 -0
  2. millforge/_forge/LICENSE +21 -0
  3. millforge/_forge/PROVENANCE.json +295 -0
  4. millforge/_forge/UPDATE_POLICY.md +24 -0
  5. millforge/_forge/__init__.py +14 -0
  6. millforge/_forge/adapter.py +2232 -0
  7. millforge/_forge/base_runner.py +121 -0
  8. millforge/_forge/clients/__init__.py +10 -0
  9. millforge/_forge/clients/base.py +200 -0
  10. millforge/_forge/context/__init__.py +23 -0
  11. millforge/_forge/context/manager.py +178 -0
  12. millforge/_forge/context/strategies.py +335 -0
  13. millforge/_forge/core/__init__.py +16 -0
  14. millforge/_forge/core/inference.py +433 -0
  15. millforge/_forge/core/messages.py +119 -0
  16. millforge/_forge/core/runner.py +479 -0
  17. millforge/_forge/core/steps.py +108 -0
  18. millforge/_forge/core/workflow.py +400 -0
  19. millforge/_forge/errors.py +222 -0
  20. millforge/_forge/guardrails/__init__.py +21 -0
  21. millforge/_forge/guardrails/error_tracker.py +71 -0
  22. millforge/_forge/guardrails/guardrails.py +194 -0
  23. millforge/_forge/guardrails/nudge.py +47 -0
  24. millforge/_forge/guardrails/response_validator.py +119 -0
  25. millforge/_forge/guardrails/step_enforcer.py +183 -0
  26. millforge/_forge/prompts/__init__.py +16 -0
  27. millforge/_forge/prompts/nudges.py +95 -0
  28. millforge/_forge/prompts/templates.py +285 -0
  29. millforge/_version.py +3 -0
  30. millforge/artifacts.py +570 -0
  31. millforge/base/__init__.py +97 -0
  32. millforge/base/composition.py +402 -0
  33. millforge/base/context.py +285 -0
  34. millforge/base/harness.py +138 -0
  35. millforge/base/identity.py +465 -0
  36. millforge/base/options.py +34 -0
  37. millforge/base/platform.py +17 -0
  38. millforge/base/prompt.py +317 -0
  39. millforge/base/runner.py +546 -0
  40. millforge/compiled_plan.py +970 -0
  41. millforge/compiler/__init__.py +231 -0
  42. millforge/compiler/artifact_validation.py +257 -0
  43. millforge/compiler/canonicalization.py +169 -0
  44. millforge/compiler/capabilities.py +66 -0
  45. millforge/compiler/catalogs.py +500 -0
  46. millforge/compiler/diagnostics.py +491 -0
  47. millforge/compiler/graph.py +678 -0
  48. millforge/compiler/lowering.py +198 -0
  49. millforge/compiler/output.py +692 -0
  50. millforge/compiler/parsing.py +1424 -0
  51. millforge/compiler/requests.py +1180 -0
  52. millforge/compiler/schema_validation.py +272 -0
  53. millforge/compiler/semantic.py +490 -0
  54. millforge/compiler/service.py +448 -0
  55. millforge/compiler/source.py +375 -0
  56. millforge/compiler/validators.py +184 -0
  57. millforge/connectors/__init__.py +95 -0
  58. millforge/connectors/admission.py +801 -0
  59. millforge/connectors/broker.py +202 -0
  60. millforge/connectors/contracts.py +1159 -0
  61. millforge/connectors/diagnostics.py +189 -0
  62. millforge/connectors/fake.py +66 -0
  63. millforge/connectors/runtime.py +236 -0
  64. millforge/contracts.py +2860 -0
  65. millforge/custom_tools/__init__.py +67 -0
  66. millforge/custom_tools/compiler.py +724 -0
  67. millforge/custom_tools/contracts.py +1093 -0
  68. millforge/custom_tools/diagnostics.py +205 -0
  69. millforge/eval_artifacts.py +952 -0
  70. millforge/eval_boundary.py +2435 -0
  71. millforge/eval_fixtures/__init__.py +1 -0
  72. millforge/eval_fixtures/default_pack/__init__.py +1 -0
  73. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
  74. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
  75. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
  76. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
  77. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
  78. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
  79. millforge/eval_fixtures/default_pack/manifest.json +12 -0
  80. millforge/eval_modes.py +1282 -0
  81. millforge/eval_presets.py +1398 -0
  82. millforge/eval_reports.py +2517 -0
  83. millforge/eval_suite.py +2429 -0
  84. millforge/eval_trials.py +2632 -0
  85. millforge/eval_workflow.py +794 -0
  86. millforge/exceptions.py +122 -0
  87. millforge/model_backend.py +2098 -0
  88. millforge/protocols.py +340 -0
  89. millforge/py.typed +0 -0
  90. millforge/runtime.py +1791 -0
  91. millforge/testing/__init__.py +1089 -0
  92. millforge/tools/__init__.py +83 -0
  93. millforge/tools/builtin_runtime.py +1339 -0
  94. millforge/tools/builtins.py +773 -0
  95. millforge/tools/execution.py +1545 -0
  96. millforge/tools/path_policy.py +155 -0
  97. millforge/tools/pi_compat/PI_LICENSE +21 -0
  98. millforge/tools/pi_compat/PROVENANCE.json +55 -0
  99. millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
  100. millforge/tools/pi_compat/__init__.py +34 -0
  101. millforge/tools/pi_compat/contracts.py +49 -0
  102. millforge/tools/pi_compat/editing.py +390 -0
  103. millforge/tools/pi_compat/mutations.py +57 -0
  104. millforge/tools/pi_compat/operations.py +401 -0
  105. millforge/tools/pi_compat/paths.py +155 -0
  106. millforge/tools/pi_compat/process.py +1375 -0
  107. millforge/tools/pi_compat/search.py +738 -0
  108. millforge/tools/pi_compat/truncation.py +267 -0
  109. millforge/tools/pi_compat_catalog.py +396 -0
  110. millforge/tools/pi_compat_runtime.py +460 -0
  111. millforge/tools/registry.py +553 -0
  112. millforge/tools/results.py +533 -0
  113. millforge-0.1.0.dist-info/METADATA +844 -0
  114. millforge-0.1.0.dist-info/RECORD +116 -0
  115. millforge-0.1.0.dist-info/WHEEL +4 -0
  116. 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
+ )